1use std::collections::HashSet;
6
7use super::expand::{split_endpoint, BOUNDARY_TYPES};
8use crate::config::{PolicyConfig, SupernodeConfig};
9
10pub(crate) const RESERVED_OUTPUT_IDS: [&str; 5] = ["input", "error", "in", "out", "success"];
13
14pub(crate) const RESERVED_ERROR_IDS: [&str; 5] = ["input", "output", "in", "out", "success"];
18
19pub 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 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 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 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 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 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 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 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 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
143pub fn validate_supernode(sn: &SupernodeConfig) -> Result<(), Vec<String>> {
172 let mut errors = Vec::new();
173
174 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 {
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 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 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 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 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 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; };
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 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 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 #[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 #[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")); sn.edges.push(sn_edge("up.success", "input.in")); 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 #[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"), 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 #[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 ],
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 #[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 #[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 #[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 #[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 ],
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 #[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 #[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 #[test]
998 fn test_supernode_multiple_output_boundaries_accepted() {
999 let mut nodes = boundary_nodes(); 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 #[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 #[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 #[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 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 #[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 #[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 #[test]
1101 fn test_supernode_multiple_error_boundaries_accepted() {
1102 let mut sn = valid_supernode();
1103 sn.nodes.push(inner("auth-error", "error"));
1104 assert_eq!(validate_supernode(&sn), Ok(()));
1107 }
1108
1109 #[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}