This commit is contained in:
ryan
2026-07-13 15:08:40 +08:00
parent 08e8eea932
commit 1eff7878a1
441 changed files with 26491 additions and 18405 deletions
@@ -1,105 +1,265 @@
import {describe, expect, it} from 'vitest';
import { describe, expect, it } from 'vitest';
import type {WAFRuleGraph} from '@/lib/services/openflare';
import type { WAFRuleGraph } from '@/lib/services/openflare';
import {removeEdgeFromGraph, removeNodeFromGraph, validateGraph, wouldCreateCycle} from './graph-validation';
import {
removeEdgeFromGraph,
removeNodeFromGraph,
validateGraph,
wouldCreateCycle,
} from './graph-validation';
const validGraph = (): WAFRuleGraph => ({
schema_version: 1,
nodes: [
{id: 'start', type: 'start', position: {x: 0, y: 0}, config: {}},
{id: 'match', type: 'ip_match', position: {x: 240, y: 0}, config: {ips: ['127.0.0.1'], cidrs: [], ip_group_ids: []}},
{id: 'allow', type: 'allow', position: {x: 520, y: -80}, config: {}},
{id: 'block', type: 'block', position: {x: 520, y: 100}, config: {status_code: 403, response_body: ''}},
{ id: 'start', type: 'start', position: { x: 0, y: 0 }, config: {} },
{
id: 'match',
type: 'ip_match',
position: { x: 240, y: 0 },
config: { ips: ['127.0.0.1'], cidrs: [], ip_group_ids: [] },
},
{ id: 'allow', type: 'allow', position: { x: 520, y: -80 }, config: {} },
{
id: 'block',
type: 'block',
position: { x: 520, y: 100 },
config: { status_code: 403, response_body: '' },
},
],
edges: [
{id: 'start-match', source: 'start', source_handle: 'next', target: 'match'},
{id: 'match-allow', source: 'match', source_handle: 'true', target: 'allow'},
{id: 'match-block', source: 'match', source_handle: 'false', target: 'block'},
{
id: 'start-match',
source: 'start',
source_handle: 'next',
target: 'match',
},
{
id: 'match-allow',
source: 'match',
source_handle: 'true',
target: 'allow',
},
{
id: 'match-block',
source: 'match',
source_handle: 'false',
target: 'block',
},
],
});
describe('validateGraph', () => {
it('accepts a complete terminating graph', () => expect(validateGraph(validGraph())).toEqual([]));
it('accepts a complete terminating graph', () =>
expect(validateGraph(validGraph())).toEqual([]));
it('requires exactly one start and allow node', () => {
const graph = validGraph();
graph.nodes = graph.nodes.filter((node) => node.type !== 'allow');
expect(validateGraph(graph).map((issue) => issue.code)).toContain('allow_count');
expect(validateGraph(graph).map((issue) => issue.code)).toContain(
'allow_count',
);
});
it('requires every source handle', () => {
const graph = validGraph();
graph.edges = graph.edges.filter((edge) => edge.source_handle !== 'false');
expect(validateGraph(graph)).toContainEqual(expect.objectContaining({code: 'missing_handle', nodeId: 'match'}));
expect(validateGraph(graph)).toContainEqual(
expect.objectContaining({ code: 'missing_handle', nodeId: 'match' }),
);
});
it('rejects cycles', () => {
const graph = validGraph();
graph.edges.push({id: 'cycle', source: 'block', source_handle: 'next', target: 'match'});
graph.edges.push({
id: 'cycle',
source: 'block',
source_handle: 'next',
target: 'match',
});
expect(validateGraph(graph).map((issue) => issue.code)).toContain('cycle');
});
it('reports unreachable nodes and paths without a terminal', () => {
const graph = validGraph();
graph.nodes.push({id: 'orphan', type: 'pow', position: {x: 0, y: 200}, config: {algorithm: 'fast', difficulty: 4, session_ttl: 60, challenge_ttl: 30}});
expect(validateGraph(graph)).toEqual(expect.arrayContaining([
expect.objectContaining({code: 'unreachable', nodeId: 'orphan'}),
expect.objectContaining({code: 'non_terminating', nodeId: 'orphan'}),
]));
graph.nodes.push({
id: 'orphan',
type: 'pow',
position: { x: 0, y: 200 },
config: {
algorithm: 'fast',
difficulty: 4,
session_ttl: 60,
challenge_ttl: 30,
},
});
expect(validateGraph(graph)).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'unreachable', nodeId: 'orphan' }),
expect.objectContaining({ code: 'non_terminating', nodeId: 'orphan' }),
]),
);
});
it('rejects duplicate identifiers and start incoming edges', () => {
const graph = validGraph();
graph.nodes.push({...graph.nodes[1]});
graph.edges.push({...graph.edges[0]}, {id: 'into-start', source: 'match', source_handle: 'true', target: 'start'});
expect(validateGraph(graph)).toEqual(expect.arrayContaining([
expect.objectContaining({code: 'duplicate_node_id', nodeId: 'match'}),
expect.objectContaining({code: 'duplicate_edge_id', edgeId: 'start-match'}),
expect.objectContaining({code: 'start_incoming', edgeId: 'into-start'}),
]));
graph.nodes.push({ ...graph.nodes[1] });
graph.edges.push(
{ ...graph.edges[0] },
{
id: 'into-start',
source: 'match',
source_handle: 'true',
target: 'start',
},
);
expect(validateGraph(graph)).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'duplicate_node_id', nodeId: 'match' }),
expect.objectContaining({
code: 'duplicate_edge_id',
edgeId: 'start-match',
}),
expect.objectContaining({
code: 'start_incoming',
edgeId: 'into-start',
}),
]),
);
});
it('validates typed node configuration locally', () => {
const graph = validGraph();
graph.nodes = graph.nodes.map((node) => node.type === 'ip_match' ? {...node, config: {ips: ['999.1.1.1'], cidrs: ['broken'], ip_group_ids: [-1]}} : node.type === 'block' ? {...node, config: {status_code: 200, response_body: 'x'.repeat(65_537)}} : node);
expect(validateGraph(graph).filter((issue) => issue.code === 'invalid_config').map((issue) => issue.nodeId)).toEqual(expect.arrayContaining(['match', 'block']));
graph.nodes = graph.nodes.map((node) =>
node.type === 'ip_match'
? {
...node,
config: {
ips: ['999.1.1.1'],
cidrs: ['broken'],
ip_group_ids: [-1],
},
}
: node.type === 'block'
? {
...node,
config: { status_code: 200, response_body: 'x'.repeat(65_537) },
}
: node,
);
expect(
validateGraph(graph)
.filter((issue) => issue.code === 'invalid_config')
.map((issue) => issue.nodeId),
).toEqual(expect.arrayContaining(['match', 'block']));
});
it('validates PoW bounds and geography codes', () => {
const graph = validGraph();
graph.nodes.push({id: 'pow', type: 'pow', position: {x: 0, y: 0}, config: {algorithm: 'fast', difficulty: 0, session_ttl: 0, challenge_ttl: 0}});
graph.nodes.push({id: 'geo', type: 'geo_match', position: {x: 0, y: 0}, config: {countries: ['china'], regions: ['']}});
expect(validateGraph(graph)).toEqual(expect.arrayContaining([
expect.objectContaining({code: 'invalid_config', nodeId: 'pow'}),
expect.objectContaining({code: 'invalid_config', nodeId: 'geo'}),
]));
graph.nodes.push({
id: 'pow',
type: 'pow',
position: { x: 0, y: 0 },
config: {
algorithm: 'fast',
difficulty: 0,
session_ttl: 0,
challenge_ttl: 0,
},
});
graph.nodes.push({
id: 'geo',
type: 'geo_match',
position: { x: 0, y: 0 },
config: { countries: ['china'], regions: [''] },
});
expect(validateGraph(graph)).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'invalid_config', nodeId: 'pow' }),
expect.objectContaining({ code: 'invalid_config', nodeId: 'geo' }),
]),
);
});
it('rejects non-finite and fractional integer configuration', () => {
const graph = validGraph();
graph.nodes.push({id: 'pow', type: 'pow', position: {x: 0, y: 0}, config: {algorithm: 'fast', difficulty: 4.5, session_ttl: Number.NaN, challenge_ttl: 30}});
graph.nodes.push({id: 'block-fraction', type: 'block', position: {x: 0, y: 0}, config: {status_code: 403.5, response_body: ''}});
expect(validateGraph(graph)).toEqual(expect.arrayContaining([
expect.objectContaining({code: 'invalid_config', nodeId: 'pow'}),
expect.objectContaining({code: 'invalid_config', nodeId: 'block-fraction'}),
]));
graph.nodes.push({
id: 'pow',
type: 'pow',
position: { x: 0, y: 0 },
config: {
algorithm: 'fast',
difficulty: 4.5,
session_ttl: Number.NaN,
challenge_ttl: 30,
},
});
graph.nodes.push({
id: 'block-fraction',
type: 'block',
position: { x: 0, y: 0 },
config: { status_code: 403.5, response_body: '' },
});
expect(validateGraph(graph)).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'invalid_config', nodeId: 'pow' }),
expect.objectContaining({
code: 'invalid_config',
nodeId: 'block-fraction',
}),
]),
);
});
it('matches server IP and prefix parsing semantics', () => {
const invalid = validGraph();
invalid.nodes = invalid.nodes.map((node) => node.type === 'ip_match' ? {...node, config: {ips: ['2001:db8::1', '::::'], cidrs: ['2001:db8::/32', '10.0.0.0/33'], ip_group_ids: []}} : node);
expect(validateGraph(invalid)).toContainEqual(expect.objectContaining({code: 'invalid_config', nodeId: 'match'}));
invalid.nodes = invalid.nodes.map((node) =>
node.type === 'ip_match'
? {
...node,
config: {
ips: ['2001:db8::1', '::::'],
cidrs: ['2001:db8::/32', '10.0.0.0/33'],
ip_group_ids: [],
},
}
: node,
);
expect(validateGraph(invalid)).toContainEqual(
expect.objectContaining({ code: 'invalid_config', nodeId: 'match' }),
);
const valid = validGraph();
valid.nodes = valid.nodes.map((node) => node.type === 'ip_match' ? {...node, config: {ips: ['2001:db8::1', '192.0.2.1'], cidrs: ['2001:db8::/32', '10.0.0.0/8'], ip_group_ids: []}} : node);
valid.nodes = valid.nodes.map((node) =>
node.type === 'ip_match'
? {
...node,
config: {
ips: ['2001:db8::1', '192.0.2.1'],
cidrs: ['2001:db8::/32', '10.0.0.0/8'],
ip_group_ids: [],
},
}
: node,
);
expect(validateGraph(valid)).toEqual([]);
});
it('requires exactly one CIDR slash and rejects scoped IPv6 addresses', () => {
for (const value of ['10.0.0.0/8/extra', 'fe80::1%en0', 'fe80::%en0/64']) {
const graph = validGraph();
graph.nodes = graph.nodes.map((node) => node.type === 'ip_match' ? {...node, config: value.includes('/') ? {ips: [], cidrs: [value], ip_group_ids: []} : {ips: [value], cidrs: [], ip_group_ids: []}} : node);
expect(validateGraph(graph)).toContainEqual(expect.objectContaining({code: 'invalid_config', nodeId: 'match'}));
graph.nodes = graph.nodes.map((node) =>
node.type === 'ip_match'
? {
...node,
config: value.includes('/')
? { ips: [], cidrs: [value], ip_group_ids: [] }
: { ips: [value], cidrs: [], ip_group_ids: [] },
}
: node,
);
expect(validateGraph(graph)).toContainEqual(
expect.objectContaining({ code: 'invalid_config', nodeId: 'match' }),
);
}
});
});