import {describe, expect, it} from 'vitest'; import type {WAFRuleGraph} from '@/lib/services/openflare'; import {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('detects whether a new connection creates a cycle', () => { expect(wouldCreateCycle(validGraph(), 'allow', 'start')).toBe(true); expect(wouldCreateCycle(validGraph(), 'start', 'block')).toBe(false); });