Skip to main content

featherbit/graph/
validation.rs

1//! Structural validation of policy node graphs, run before compilation
2//! (e.g. at config load and on Admin API writes) so malformed policies are
3//! rejected with actionable messages instead of failing at request time.
4
5use std::collections::HashSet;
6
7use super::expand::{split_endpoint, BOUNDARY_TYPES};
8use crate::config::{PolicyConfig, SupernodeConfig};
9
10/// Output-boundary ids that would collide with an instance's fixed port
11/// names (spec §1). `output` is the one special id: it maps to `success`.
12pub(crate) const RESERVED_OUTPUT_IDS: [&str; 5] = ["input", "error", "in", "out", "success"];
13
14/// Error-boundary ids that would collide with an instance's fixed port
15/// names (spec §1). `error` is the one special id: it is both the port
16/// name and the black-box default exit.
17pub(crate) const RESERVED_ERROR_IDS: [&str; 5] = ["input", "output", "in", "out", "success"];
18
19/// Validates a policy's node graph structure, collecting all violations.
20///
21/// Enforced rules:
22/// - no two nodes share an id;
23/// - the policy has a `listener` node (entry) and a `client` node (exit);
24/// - every edge endpoint references an existing node;
25/// - no orphan nodes (a node with neither incoming nor outgoing edges;
26///   being named as the policy-level `error_handler` counts as connected);
27/// - `error_handler`, if set, references an existing node that is not a
28///   `type: supernode` instance (expansion removes instance nodes, so a
29///   catch-all pointed at one would dangle at request time).
30///
31/// Returns `Ok(())` when valid, otherwise `Err` with one message per
32/// violation (validation does not stop at the first error).
33pub fn validate_policy(policy: &PolicyConfig) -> Result<(), Vec<String>> {
34    let mut errors = Vec::new();
35
36    let node_ids: HashSet<&str> = policy.nodes.iter().map(|n| n.id.as_str()).collect();
37
38    // Duplicate node ids collapse silently in compile — with supernodes they
39    // would silently drop an expansion — so reject each duplicate up front.
40    let mut seen_ids: HashSet<&str> = HashSet::new();
41    for node in &policy.nodes {
42        if !seen_ids.insert(node.id.as_str()) {
43            errors.push(format!("Duplicate node id '{}'", node.id));
44        }
45    }
46
47    // Must have a listener node
48    let has_listener = policy.nodes.iter().any(|n| n.node_type == "listener");
49    if !has_listener {
50        errors.push("Policy must have a 'listener' node".to_string());
51    }
52
53    // Must have a client node
54    let has_client = policy.nodes.iter().any(|n| n.node_type == "client");
55    if !has_client {
56        errors.push("Policy must have a 'client' node".to_string());
57    }
58
59    // Node-id and reserved-type hygiene: `/` is the supernode namespace
60    // separator, and boundary pseudo-types only exist inside definitions.
61    for node in &policy.nodes {
62        if node.id.contains('/') {
63            errors.push(format!(
64                "Node id '{}' must not contain '/' (reserved for supernode expansion)",
65                node.id
66            ));
67        }
68        if BOUNDARY_TYPES.contains(&node.node_type.as_str()) {
69            errors.push(format!(
70                "Node '{}' has type '{}', which is reserved for supernode definitions",
71                node.id, node.node_type
72            ));
73        }
74    }
75
76    // Validate edges reference existing nodes
77    for edge in &policy.edges {
78        let from_node = edge.from.split('.').next().unwrap_or("");
79        let to_node = edge.to.split('.').next().unwrap_or("");
80
81        if !node_ids.contains(from_node) {
82            errors.push(format!(
83                "Edge references unknown source node: '{}'",
84                from_node
85            ));
86        }
87        if !node_ids.contains(to_node) {
88            errors.push(format!(
89                "Edge references unknown target node: '{}'",
90                to_node
91            ));
92        }
93    }
94
95    // Fan-in is unrestricted: any number of edges may converge on the same
96    // input port. The engine indexes edges by source port only (fan-out and
97    // cycles are compile-time errors in engine.rs).
98
99    // Check for orphan nodes (no incoming or outgoing edges)
100    let mut connected_nodes: HashSet<&str> = HashSet::new();
101    for edge in &policy.edges {
102        let from_node = edge.from.split('.').next().unwrap_or("");
103        let to_node = edge.to.split('.').next().unwrap_or("");
104        connected_nodes.insert(from_node);
105        connected_nodes.insert(to_node);
106    }
107
108    // Also include the catch-all handler if specified
109    if let Some(ref handler) = policy.error_handler {
110        connected_nodes.insert(handler.as_str());
111    }
112
113    for node in &policy.nodes {
114        if !connected_nodes.contains(node.id.as_str()) {
115            errors.push(format!("Orphan node '{}' has no connections", node.id));
116        }
117    }
118
119    // Validate error_handler references an existing node
120    if let Some(ref handler) = policy.error_handler {
121        if !node_ids.contains(handler.as_str()) {
122            errors.push(format!(
123                "Policy error_handler references unknown node: '{}'",
124                handler
125            ));
126        } else if let Some(node) = policy.nodes.iter().find(|n| n.id == *handler) {
127            if node.node_type == "supernode" {
128                errors.push(format!(
129                    "Policy error_handler cannot reference supernode instance '{}' — point it at a concrete node",
130                    handler
131                ));
132            }
133        }
134    }
135
136    if errors.is_empty() {
137        Ok(())
138    } else {
139        Err(errors)
140    }
141}
142
143/// Validates a supernode definition's structure, collecting all violations.
144///
145/// Enforced rules (spec §3):
146/// - exactly one boundary node of type `input`, id `input`; one or more
147///   `output` boundaries (id = port name, `output` = the `success` port);
148///   one or more `error` boundaries (id = error-kind port name, `error` =
149///   the default the black-box rule targets);
150/// - output/error ids must not be in RESERVED_OUTPUT_IDS/RESERVED_ERROR_IDS
151///   (collide with fixed instance port names like `success`, `input`, etc.);
152/// - no two nodes share an id (duplicate ids would create ambiguous output ports);
153/// - inner nodes must not be `listener`/`client`/`supernode`, must not use
154///   reserved ids, and must not contain `/`;
155/// - exactly one edge leaves `input`; no edges into `input` or out of
156///   `output`/`error` boundaries;
157/// - every edge endpoint references an existing node;
158/// - no orphan inner nodes (unconnected `output`/`error` boundaries are
159///   fine — not every subgraph uses both exits);
160/// - every inner edge leaves a port its source node's type actually declares
161///   (`out` normalizes to `success`; boundary pseudo-nodes are exempt — they
162///   have no plugin type and therefore no `PortSpec`);
163/// - every `success`/outcome port of every inner node is wired **inside** the
164///   definition, to another inner node or to a boundary. Definitions are
165///   compiled as part of each policy that instantiates them, so an unwired
166///   outcome port would otherwise surface as a confusing compile error on an
167///   unrelated policy rather than at save time on the definition itself.
168///   `error` ports stay exempt: the black-box rule in
169///   [`expand_policy`](crate::graph::expand_policy) wires every unhandled
170///   inner error port to the instance's error exit.
171pub fn validate_supernode(sn: &SupernodeConfig) -> Result<(), Vec<String>> {
172    let mut errors = Vec::new();
173
174    // Duplicate ids: two output boundaries sharing an id would be one
175    // ambiguous instance port; inner-node duplicates collapse in compile.
176    let mut seen_ids: HashSet<&str> = HashSet::new();
177    for n in &sn.nodes {
178        if !seen_ids.insert(n.id.as_str()) {
179            errors.push(format!(
180                "Supernode '{}': duplicate node id '{}'",
181                sn.name, n.id
182            ));
183        }
184    }
185
186    // input: exactly one, id == type (unchanged rule).
187    {
188        let ty = "input";
189        let matching: Vec<&crate::config::NodeConfig> =
190            sn.nodes.iter().filter(|n| n.node_type == ty).collect();
191        match matching.as_slice() {
192            [one] if one.id == ty => {}
193            [one] => errors.push(format!(
194                "Supernode '{}': boundary node of type '{}' must have id '{}' (got '{}')",
195                sn.name, ty, ty, one.id
196            )),
197            [] => errors.push(format!(
198                "Supernode '{}' must declare an '{}' boundary node",
199                sn.name, ty
200            )),
201            _ => errors.push(format!(
202                "Supernode '{}' declares more than one '{}' node",
203                sn.name, ty
204            )),
205        }
206    }
207
208    // error: one or more; each id is an error-kind instance port name (`error`
209    // is the default the black-box rule targets), so reserved ids that collide
210    // with fixed port names are rejected.
211    let error_nodes: Vec<&crate::config::NodeConfig> =
212        sn.nodes.iter().filter(|n| n.node_type == "error").collect();
213    if error_nodes.is_empty() {
214        errors.push(format!(
215            "Supernode '{}' must declare at least one 'error' boundary node",
216            sn.name
217        ));
218    }
219    for e in &error_nodes {
220        if RESERVED_ERROR_IDS.contains(&e.id.as_str()) {
221            errors.push(format!(
222                "Supernode '{}': error boundary id '{}' is reserved — it would \
223                 collide with a fixed instance port name",
224                sn.name, e.id
225            ));
226        }
227    }
228
229    // output: one or more; each id is an instance port name (`output` -> the
230    // `success` port), so reserved ids that collide with fixed port names are
231    // rejected.
232    let output_nodes: Vec<&crate::config::NodeConfig> = sn
233        .nodes
234        .iter()
235        .filter(|n| n.node_type == "output")
236        .collect();
237    if output_nodes.is_empty() {
238        errors.push(format!(
239            "Supernode '{}' must declare at least one 'output' boundary node",
240            sn.name
241        ));
242    }
243    for o in &output_nodes {
244        if RESERVED_OUTPUT_IDS.contains(&o.id.as_str()) {
245            errors.push(format!(
246                "Supernode '{}': output boundary id '{}' is reserved — it would \
247                 collide with a fixed instance port name",
248                sn.name, o.id
249            ));
250        }
251    }
252
253    for n in &sn.nodes {
254        let is_boundary = BOUNDARY_TYPES.contains(&n.node_type.as_str());
255        if n.id.contains('/') {
256            errors.push(format!(
257                "Supernode '{}': node id '{}' must not contain '/'",
258                sn.name, n.id
259            ));
260        }
261        if !is_boundary {
262            if BOUNDARY_TYPES.contains(&n.id.as_str()) {
263                errors.push(format!(
264                    "Supernode '{}': node id '{}' is reserved for boundary nodes",
265                    sn.name, n.id
266                ));
267            }
268            if ["listener", "client", "supernode"].contains(&n.node_type.as_str()) {
269                errors.push(format!(
270                    "Supernode '{}': node '{}' has forbidden type '{}' \
271                     (supernodes cannot contain endpoints or other supernodes)",
272                    sn.name, n.id, n.node_type
273                ));
274            }
275        }
276    }
277
278    let node_ids: HashSet<&str> = sn.nodes.iter().map(|n| n.id.as_str()).collect();
279
280    // Build a set of exit-boundary ids once, above the edge loop
281    let exit_boundary_ids: HashSet<&str> = sn
282        .nodes
283        .iter()
284        .filter(|n| n.node_type == "output" || n.node_type == "error")
285        .map(|n| n.id.as_str())
286        .collect();
287
288    for edge in &sn.edges {
289        let (from_node, _) = split_endpoint(&edge.from);
290        let (to_node, _) = split_endpoint(&edge.to);
291        if !node_ids.contains(from_node) {
292            errors.push(format!(
293                "Supernode '{}': edge references unknown source node: '{}'",
294                sn.name, from_node
295            ));
296        }
297        if !node_ids.contains(to_node) {
298            errors.push(format!(
299                "Supernode '{}': edge references unknown target node: '{}'",
300                sn.name, to_node
301            ));
302        }
303        if to_node == "input" {
304            errors.push(format!(
305                "Supernode '{}': the 'input' boundary cannot have incoming edges",
306                sn.name
307            ));
308        }
309        if exit_boundary_ids.contains(from_node) {
310            errors.push(format!(
311                "Supernode '{}': the '{}' boundary cannot have outgoing edges",
312                sn.name, from_node
313            ));
314        }
315    }
316
317    let input_out_edges = sn
318        .edges
319        .iter()
320        .filter(|e| split_endpoint(&e.from).0 == "input")
321        .count();
322    if input_out_edges != 1 {
323        errors.push(format!(
324            "Supernode '{}' must have exactly one edge from input.out (found {})",
325            sn.name, input_out_edges
326        ));
327    }
328
329    // Fan-in is unrestricted here too: any number of inner edges may converge
330    // on the same input port or boundary exit.
331
332    // Port hygiene on inner nodes. Boundary pseudo-nodes have no plugin type
333    // (and no PortSpec), so they are exempt; an unknown inner type is left to
334    // the forbidden-type/factory checks rather than reported twice here.
335    let inner_specs: std::collections::HashMap<&str, &'static crate::plugins::ports::PortSpec> = sn
336        .nodes
337        .iter()
338        .filter(|n| !BOUNDARY_TYPES.contains(&n.node_type.as_str()))
339        .filter_map(|n| crate::plugins::port_spec(&n.node_type).map(|s| (n.id.as_str(), s)))
340        .collect();
341
342    // (a) Every inner edge must leave a port its source type declares.
343    let mut wired_ports: HashSet<(&str, &str)> = HashSet::new();
344    for edge in &sn.edges {
345        let (from_node, from_port) = split_endpoint(&edge.from);
346        let from_port = if from_port == "out" {
347            "success"
348        } else {
349            from_port
350        };
351        let Some(spec) = inner_specs.get(from_node) else {
352            continue; // boundary node, or a type reported elsewhere
353        };
354        if spec.outputs.iter().any(|p| p.name == from_port) {
355            wired_ports.insert((from_node, from_port));
356        } else {
357            let node_type = sn
358                .nodes
359                .iter()
360                .find(|n| n.id == from_node)
361                .map(|n| n.node_type.as_str())
362                .unwrap_or("");
363            errors.push(format!(
364                "Supernode '{}': node '{}' (type '{}') has no output port '{}'",
365                sn.name, from_node, node_type, from_port
366            ));
367        }
368    }
369
370    // (b) Every success/outcome port of every inner node must be wired inside
371    // the definition. `error` is exempt (the black-box rule covers it).
372    for n in &sn.nodes {
373        let Some(spec) = inner_specs.get(n.id.as_str()) else {
374            continue;
375        };
376        for p in spec.outputs {
377            if matches!(p.kind, crate::plugins::ports::PortKind::Error) {
378                continue;
379            }
380            if !wired_ports.contains(&(n.id.as_str(), p.name)) {
381                errors.push(format!(
382                    "Supernode '{}': output port '{}' of node '{}' (type '{}') must be wired \
383                     inside the definition — add an edge from '{}.{}' to another inner node or \
384                     to the 'output'/'error' boundary",
385                    sn.name, p.name, n.id, n.node_type, n.id, p.name
386                ));
387            }
388        }
389    }
390
391    // Orphans: every non-boundary node needs at least one edge.
392    let mut connected: HashSet<&str> = HashSet::new();
393    for edge in &sn.edges {
394        connected.insert(split_endpoint(&edge.from).0);
395        connected.insert(split_endpoint(&edge.to).0);
396    }
397    for n in &sn.nodes {
398        if !BOUNDARY_TYPES.contains(&n.node_type.as_str()) && !connected.contains(n.id.as_str()) {
399            errors.push(format!(
400                "Supernode '{}': Orphan node '{}' has no connections",
401                sn.name, n.id
402            ));
403        }
404    }
405
406    if errors.is_empty() {
407        Ok(())
408    } else {
409        Err(errors)
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::config::{EdgeConfig, NodeConfig, PolicyConfig, SupernodeConfig};
417    use std::collections::HashMap;
418
419    fn listener_node() -> NodeConfig {
420        NodeConfig {
421            id: "listener".to_string(),
422            node_type: "listener".to_string(),
423            config: HashMap::new(),
424            config_ref: None,
425            position: None,
426        }
427    }
428
429    fn client_node() -> NodeConfig {
430        NodeConfig {
431            id: "client".to_string(),
432            node_type: "client".to_string(),
433            config: HashMap::new(),
434            config_ref: None,
435            position: None,
436        }
437    }
438
439    fn upstream_node() -> NodeConfig {
440        NodeConfig {
441            id: "backend".to_string(),
442            node_type: "upstream".to_string(),
443            config: HashMap::new(),
444            config_ref: None,
445            position: None,
446        }
447    }
448
449    fn boundary_nodes() -> Vec<NodeConfig> {
450        ["input", "output", "error"]
451            .into_iter()
452            .map(|t| NodeConfig {
453                id: t.to_string(),
454                node_type: t.to_string(),
455                config: HashMap::new(),
456                config_ref: None,
457                position: None,
458            })
459            .collect()
460    }
461
462    fn inner(id: &str, ty: &str) -> NodeConfig {
463        NodeConfig {
464            id: id.to_string(),
465            node_type: ty.to_string(),
466            config: HashMap::new(),
467            config_ref: None,
468            position: None,
469        }
470    }
471
472    fn sn_edge(from: &str, to: &str) -> EdgeConfig {
473        EdgeConfig {
474            from: from.to_string(),
475            to: to.to_string(),
476        }
477    }
478
479    fn valid_supernode() -> SupernodeConfig {
480        let mut nodes = boundary_nodes();
481        nodes.push(inner("up", "upstream"));
482        SupernodeConfig {
483            name: "sn".to_string(),
484            description: None,
485            nodes,
486            edges: vec![
487                sn_edge("input.out", "up.in"),
488                sn_edge("up.success", "output.in"),
489                sn_edge("up.error", "error.in"),
490            ],
491        }
492    }
493
494    #[test]
495    fn test_valid_simple_policy() {
496        let policy = PolicyConfig {
497            name: "test".to_string(),
498            error_handler: None,
499            nodes: vec![listener_node(), upstream_node(), client_node()],
500            edges: vec![
501                EdgeConfig {
502                    from: "listener.out".to_string(),
503                    to: "backend.in".to_string(),
504                },
505                EdgeConfig {
506                    from: "backend.success".to_string(),
507                    to: "client.in".to_string(),
508                },
509            ],
510        };
511        assert!(validate_policy(&policy).is_ok());
512    }
513
514    #[test]
515    fn test_missing_listener() {
516        let policy = PolicyConfig {
517            name: "test".to_string(),
518            error_handler: None,
519            nodes: vec![upstream_node(), client_node()],
520            edges: vec![],
521        };
522        let errors = validate_policy(&policy).unwrap_err();
523        assert!(errors.iter().any(|e| e.contains("listener")));
524    }
525
526    #[test]
527    fn test_missing_client() {
528        let policy = PolicyConfig {
529            name: "test".to_string(),
530            error_handler: None,
531            nodes: vec![listener_node(), upstream_node()],
532            edges: vec![EdgeConfig {
533                from: "listener.out".to_string(),
534                to: "backend.in".to_string(),
535            }],
536        };
537        let errors = validate_policy(&policy).unwrap_err();
538        assert!(errors.iter().any(|e| e.contains("client")));
539    }
540
541    #[test]
542    fn test_unknown_edge_reference() {
543        let policy = PolicyConfig {
544            name: "test".to_string(),
545            error_handler: None,
546            nodes: vec![listener_node(), client_node()],
547            edges: vec![EdgeConfig {
548                from: "listener.out".to_string(),
549                to: "nonexistent.in".to_string(),
550            }],
551        };
552        let errors = validate_policy(&policy).unwrap_err();
553        assert!(errors.iter().any(|e| e.contains("nonexistent")));
554    }
555
556    #[test]
557    fn test_client_allows_multiple_inputs() {
558        let policy = PolicyConfig {
559            name: "test".to_string(),
560            error_handler: None,
561            nodes: vec![listener_node(), upstream_node(), client_node()],
562            edges: vec![
563                EdgeConfig {
564                    from: "listener.out".to_string(),
565                    to: "backend.in".to_string(),
566                },
567                EdgeConfig {
568                    from: "backend.success".to_string(),
569                    to: "client.in".to_string(),
570                },
571                EdgeConfig {
572                    from: "backend.error".to_string(),
573                    to: "client.in".to_string(),
574                },
575            ],
576        };
577        assert!(validate_policy(&policy).is_ok());
578    }
579
580    /// Fan-in is legal on every node, not just client/error-handler: the
581    /// engine indexes edges by source port only, so any number of edges may
582    /// converge on the same input.
583    #[test]
584    fn test_any_node_allows_multiple_inputs() {
585        let mut second = upstream_node();
586        second.id = "backend2".to_string();
587        let policy = PolicyConfig {
588            name: "test".to_string(),
589            error_handler: None,
590            nodes: vec![listener_node(), upstream_node(), second, client_node()],
591            edges: vec![
592                EdgeConfig {
593                    from: "listener.out".to_string(),
594                    to: "backend2.in".to_string(),
595                },
596                EdgeConfig {
597                    from: "backend2.success".to_string(),
598                    to: "backend.in".to_string(),
599                },
600                EdgeConfig {
601                    from: "backend2.error".to_string(),
602                    to: "backend.in".to_string(),
603                },
604                EdgeConfig {
605                    from: "backend.success".to_string(),
606                    to: "client.in".to_string(),
607                },
608            ],
609        };
610        assert!(validate_policy(&policy).is_ok());
611    }
612
613    #[test]
614    fn test_valid_supernode_passes() {
615        assert!(validate_supernode(&valid_supernode()).is_ok());
616    }
617
618    /// Fan-in converges on inner nodes too, not only boundary exits.
619    #[test]
620    fn test_supernode_fan_in_into_inner_node_ok() {
621        let mut sn = valid_supernode();
622        sn.nodes.push(inner("up2", "upstream"));
623        sn.edges = vec![
624            sn_edge("input.out", "up2.in"),
625            sn_edge("up2.success", "up.in"),
626            sn_edge("up2.error", "up.in"),
627            sn_edge("up.success", "output.in"),
628            sn_edge("up.error", "error.in"),
629        ];
630        assert!(validate_supernode(&sn).is_ok());
631    }
632
633    #[test]
634    fn test_supernode_missing_boundary_nodes() {
635        let mut sn = valid_supernode();
636        sn.nodes.retain(|n| n.node_type != "error");
637        let errors = validate_supernode(&sn).unwrap_err();
638        assert!(
639            errors.iter().any(|e| e.contains("'error' boundary node")),
640            "{errors:?}"
641        );
642    }
643
644    #[test]
645    fn test_supernode_boundary_id_must_match_type() {
646        let mut sn = valid_supernode();
647        sn.nodes
648            .iter_mut()
649            .find(|n| n.node_type == "input")
650            .unwrap()
651            .id = "start".into();
652        let errors = validate_supernode(&sn).unwrap_err();
653        assert!(
654            errors.iter().any(|e| e.contains("must have id 'input'")),
655            "{errors:?}"
656        );
657    }
658
659    #[test]
660    fn test_supernode_forbidden_inner_types() {
661        for ty in ["listener", "client", "supernode"] {
662            let mut sn = valid_supernode();
663            sn.nodes.push(inner("x", ty));
664            sn.edges.push(sn_edge("up.success", "x.in"));
665            let errors = validate_supernode(&sn).unwrap_err();
666            assert!(
667                errors.iter().any(|e| e.contains("forbidden type")),
668                "type {ty}: {errors:?}"
669            );
670        }
671    }
672
673    #[test]
674    fn test_supernode_reserved_inner_ids_and_slash() {
675        let mut sn = valid_supernode();
676        sn.nodes.push(inner("output2/x", "cors"));
677        sn.edges.push(sn_edge("up.success", "output2/x.in"));
678        let errors = validate_supernode(&sn).unwrap_err();
679        assert!(
680            errors.iter().any(|e| e.contains("must not contain '/'")),
681            "{errors:?}"
682        );
683    }
684
685    #[test]
686    fn test_supernode_input_needs_exactly_one_outgoing_edge() {
687        let mut sn = valid_supernode();
688        sn.nodes.push(inner("cors", "cors"));
689        sn.edges.push(sn_edge("input.out", "cors.in"));
690        sn.edges.push(sn_edge("cors.success", "output.in"));
691        let errors = validate_supernode(&sn).unwrap_err();
692        assert!(
693            errors.iter().any(|e| e.contains("exactly one edge")),
694            "{errors:?}"
695        );
696
697        let mut sn = valid_supernode();
698        sn.edges.retain(|e| !e.from.starts_with("input."));
699        let errors = validate_supernode(&sn).unwrap_err();
700        assert!(
701            errors.iter().any(|e| e.contains("exactly one edge")),
702            "{errors:?}"
703        );
704    }
705
706    #[test]
707    fn test_supernode_boundary_direction_rules() {
708        let mut sn = valid_supernode();
709        sn.edges.push(sn_edge("output.out", "up.in")); // out of output: invalid
710        sn.edges.push(sn_edge("up.success", "input.in")); // into input: invalid
711        let errors = validate_supernode(&sn).unwrap_err();
712        assert!(
713            errors.iter().any(|e| e.contains("cannot have outgoing")),
714            "{errors:?}"
715        );
716        assert!(
717            errors.iter().any(|e| e.contains("cannot have incoming")),
718            "{errors:?}"
719        );
720    }
721
722    /// Two branches may exit through the same boundary port (fan-in), and an
723    /// unconnected `error` boundary is not an orphan.
724    #[test]
725    fn test_supernode_fan_in_to_output_and_unused_error_ok() {
726        let mut nodes = boundary_nodes();
727        nodes.push(inner("a", "cors"));
728        nodes.push(inner("b", "gzip"));
729        let sn = SupernodeConfig {
730            name: "fan".to_string(),
731            description: None,
732            nodes,
733            edges: vec![
734                sn_edge("input.out", "a.in"),
735                sn_edge("a.success", "b.in"),
736                sn_edge("a.preflight", "output.in"), // cors' outcome port
737                sn_edge("a.error", "output.in"),
738                sn_edge("b.success", "output.in"),
739            ],
740        };
741        assert!(
742            validate_supernode(&sn).is_ok(),
743            "{:?}",
744            validate_supernode(&sn)
745        );
746    }
747
748    /// I4(b): an inner node whose outcome port is left unwired inside the
749    /// definition must be rejected at save time. Otherwise the failure only
750    /// surfaces later, as a compile error on whichever unrelated policy
751    /// happens to instantiate the supernode.
752    #[test]
753    fn test_supernode_unwired_inner_outcome_port_rejected() {
754        let mut nodes = boundary_nodes();
755        nodes.push(inner("auth", "key-auth"));
756        let sn = SupernodeConfig {
757            name: "unwired".to_string(),
758            description: None,
759            nodes,
760            edges: vec![
761                sn_edge("input.out", "auth.in"),
762                sn_edge("auth.success", "output.in"),
763                // auth.denied deliberately left unwired
764            ],
765        };
766        let errors = validate_supernode(&sn).unwrap_err();
767        assert!(
768            errors.iter().any(|e| e.contains("output port 'denied'")
769                && e.contains("'auth'")
770                && e.contains("must be wired inside the definition")),
771            "{errors:?}"
772        );
773    }
774
775    /// The same definition with `denied` wired to a boundary is accepted.
776    #[test]
777    fn test_supernode_fully_wired_outcome_port_accepted() {
778        let mut nodes = boundary_nodes();
779        nodes.push(inner("auth", "key-auth"));
780        let sn = SupernodeConfig {
781            name: "wired".to_string(),
782            description: None,
783            nodes,
784            edges: vec![
785                sn_edge("input.out", "auth.in"),
786                sn_edge("auth.success", "output.in"),
787                sn_edge("auth.denied", "output.in"),
788            ],
789        };
790        assert_eq!(validate_supernode(&sn), Ok(()));
791    }
792
793    /// I4(a): an inner edge naming a port the node's type does not declare is
794    /// rejected — the same rule `compile_policy` applies to policy edges.
795    #[test]
796    fn test_supernode_undeclared_inner_port_rejected() {
797        let mut nodes = boundary_nodes();
798        nodes.push(inner("up", "upstream"));
799        let sn = SupernodeConfig {
800            name: "bogus-port".to_string(),
801            description: None,
802            nodes,
803            edges: vec![
804                sn_edge("input.out", "up.in"),
805                sn_edge("up.success", "output.in"),
806                sn_edge("up.banana", "error.in"),
807            ],
808        };
809        let errors = validate_supernode(&sn).unwrap_err();
810        assert!(
811            errors
812                .iter()
813                .any(|e| e.contains("has no output port 'banana'") && e.contains("'up'")),
814            "{errors:?}"
815        );
816    }
817
818    /// `out` is the YAML alias for `success` on an inner edge too, and must
819    /// satisfy the mandatory-wiring check rather than trip the port-name one.
820    #[test]
821    fn test_supernode_out_alias_satisfies_inner_wiring() {
822        let mut nodes = boundary_nodes();
823        nodes.push(inner("up", "upstream"));
824        let sn = SupernodeConfig {
825            name: "alias".to_string(),
826            description: None,
827            nodes,
828            edges: vec![
829                sn_edge("input.out", "up.in"),
830                sn_edge("up.out", "output.in"),
831            ],
832        };
833        assert_eq!(validate_supernode(&sn), Ok(()));
834    }
835
836    /// All violations are collected, not just the first: two inner nodes each
837    /// missing a mandatory port report two errors.
838    #[test]
839    fn test_supernode_collects_all_port_violations() {
840        let mut nodes = boundary_nodes();
841        nodes.push(inner("auth", "key-auth"));
842        nodes.push(inner("rl", "rate-limit"));
843        let sn = SupernodeConfig {
844            name: "many".to_string(),
845            description: None,
846            nodes,
847            edges: vec![
848                sn_edge("input.out", "auth.in"),
849                sn_edge("auth.success", "rl.in"),
850                sn_edge("rl.success", "output.in"),
851                // auth.denied and rl.limited both unwired
852            ],
853        };
854        let errors = validate_supernode(&sn).unwrap_err();
855        assert!(errors.iter().any(|e| e.contains("'denied'")), "{errors:?}");
856        assert!(errors.iter().any(|e| e.contains("'limited'")), "{errors:?}");
857    }
858
859    #[test]
860    fn test_supernode_dangling_edge_and_orphan() {
861        let mut sn = valid_supernode();
862        sn.edges.push(sn_edge("ghost.success", "output.in"));
863        sn.nodes.push(inner("lonely", "cors"));
864        let errors = validate_supernode(&sn).unwrap_err();
865        assert!(
866            errors
867                .iter()
868                .any(|e| e.contains("unknown source node: 'ghost'")),
869            "{errors:?}"
870        );
871        assert!(
872            errors.iter().any(|e| e.contains("Orphan node 'lonely'")),
873            "{errors:?}"
874        );
875    }
876
877    #[test]
878    fn test_policy_rejects_slash_ids_and_reserved_types() {
879        let mut policy = PolicyConfig {
880            name: "test".to_string(),
881            error_handler: None,
882            nodes: vec![listener_node(), upstream_node(), client_node()],
883            edges: vec![
884                EdgeConfig {
885                    from: "listener.out".to_string(),
886                    to: "backend.in".to_string(),
887                },
888                EdgeConfig {
889                    from: "backend.success".to_string(),
890                    to: "client.in".to_string(),
891                },
892            ],
893        };
894        policy.nodes[1].id = "a/b".to_string();
895        policy.edges[0].to = "a/b.in".to_string();
896        policy.edges[1].from = "a/b.success".to_string();
897        let errors = validate_policy(&policy).unwrap_err();
898        assert!(
899            errors.iter().any(|e| e.contains("must not contain '/'")),
900            "{errors:?}"
901        );
902
903        let mut policy2 = PolicyConfig {
904            name: "test2".to_string(),
905            error_handler: None,
906            nodes: vec![listener_node(), inner("x", "input"), client_node()],
907            edges: vec![
908                EdgeConfig {
909                    from: "listener.out".to_string(),
910                    to: "x.in".to_string(),
911                },
912                EdgeConfig {
913                    from: "x.success".to_string(),
914                    to: "client.in".to_string(),
915                },
916            ],
917        };
918        policy2.nodes[1].node_type = "input".to_string();
919        let errors = validate_policy(&policy2).unwrap_err();
920        assert!(
921            errors.iter().any(|e| e.contains("reserved for supernode")),
922            "{errors:?}"
923        );
924    }
925
926    /// I-1: a policy whose `error_handler` names a `type: supernode`
927    /// instance passes structural checks against the un-expanded node list
928    /// but dangles after expansion (the instance node is removed). Must be
929    /// rejected at validate_policy time, before expansion ever runs.
930    #[test]
931    fn test_error_handler_rejects_supernode_instance() {
932        let mut sn_instance = inner("sec", "supernode");
933        sn_instance
934            .config
935            .insert("name".to_string(), serde_json::json!("secured-call"));
936
937        let policy = PolicyConfig {
938            name: "test".to_string(),
939            error_handler: Some("sec".to_string()),
940            nodes: vec![listener_node(), sn_instance, client_node()],
941            edges: vec![
942                EdgeConfig {
943                    from: "listener.out".to_string(),
944                    to: "sec.in".to_string(),
945                },
946                EdgeConfig {
947                    from: "sec.success".to_string(),
948                    to: "client.in".to_string(),
949                },
950            ],
951        };
952        let errors = validate_policy(&policy).unwrap_err();
953        assert!(
954            errors
955                .iter()
956                .any(|e| e.contains("error_handler") && e.contains("sec")),
957            "{errors:?}"
958        );
959    }
960
961    /// FINDING D: two nodes sharing an id collapse silently in compile —
962    /// with supernodes this would silently drop an expansion — so
963    /// validate_policy must reject every duplicate.
964    #[test]
965    fn test_duplicate_node_ids_rejected() {
966        let policy = PolicyConfig {
967            name: "test".to_string(),
968            error_handler: None,
969            nodes: vec![
970                listener_node(),
971                upstream_node(),
972                upstream_node(),
973                client_node(),
974            ],
975            edges: vec![
976                EdgeConfig {
977                    from: "listener.out".to_string(),
978                    to: "backend.in".to_string(),
979                },
980                EdgeConfig {
981                    from: "backend.success".to_string(),
982                    to: "client.in".to_string(),
983                },
984            ],
985        };
986        let errors = validate_policy(&policy).unwrap_err();
987        assert!(
988            errors
989                .iter()
990                .any(|e| e.contains("Duplicate node id 'backend'")),
991            "{errors:?}"
992        );
993    }
994
995    /// Named output ports: any number of `type: output` boundary nodes, each
996    /// id becoming an instance port name (spec §1-2).
997    #[test]
998    fn test_supernode_multiple_output_boundaries_accepted() {
999        let mut nodes = boundary_nodes(); // input/output/error
1000        nodes.push(inner("denied", "output"));
1001        nodes.push(inner("auth", "key-auth"));
1002        let sn = SupernodeConfig {
1003            name: "gate".to_string(),
1004            description: None,
1005            nodes,
1006            edges: vec![
1007                sn_edge("input.out", "auth.in"),
1008                sn_edge("auth.success", "output.in"),
1009                sn_edge("auth.denied", "denied.in"),
1010            ],
1011        };
1012        assert_eq!(validate_supernode(&sn), Ok(()));
1013    }
1014
1015    /// A definition may have only named outputs (no `output`-id node): the
1016    /// instance then has no `success` port.
1017    #[test]
1018    fn test_supernode_only_named_outputs_accepted() {
1019        let nodes: Vec<NodeConfig> = vec![
1020            inner("input", "input"),
1021            inner("done", "output"),
1022            inner("error", "error"),
1023            inner("up", "upstream"),
1024        ];
1025        let sn = SupernodeConfig {
1026            name: "named-only".to_string(),
1027            description: None,
1028            nodes,
1029            edges: vec![
1030                sn_edge("input.out", "up.in"),
1031                sn_edge("up.success", "done.in"),
1032            ],
1033        };
1034        assert_eq!(validate_supernode(&sn), Ok(()));
1035    }
1036
1037    /// Reserved ids collide with fixed instance port names.
1038    #[test]
1039    fn test_supernode_reserved_output_ids_rejected() {
1040        for id in ["input", "error", "in", "out", "success"] {
1041            let mut sn = valid_supernode();
1042            sn.nodes.push(inner(id, "output"));
1043            let errors = validate_supernode(&sn).unwrap_err();
1044            assert!(
1045                errors.iter().any(|e| e.contains("reserved")),
1046                "id {id}: {errors:?}"
1047            );
1048        }
1049    }
1050
1051    /// Zero output boundaries is still an error.
1052    #[test]
1053    fn test_supernode_zero_output_boundaries_rejected() {
1054        let mut sn = valid_supernode();
1055        sn.nodes.retain(|n| n.node_type != "output");
1056        sn.edges.retain(|e| !e.to.starts_with("output."));
1057        // keep `up` connected so only the output complaint fires
1058        sn.edges.push(sn_edge("up.success", "error.in"));
1059        let errors = validate_supernode(&sn).unwrap_err();
1060        assert!(
1061            errors
1062                .iter()
1063                .any(|e| e.contains("at least one 'output' boundary")),
1064            "{errors:?}"
1065        );
1066    }
1067
1068    /// Duplicate node ids inside a definition are rejected (two outputs with
1069    /// the same id would otherwise be one ambiguous port).
1070    #[test]
1071    fn test_supernode_duplicate_node_ids_rejected() {
1072        let mut sn = valid_supernode();
1073        sn.nodes.push(inner("denied", "output"));
1074        sn.nodes.push(inner("denied", "output"));
1075        let errors = validate_supernode(&sn).unwrap_err();
1076        assert!(
1077            errors
1078                .iter()
1079                .any(|e| e.contains("duplicate node id 'denied'")),
1080            "{errors:?}"
1081        );
1082    }
1083
1084    /// Named output boundaries obey the no-outgoing-edges rule like `output`/`error`.
1085    #[test]
1086    fn test_supernode_named_output_boundary_no_outgoing_edges() {
1087        let mut sn = valid_supernode();
1088        sn.nodes.push(inner("denied", "output"));
1089        sn.edges.push(sn_edge("denied.out", "up.in"));
1090        let errors = validate_supernode(&sn).unwrap_err();
1091        assert!(
1092            errors
1093                .iter()
1094                .any(|e| e.contains("'denied'") && e.contains("cannot have outgoing")),
1095            "{errors:?}"
1096        );
1097    }
1098
1099    /// Named error ports: any number of `type: error` boundary nodes (spec §1-2).
1100    #[test]
1101    fn test_supernode_multiple_error_boundaries_accepted() {
1102        let mut sn = valid_supernode();
1103        sn.nodes.push(inner("auth-error", "error"));
1104        // `up.error` already exits via the default `error` boundary; the extra
1105        // named error boundary may stay unconnected (boundaries are orphan-exempt).
1106        assert_eq!(validate_supernode(&sn), Ok(()));
1107    }
1108
1109    /// A definition whose only error boundary is renamed away from `error` is
1110    /// legal — the instance then has no default black-box exit.
1111    #[test]
1112    fn test_supernode_renamed_only_error_boundary_accepted() {
1113        let mut sn = valid_supernode();
1114        sn.nodes
1115            .iter_mut()
1116            .find(|n| n.node_type == "error")
1117            .unwrap()
1118            .id = "oops".into();
1119        sn.edges.iter_mut().find(|e| e.to == "error.in").unwrap().to = "oops.in".into();
1120        assert_eq!(validate_supernode(&sn), Ok(()));
1121    }
1122
1123    #[test]
1124    fn test_supernode_zero_error_boundaries_rejected() {
1125        let mut sn = valid_supernode();
1126        sn.nodes.retain(|n| n.node_type != "error");
1127        sn.edges.retain(|e| !e.to.starts_with("error."));
1128        let errors = validate_supernode(&sn).unwrap_err();
1129        assert!(
1130            errors
1131                .iter()
1132                .any(|e| e.contains("at least one 'error' boundary")),
1133            "{errors:?}"
1134        );
1135    }
1136
1137    #[test]
1138    fn test_supernode_reserved_error_ids_rejected() {
1139        for id in ["input", "output", "in", "out", "success"] {
1140            let mut sn = valid_supernode();
1141            sn.nodes.push(inner(id, "error"));
1142            let errors = validate_supernode(&sn).unwrap_err();
1143            assert!(
1144                errors.iter().any(|e| e.contains("reserved")),
1145                "id {id}: {errors:?}"
1146            );
1147        }
1148    }
1149}