Files
OpenFlare/frontend/app/(main)/waf/rules/editor/components/graph-validation.test.ts
T
2026-07-13 15:10:28 +08:00

286 lines
7.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { WAFRuleGraph } from '@/lib/services/openflare';
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: '' },
},
],
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',
},
],
});
describe('validateGraph', () => {
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',
);
});
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' }),
);
});
it('rejects cycles', () => {
const graph = validGraph();
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' }),
]),
);
});
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',
}),
]),
);
});
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']));
});
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' }),
]),
);
});
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',
}),
]),
);
});
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' }),
);
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,
);
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' }),
);
}
});
});
it('removes incident edges when deleting a node', () => {
const next = removeNodeFromGraph(validGraph(), 'match');
expect(next.nodes.some((node) => node.id === 'match')).toBe(false);
expect(next.edges).toEqual([]);
});
it('removes a selected connection without changing nodes', () => {
const graph = validGraph();
const edgeId = graph.edges[0].id;
const next = removeEdgeFromGraph(graph, edgeId);
expect(next.nodes).toEqual(graph.nodes);
expect(next.edges).toHaveLength(graph.edges.length - 1);
expect(next.edges.some((edge) => edge.id === edgeId)).toBe(false);
});
it('detects whether a new connection creates a cycle', () => {
expect(wouldCreateCycle(validGraph(), 'allow', 'start')).toBe(true);
expect(wouldCreateCycle(validGraph(), 'start', 'block')).toBe(false);
});