1use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10
11use serde::Serialize;
12
13use crate::config::{NodeConfig, PolicyConfig};
14use crate::context::Context;
15use crate::debug::{EdgeKind, StepOutcome, TraceRecorder};
16use crate::plugins::resources::PluginResources;
17use crate::plugins::{self, Plugin};
18
19pub struct CompiledGraph {
24 nodes: HashMap<String, Box<dyn Plugin>>,
25 edges: HashMap<String, HashMap<String, String>>,
27 entry_node_id: String,
29 terminal_node_ids: HashSet<String>,
31 catch_all_handler: Option<String>,
33 policy_name: String,
35 resources: Arc<PluginResources>,
38 stream_capable: HashSet<String>,
42 buffering_reasons: Vec<BufferingReason>,
44 cache_pair_warnings: Vec<CachePairWarning>,
45 cache_targets: Vec<crate::traffic::CacheTarget>,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize)]
60pub struct CachePairWarning {
61 pub cache_id: String,
63 pub present_node_id: String,
65 pub missing_role: String,
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize)]
71pub struct BufferingReason {
72 #[serde(rename = "upstream")]
73 pub upstream_node_id: String,
74 #[serde(rename = "blocked_by")]
75 pub blocked_by_node_id: String,
76 pub node_type: String,
77}
78
79impl std::fmt::Debug for CompiledGraph {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.debug_struct("CompiledGraph")
85 .field("policy_name", &self.policy_name)
86 .field("node_ids", &self.nodes.keys().collect::<Vec<_>>())
87 .field("edges", &self.edges)
88 .field("entry_node_id", &self.entry_node_id)
89 .field("terminal_node_ids", &self.terminal_node_ids)
90 .field("catch_all_handler", &self.catch_all_handler)
91 .finish()
92 }
93}
94
95impl CompiledGraph {
96 pub async fn execute(&self, ctx: Context) -> Context {
109 self.run(ctx, None).await.0
110 }
111
112 pub async fn execute_traced(
120 &self,
121 ctx: Context,
122 recorder: TraceRecorder,
123 ) -> (Context, TraceRecorder) {
124 let (ctx, rec) = self.run(ctx, Some(recorder)).await;
125 (
126 ctx,
127 rec.expect("recorder is returned when one was supplied"),
128 )
129 }
130
131 async fn run(
137 &self,
138 mut ctx: Context,
139 mut recorder: Option<TraceRecorder>,
140 ) -> (Context, Option<TraceRecorder>) {
141 let mut current_node_id = self.entry_node_id.clone();
142
143 loop {
144 let node = match self.nodes.get(¤t_node_id) {
145 Some(n) => n,
146 None => {
147 ctx.response.status_code = 500;
148 ctx.response.body = bytes::Bytes::from(format!(
149 "Graph error: node '{}' not found",
150 current_node_id
151 ));
152 ctx.response.stream = None;
160 if let Some(r) = recorder.as_mut() {
161 r.record_step(
162 ¤t_node_id,
163 "<missing>",
164 StepOutcome::Error {
165 code: "NODE_NOT_FOUND".to_string(),
166 message: format!("node '{}' not found", current_node_id),
167 },
168 std::time::Duration::ZERO,
169 EdgeKind::NodeNotFound,
170 None,
171 None,
172 &ctx,
173 );
174 }
175 break;
176 }
177 };
178
179 let node_type = node.plugin_type().to_string();
180 ctx.message.insert(
197 "__may_stream".to_string(),
198 serde_json::json!(self.is_stream_capable(¤t_node_id)),
199 );
200 let started = std::time::Instant::now();
201 let result = node.execute(ctx).await;
202 let elapsed = started.elapsed();
203 if let Some(ref m) = self.resources.metrics {
204 m.node_execution_count
205 .with_label_values(&[&self.policy_name, ¤t_node_id, &node_type])
206 .inc();
207 m.node_execution_duration
208 .with_label_values(&[&self.policy_name, ¤t_node_id])
209 .observe(elapsed.as_secs_f64());
210 }
211
212 match result {
213 Ok(output) => {
214 ctx = output.context;
215 let port = output.port.unwrap_or("success");
216 debug_assert!(
221 port != "error",
222 "plugins must not emit on the error port via Ok"
223 );
224
225 let terminal = self.terminal_node_ids.contains(¤t_node_id);
227 let next = if terminal {
228 None
229 } else {
230 self.edges.get(¤t_node_id).and_then(|m| m.get(port))
231 };
232 if let Some(r) = recorder.as_mut() {
233 let edge = if terminal {
234 EdgeKind::Terminal
235 } else if next.is_none() {
236 EdgeKind::EndOfChain } else if port == "success" {
238 EdgeKind::Success
239 } else {
240 EdgeKind::Outcome
241 };
242 r.record_step(
243 ¤t_node_id,
244 &node_type,
245 StepOutcome::Success,
246 elapsed,
247 edge,
248 (port != "success").then_some(port),
249 next.map(String::as_str),
250 &ctx,
251 );
252 }
253 match next {
254 Some(next_id) => current_node_id = next_id.clone(),
255 None => break,
257 }
258 }
259 Err(mut err) => {
260 if let Some(ref m) = self.resources.metrics {
261 m.node_errors
262 .with_label_values(&[
263 &self.policy_name,
264 ¤t_node_id,
265 &err.error.code,
266 ])
267 .inc();
268 }
269
270 err.error.node_id = current_node_id.clone();
272 tracing::warn!(
276 "policy '{}': node '{}' exited on error port: {} ({})",
277 self.policy_name,
278 current_node_id,
279 err.error.code,
280 err.error.message
281 );
282 let outcome = StepOutcome::Error {
283 code: err.error.code.clone(),
284 message: err.error.message.clone(),
285 };
286 err.context.errors.push(err.error);
287 ctx = err.context;
288
289 let next_error = self
291 .edges
292 .get(¤t_node_id)
293 .and_then(|m| m.get("error"))
294 .map(|id| (id.clone(), EdgeKind::Error))
295 .or_else(|| {
296 self.catch_all_handler
297 .as_ref()
298 .map(|id| (id.clone(), EdgeKind::CatchAll))
299 });
300
301 if next_error.is_none() {
302 ctx.response.status_code = 500;
304 ctx.response.body = bytes::Bytes::from(
305 r#"{"error": "internal_error", "message": "Unhandled error in routing policy"}"#,
306 );
307 ctx.response.stream = None;
314 ctx.response.headers.insert(
315 "content-type".to_string(),
316 vec!["application/json".to_string()],
317 );
318 }
319
320 if let Some(r) = recorder.as_mut() {
321 let (edge, next_id) = match &next_error {
322 Some((id, kind)) => (*kind, Some(id.as_str())),
323 None => (EdgeKind::Unhandled, None),
324 };
325 r.record_step(
326 ¤t_node_id,
327 &node_type,
328 outcome,
329 elapsed,
330 edge,
331 None,
332 next_id,
333 &ctx,
334 );
335 }
336
337 match next_error {
338 Some((id, _)) => current_node_id = id,
339 None => break,
340 }
341 }
342 }
343 }
344
345 (ctx, recorder)
346 }
347
348 pub fn is_stream_capable(&self, node_id: &str) -> bool {
353 self.stream_capable.contains(node_id)
354 }
355
356 pub fn cache_pair_warnings(&self) -> &[CachePairWarning] {
361 &self.cache_pair_warnings
362 }
363
364 pub fn cache_targets(&self) -> &[crate::traffic::CacheTarget] {
366 &self.cache_targets
367 }
368
369 pub fn buffering_reasons(&self) -> &[BufferingReason] {
370 &self.buffering_reasons
371 }
372}
373
374pub fn compile_policy(
401 policy: &PolicyConfig,
402 resources: Arc<PluginResources>,
403) -> Result<CompiledGraph, String> {
404 let cache_pair_warnings = validate_cache_pairs(&policy.nodes)?;
408
409 let mut nodes: HashMap<String, Box<dyn Plugin>> = HashMap::new();
410 let mut listener_node_id = None;
411 let mut terminal_node_ids = HashSet::new();
412
413 for node_config in &policy.nodes {
415 tracing::debug!(
416 "Compiling node '{}' type='{}' config={:?}",
417 node_config.id,
418 node_config.node_type,
419 node_config.config
420 );
421 let mut config = node_config.config.clone();
428 for value in config.values_mut() {
429 crate::config::interpolate_env_json(value);
430 }
431 let plugin = plugins::create_plugin(&node_config.node_type, &config, &resources)?;
432 if node_config.node_type == "listener" {
433 listener_node_id = Some(node_config.id.clone());
434 }
435 if node_config.node_type == "client" {
436 terminal_node_ids.insert(node_config.id.clone());
437 }
438 nodes.insert(node_config.id.clone(), plugin);
439 }
440
441 let listener_node_id = listener_node_id.ok_or("Policy must have a listener node")?;
442
443 let node_types: HashMap<String, String> = policy
444 .nodes
445 .iter()
446 .map(|n| (n.id.clone(), n.node_type.clone()))
447 .collect();
448
449 let mut edges: HashMap<String, HashMap<String, String>> = HashMap::new();
451 for edge in &policy.edges {
452 let (from_node, from_port) = parse_edge_endpoint(&edge.from)?;
453 let (to_node, _to_port) = parse_edge_endpoint(&edge.to)?;
454 let from_port = if from_port == "out" {
455 "success".to_string()
456 } else {
457 from_port
458 };
459
460 let node_type = node_types.get(&from_node).ok_or_else(|| {
461 format!(
462 "policy '{}': edge references unknown node '{}'",
463 policy.name, from_node
464 )
465 })?;
466 let spec = plugins::port_spec(node_type)
467 .ok_or_else(|| format!("Unknown plugin type: {}", node_type))?;
468 if !spec.outputs.iter().any(|p| p.name == from_port) {
469 return Err(format!(
470 "policy '{}': node '{}' (type '{}') has no output port '{}'",
471 policy.name, from_node, node_type, from_port
472 ));
473 }
474 if edges
475 .entry(from_node.clone())
476 .or_default()
477 .insert(from_port.clone(), to_node)
478 .is_some()
479 {
480 return Err(format!(
481 "policy '{}': duplicate edge from '{}.{}' — fan-out is not supported",
482 policy.name, from_node, from_port
483 ));
484 }
485 }
486
487 for node_config in &policy.nodes {
489 let spec = plugins::port_spec(&node_config.node_type)
490 .ok_or_else(|| format!("Unknown plugin type: {}", node_config.node_type))?;
491 for p in spec.outputs {
492 if matches!(p.kind, plugins::ports::PortKind::Error) {
493 continue;
494 }
495 let wired = edges
496 .get(&node_config.id)
497 .is_some_and(|m| m.contains_key(p.name));
498 if !wired {
499 return Err(format!(
500 "policy '{}': output port '{}' of node '{}' (type '{}') must be wired — add an edge from '{}.{}'",
501 policy.name, p.name, node_config.id, node_config.node_type, node_config.id, p.name
502 ));
503 }
504 }
505 }
506
507 for node_config in &policy.nodes {
513 let start = &node_config.id;
514 let mut stack: Vec<&String> = edges
515 .get(start)
516 .map(|m| m.values().collect())
517 .unwrap_or_default();
518 let mut seen: HashSet<&String> = HashSet::new();
519 while let Some(next) = stack.pop() {
520 if next == start {
521 return Err(format!(
522 "policy '{}': policy graph contains a cycle through node '{}' — policies must be acyclic",
523 policy.name, start
524 ));
525 }
526 if seen.insert(next) {
527 if let Some(m) = edges.get(next) {
528 stack.extend(m.values());
529 }
530 }
531 }
532 }
533
534 let entry_node_id = edges
536 .get(&listener_node_id)
537 .and_then(|m| m.get("success"))
538 .cloned()
539 .unwrap_or_else(|| listener_node_id.clone());
540
541 let (stream_capable, buffering_reasons) =
547 infer_stream_capability(&policy.nodes, &nodes, &edges);
548
549 for reason in &buffering_reasons {
550 tracing::info!(
551 policy = %policy.name,
552 upstream = %reason.upstream_node_id,
553 blocked_by = %reason.blocked_by_node_id,
554 node_type = %reason.node_type,
555 "response buffering: upstream cannot stream because a downstream node reads the response body"
556 );
557 }
558
559 let cache_targets: Vec<_> = nodes.values().filter_map(|n| n.cache_target()).collect();
560
561 Ok(CompiledGraph {
562 nodes,
563 edges,
564 entry_node_id,
565 terminal_node_ids,
566 catch_all_handler: policy.error_handler.clone(),
567 policy_name: policy.name.clone(),
568 resources,
569 stream_capable,
570 buffering_reasons,
571 cache_pair_warnings,
572 cache_targets,
573 })
574}
575
576fn validate_cache_pairs(policy_nodes: &[NodeConfig]) -> Result<Vec<CachePairWarning>, String> {
588 struct Half<'a> {
590 cache_id: &'a str,
591 node_id: &'a str,
592 role: &'a str,
593 policy: &'a str,
594 store: &'a str,
595 }
596
597 let mut halves: Vec<Half> = Vec::new();
598 for node in policy_nodes {
599 if node.node_type != "proxy-cache" {
600 continue;
601 }
602 let get = |key: &str| -> Option<&str> { node.config.get(key).and_then(|v| v.as_str()) };
603 let Some(cache_id) = get("id") else { continue };
604 let Some(role) = get("phase").or_else(|| get("role")) else {
606 continue;
607 };
608 halves.push(Half {
609 node_id: &node.id,
610 role,
611 policy: get("policy").unwrap_or("local"),
612 store: get("store").unwrap_or(""),
613 cache_id,
614 });
615 }
616
617 let mut warnings = Vec::new();
618 let ids: Vec<&str> = {
619 let mut seen: Vec<&str> = Vec::new();
620 for h in &halves {
621 if !seen.contains(&h.cache_id) {
622 seen.push(h.cache_id);
623 }
624 }
625 seen
626 };
627
628 for cache_id in ids {
629 let group: Vec<&Half> = halves.iter().filter(|h| h.cache_id == cache_id).collect();
630
631 for pair in group.windows(2) {
634 let (a, b) = (pair[0], pair[1]);
635 for (key, left, right) in [("policy", a.policy, b.policy), ("store", a.store, b.store)]
636 {
637 if left != right {
638 return Err(format!(
639 "proxy-cache pair '{}': node '{}' uses {} '{}' but node '{}' uses {} '{}'. Both halves must agree, or the store half writes where the lookup half never reads and the route serves a permanent 100% miss with no error",
640 cache_id, a.node_id, key, left, b.node_id, key, right
641 ));
642 }
643 }
644 }
645
646 let has_lookup = group.iter().any(|h| h.role == "lookup");
650 let has_store = group.iter().any(|h| h.role == "store");
651 if !(has_lookup && has_store) {
652 let present = group.first().expect("groups are non-empty");
656 warnings.push(CachePairWarning {
657 cache_id: cache_id.to_string(),
658 present_node_id: present.node_id.to_string(),
659 missing_role: if !has_lookup { "lookup" } else { "store" }.to_string(),
660 });
661 }
662 }
663
664 Ok(warnings)
665}
666
667fn infer_stream_capability(
716 policy_nodes: &[NodeConfig],
717 nodes: &HashMap<String, Box<dyn Plugin>>,
718 edges: &HashMap<String, HashMap<String, String>>,
719) -> (HashSet<String>, Vec<BufferingReason>) {
720 let mut stream_capable = HashSet::new();
721 let mut buffering_reasons = Vec::new();
722
723 for node_config in policy_nodes {
724 if node_config.node_type != "upstream" {
725 continue;
726 }
727 let node_id = &node_config.id;
728 let mut blocked_by: Option<(&String, &str)> = None;
729 let mut seen: HashSet<&String> = HashSet::new();
730 let mut queue: Vec<&String> = edges
731 .get(node_id)
732 .and_then(|ports| ports.get("success"))
733 .into_iter()
734 .collect();
735
736 while let Some(current) = queue.pop() {
737 if !seen.insert(current) {
738 continue;
739 }
740 let Some(p) = nodes.get(current) else {
741 blocked_by = Some((current, "unknown"));
745 break;
746 };
747 if p.reads_response_body() {
748 blocked_by = Some((current, p.plugin_type()));
749 break;
750 }
751 if let Some(ports) = edges.get(current) {
752 let mut names: Vec<&String> = ports
753 .keys()
754 .filter(|name| name.as_str() != "error")
755 .collect();
756 names.sort();
757 queue.extend(names.into_iter().filter_map(|name| ports.get(name)));
758 }
759 }
760
761 match blocked_by {
762 None => {
763 stream_capable.insert(node_id.clone());
764 }
765 Some((blocker, node_type)) => buffering_reasons.push(BufferingReason {
766 upstream_node_id: node_id.clone(),
767 blocked_by_node_id: blocker.clone(),
768 node_type: node_type.to_string(),
769 }),
770 }
771 }
772
773 (stream_capable, buffering_reasons)
774}
775
776fn parse_edge_endpoint(endpoint: &str) -> Result<(String, String), String> {
780 if let Some(dot_pos) = endpoint.rfind('.') {
781 let node_id = endpoint[..dot_pos].to_string();
782 let port = endpoint[dot_pos + 1..].to_string();
783 Ok((node_id, port))
784 } else {
785 Ok((endpoint.to_string(), "out".to_string()))
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792 use crate::config::{EdgeConfig, NodeConfig, PolicyConfig};
793 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
794 use bytes::Bytes;
795
796 #[test]
797 fn test_parse_edge_endpoint() {
798 let (node, port) = parse_edge_endpoint("listener.out").unwrap();
799 assert_eq!(node, "listener");
800 assert_eq!(port, "out");
801
802 let (node, port) = parse_edge_endpoint("upstream.error").unwrap();
803 assert_eq!(node, "upstream");
804 assert_eq!(port, "error");
805
806 let (node, port) = parse_edge_endpoint("rewrite.success").unwrap();
807 assert_eq!(node, "rewrite");
808 assert_eq!(port, "success");
809 }
810
811 fn test_context(path: &str) -> Context {
812 Context {
813 request: GatewayRequest {
814 method: "GET".to_string(),
815 path: path.to_string(),
816 host: "localhost".to_string(),
817 scheme: "http".to_string(),
818 headers: HashMap::new(),
819 query_params: HashMap::new(),
820 body: Bytes::new(),
821 remote_addr: "127.0.0.1:12345".to_string(),
822 protocol: Protocol::Http1,
823 },
824 response: GatewayResponse {
825 status_code: 0,
826 headers: HashMap::new(),
827 body: Bytes::new(),
828 stream: None,
829 },
830 message: HashMap::new(),
831 errors: Vec::new(),
832 }
833 }
834
835 #[tokio::test]
836 async fn test_graph_proxy_rewrite_pipeline() {
837 let mut rewrite_config = HashMap::new();
838 rewrite_config.insert(
839 "strip_path_prefix".to_string(),
840 serde_json::Value::String("/api/v1".to_string()),
841 );
842 rewrite_config.insert(
843 "phase".to_string(),
844 serde_json::Value::String("request".to_string()),
845 );
846
847 let policy = PolicyConfig {
848 name: "test".to_string(),
849 error_handler: None,
850 nodes: vec![
851 NodeConfig {
852 id: "listener".to_string(),
853 node_type: "listener".to_string(),
854 config: HashMap::new(),
855 config_ref: None,
856 position: None,
857 },
858 NodeConfig {
859 id: "rewrite".to_string(),
860 node_type: "proxy-rewrite".to_string(),
861 config: rewrite_config,
862 config_ref: None,
863 position: None,
864 },
865 NodeConfig {
866 id: "client".to_string(),
867 node_type: "client".to_string(),
868 config: HashMap::new(),
869 config_ref: None,
870 position: None,
871 },
872 ],
873 edges: vec![
874 EdgeConfig {
875 from: "listener.out".to_string(),
876 to: "rewrite.in".to_string(),
877 },
878 EdgeConfig {
879 from: "rewrite.success".to_string(),
880 to: "client.in".to_string(),
881 },
882 ],
883 };
884
885 let graph = compile_policy(&policy, PluginResources::empty()).unwrap();
886 let ctx = test_context("/api/v1/users");
887 let result = graph.execute(ctx).await;
888
889 assert_eq!(result.request.path, "/users");
890 }
891
892 #[tokio::test]
893 async fn test_graph_records_node_metrics() {
894 let policy = PolicyConfig {
895 name: "metrics-test".to_string(),
896 error_handler: None,
897 nodes: vec![
898 NodeConfig {
899 id: "listener".to_string(),
900 node_type: "listener".to_string(),
901 config: HashMap::new(),
902 config_ref: None,
903 position: None,
904 },
905 NodeConfig {
906 id: "client".to_string(),
907 node_type: "client".to_string(),
908 config: HashMap::new(),
909 config_ref: None,
910 position: None,
911 },
912 ],
913 edges: vec![EdgeConfig {
914 from: "listener.out".to_string(),
915 to: "client.in".to_string(),
916 }],
917 };
918
919 let metrics = Arc::new(crate::metrics::GatewayMetrics::new());
920 let graph = compile_policy(&policy, PluginResources::new(Some(metrics.clone()))).unwrap();
921 graph.execute(test_context("/test")).await;
922
923 assert_eq!(
924 metrics
925 .node_execution_count
926 .with_label_values(&["metrics-test", "client", "client"])
927 .get(),
928 1
929 );
930 }
931
932 #[tokio::test]
940 async fn test_graph_error_handler_catch_all() {
941 let policy = PolicyConfig {
942 name: "test".to_string(),
943 error_handler: Some("error-handler".to_string()),
944 nodes: vec![
945 NodeConfig {
946 id: "listener".to_string(),
947 node_type: "listener".to_string(),
948 config: HashMap::new(),
949 config_ref: None,
950 position: None,
951 },
952 NodeConfig {
953 id: "backend".to_string(),
954 node_type: "upstream".to_string(),
955 config: {
956 let mut c = HashMap::new();
957 c.insert(
959 "targets".to_string(),
960 serde_json::json!([{ "host": "127.0.0.1", "port": 1 }]),
961 );
962 c.insert("timeout_ms".to_string(), serde_json::json!(500));
963 c
964 },
965 config_ref: None,
966 position: None,
967 },
968 NodeConfig {
969 id: "error-handler".to_string(),
970 node_type: "error-handler".to_string(),
971 config: {
972 let mut c = HashMap::new();
973 c.insert("status_code".to_string(), serde_json::json!(503));
974 c.insert(
975 "body_template".to_string(),
976 serde_json::Value::String(r#"{"error": "{{error.code}}"}"#.to_string()),
977 );
978 c
979 },
980 config_ref: None,
981 position: None,
982 },
983 NodeConfig {
984 id: "client".to_string(),
985 node_type: "client".to_string(),
986 config: HashMap::new(),
987 config_ref: None,
988 position: None,
989 },
990 ],
991 edges: vec![
992 EdgeConfig {
993 from: "listener.out".to_string(),
994 to: "backend.in".to_string(),
995 },
996 EdgeConfig {
998 from: "backend.success".to_string(),
999 to: "client.in".to_string(),
1000 },
1001 EdgeConfig {
1002 from: "error-handler.success".to_string(),
1003 to: "client.in".to_string(),
1004 },
1005 ],
1006 };
1007
1008 let graph = compile_policy(&policy, PluginResources::empty()).unwrap();
1009 let ctx = test_context("/test");
1010 let result = graph.execute(ctx).await;
1011
1012 assert_eq!(result.response.status_code, 503);
1013 let body = String::from_utf8(result.response.body.to_vec()).unwrap();
1014 assert!(!body.contains("{{"), "template must be rendered: {body}");
1015 }
1016
1017 #[tokio::test]
1022 async fn test_error_handler_passes_errorless_context_through() {
1023 let policy = PolicyConfig {
1024 name: "test".to_string(),
1025 error_handler: None,
1026 nodes: vec![
1027 NodeConfig {
1028 id: "listener".to_string(),
1029 node_type: "listener".to_string(),
1030 config: HashMap::new(),
1031 config_ref: None,
1032 position: None,
1033 },
1034 NodeConfig {
1035 id: "error-handler".to_string(),
1036 node_type: "error-handler".to_string(),
1037 config: {
1038 let mut c = HashMap::new();
1039 c.insert("status_code".to_string(), serde_json::json!(503));
1040 c.insert(
1041 "body_template".to_string(),
1042 serde_json::Value::String(r#"{"error": "{{error.code}}"}"#.to_string()),
1043 );
1044 c
1045 },
1046 config_ref: None,
1047 position: None,
1048 },
1049 NodeConfig {
1050 id: "client".to_string(),
1051 node_type: "client".to_string(),
1052 config: HashMap::new(),
1053 config_ref: None,
1054 position: None,
1055 },
1056 ],
1057 edges: vec![
1058 EdgeConfig {
1059 from: "listener.out".to_string(),
1060 to: "error-handler.in".to_string(),
1061 },
1062 EdgeConfig {
1063 from: "error-handler.success".to_string(),
1064 to: "client.in".to_string(),
1065 },
1066 ],
1067 };
1068
1069 let graph = compile_policy(&policy, PluginResources::empty()).unwrap();
1070 let mut ctx = test_context("/test");
1071 ctx.response.status_code = 401;
1073 ctx.response.body = Bytes::from(r#"{"error":"unauthorized"}"#);
1074 let result = graph.execute(ctx).await;
1075
1076 assert_eq!(result.response.status_code, 401);
1077 assert_eq!(
1078 String::from_utf8(result.response.body.to_vec()).unwrap(),
1079 r#"{"error":"unauthorized"}"#
1080 );
1081 }
1082
1083 use crate::debug::{CaptureOptions, EdgeKind, StepOutcome, TraceRecorder, TraceSource};
1086 use std::time::Duration;
1087
1088 fn recorder(ctx: &Context) -> TraceRecorder {
1089 TraceRecorder::new(ctx, CaptureOptions::default(), 100)
1090 }
1091
1092 fn finish(rec: TraceRecorder, ctx: &Context) -> crate::debug::Trace {
1093 rec.finish(
1094 "t".to_string(),
1095 0,
1096 TraceSource::Request,
1097 Some("r".to_string()),
1098 "p".to_string(),
1099 ctx,
1100 Duration::from_millis(1),
1101 )
1102 }
1103
1104 struct AlwaysFails;
1107
1108 #[async_trait::async_trait]
1109 impl Plugin for AlwaysFails {
1110 fn plugin_type(&self) -> &str {
1111 "always-fails"
1112 }
1113 async fn execute(&self, ctx: Context) -> crate::plugins::PluginResult {
1114 Err(crate::plugins::PluginExecutionError {
1115 context: ctx,
1116 error: crate::context::GatewayError {
1117 node_id: String::new(),
1118 code: "BOOM".to_string(),
1119 message: "exploded".to_string(),
1120 metadata: HashMap::new(),
1121 },
1122 })
1123 }
1124 }
1125
1126 fn failing_graph(
1128 error_edges: HashMap<String, String>,
1129 catch_all: Option<String>,
1130 ) -> CompiledGraph {
1131 let mut nodes: HashMap<String, Box<dyn Plugin>> = HashMap::new();
1132 nodes.insert("boom".to_string(), Box::new(AlwaysFails));
1133 nodes.insert(
1134 "client".to_string(),
1135 plugins::create_plugin("client", &HashMap::new(), &PluginResources::empty()).unwrap(),
1136 );
1137 let mut edges: HashMap<String, HashMap<String, String>> = HashMap::new();
1138 if let Some(target) = error_edges.get("boom") {
1139 edges
1140 .entry("boom".to_string())
1141 .or_default()
1142 .insert("error".to_string(), target.clone());
1143 }
1144 CompiledGraph {
1145 nodes,
1146 edges,
1147 entry_node_id: "boom".to_string(),
1148 terminal_node_ids: HashSet::from(["client".to_string()]),
1149 catch_all_handler: catch_all,
1150 policy_name: "p".to_string(),
1151 resources: PluginResources::empty(),
1152 stream_capable: HashSet::new(),
1153 buffering_reasons: Vec::new(),
1154 cache_pair_warnings: Vec::new(),
1155 cache_targets: Vec::new(),
1156 }
1157 }
1158
1159 fn rewrite_policy() -> PolicyConfig {
1160 let mut cfg = HashMap::new();
1161 cfg.insert(
1162 "strip_path_prefix".to_string(),
1163 serde_json::Value::String("/api/v1".to_string()),
1164 );
1165 cfg.insert(
1166 "phase".to_string(),
1167 serde_json::Value::String("request".to_string()),
1168 );
1169 PolicyConfig {
1170 name: "traced".to_string(),
1171 error_handler: None,
1172 nodes: vec![
1173 NodeConfig {
1174 id: "listener".to_string(),
1175 node_type: "listener".to_string(),
1176 config: HashMap::new(),
1177 config_ref: None,
1178 position: None,
1179 },
1180 NodeConfig {
1181 id: "rewrite".to_string(),
1182 node_type: "proxy-rewrite".to_string(),
1183 config: cfg,
1184 config_ref: None,
1185 position: None,
1186 },
1187 NodeConfig {
1188 id: "client".to_string(),
1189 node_type: "client".to_string(),
1190 config: HashMap::new(),
1191 config_ref: None,
1192 position: None,
1193 },
1194 ],
1195 edges: vec![
1196 EdgeConfig {
1197 from: "listener.out".to_string(),
1198 to: "rewrite.in".to_string(),
1199 },
1200 EdgeConfig {
1201 from: "rewrite.success".to_string(),
1202 to: "client.in".to_string(),
1203 },
1204 ],
1205 }
1206 }
1207
1208 #[tokio::test]
1209 async fn test_trace_records_each_node_in_order() {
1210 let graph = compile_policy(&rewrite_policy(), PluginResources::empty()).unwrap();
1211 let ctx = test_context("/api/v1/users");
1212 let rec = recorder(&ctx);
1213 let (out, rec) = graph.execute_traced(ctx, rec).await;
1214 let trace = finish(rec, &out);
1215
1216 let ids: Vec<&str> = trace.steps.iter().map(|s| s.node_id.as_str()).collect();
1217 assert_eq!(ids, vec!["rewrite", "client"]);
1218 assert_eq!(trace.steps[0].node_type, "proxy-rewrite");
1219 assert_eq!(trace.steps[0].edge, EdgeKind::Success);
1220 assert_eq!(trace.steps[0].next_node_id.as_deref(), Some("client"));
1221 assert_eq!(trace.steps[1].edge, EdgeKind::Terminal);
1223 assert_eq!(trace.steps[1].next_node_id, None);
1224 }
1225
1226 #[tokio::test]
1228 async fn test_trace_captures_what_the_plugin_changed() {
1229 let graph = compile_policy(&rewrite_policy(), PluginResources::empty()).unwrap();
1230 let ctx = test_context("/api/v1/users");
1231 let rec = recorder(&ctx);
1232 let (out, rec) = graph.execute_traced(ctx, rec).await;
1233 let trace = finish(rec, &out);
1234
1235 assert_eq!(trace.initial.request.path, "/api/v1/users");
1236 assert_eq!(trace.steps[0].after.request.path, "/users");
1237
1238 let changes = crate::debug::diff::diff(&trace.initial, &trace.steps[0].after);
1239 let c = changes
1240 .iter()
1241 .find(|c| c.path == "request.path")
1242 .expect("path change");
1243 assert_eq!(c.before.as_deref(), Some("/api/v1/users"));
1244 assert_eq!(c.after.as_deref(), Some("/users"));
1245 }
1246
1247 #[tokio::test]
1250 async fn test_tracing_does_not_alter_behaviour() {
1251 let policy = rewrite_policy();
1252 let plain = compile_policy(&policy, PluginResources::empty()).unwrap();
1253 let traced = compile_policy(&policy, PluginResources::empty()).unwrap();
1254
1255 let untraced_out = plain.execute(test_context("/api/v1/users")).await;
1256 let ctx = test_context("/api/v1/users");
1257 let rec = recorder(&ctx);
1258 let (traced_out, _) = traced.execute_traced(ctx, rec).await;
1259
1260 assert_eq!(untraced_out.request.path, traced_out.request.path);
1261 assert_eq!(
1262 untraced_out.response.status_code,
1263 traced_out.response.status_code
1264 );
1265 assert_eq!(untraced_out.response.body, traced_out.response.body);
1266 assert_eq!(untraced_out.message, traced_out.message);
1267 assert_eq!(untraced_out.errors, traced_out.errors);
1268 }
1269
1270 #[tokio::test]
1271 async fn test_trace_records_error_edge() {
1272 let graph = failing_graph(
1273 HashMap::from([("boom".to_string(), "client".to_string())]),
1274 None,
1275 );
1276 let ctx = test_context("/x");
1277 let rec = recorder(&ctx);
1278 let (out, rec) = graph.execute_traced(ctx, rec).await;
1279 let trace = finish(rec, &out);
1280
1281 assert_eq!(trace.steps[0].node_id, "boom");
1282 assert_eq!(
1283 trace.steps[0].outcome,
1284 StepOutcome::Error {
1285 code: "BOOM".to_string(),
1286 message: "exploded".to_string()
1287 }
1288 );
1289 assert_eq!(trace.steps[0].edge, EdgeKind::Error);
1290 assert_eq!(trace.steps[0].next_node_id.as_deref(), Some("client"));
1291 assert_eq!(trace.steps[0].after.errors.len(), 1);
1293 assert_eq!(trace.steps[0].after.errors[0].node_id, "boom");
1294 }
1295
1296 #[tokio::test]
1301 async fn test_error_port_exit_is_logged_at_warn() {
1302 let graph = failing_graph(
1303 HashMap::from([("boom".to_string(), "client".to_string())]),
1304 None,
1305 );
1306 let (_guard, logs) = crate::test_log::capture_warnings();
1307 let _ = graph.execute(test_context("/x")).await;
1308
1309 let out = logs.contents();
1310 assert!(out.contains("WARN"), "expected a WARN line, got: {out:?}");
1311 for needle in ["policy 'p'", "node 'boom'", "BOOM", "exploded"] {
1312 assert!(out.contains(needle), "missing {needle:?} in: {out:?}");
1313 }
1314 }
1315
1316 #[tokio::test]
1317 async fn test_trace_records_catch_all_edge() {
1318 let graph = failing_graph(HashMap::new(), Some("client".to_string()));
1319 let ctx = test_context("/x");
1320 let rec = recorder(&ctx);
1321 let (out, rec) = graph.execute_traced(ctx, rec).await;
1322 let trace = finish(rec, &out);
1323 assert_eq!(trace.steps[0].edge, EdgeKind::CatchAll);
1324 }
1325
1326 #[tokio::test]
1329 async fn test_trace_records_unhandled_error() {
1330 let graph = failing_graph(HashMap::new(), None);
1331 let ctx = test_context("/x");
1332 let rec = recorder(&ctx);
1333 let (out, rec) = graph.execute_traced(ctx, rec).await;
1334 let trace = finish(rec, &out);
1335
1336 assert_eq!(trace.steps.len(), 1);
1337 assert_eq!(trace.steps[0].edge, EdgeKind::Unhandled);
1338 assert_eq!(trace.steps[0].next_node_id, None);
1339 assert_eq!(out.response.status_code, 500);
1341 assert_eq!(trace.steps[0].after.response.status_code, 500);
1342 }
1343
1344 #[tokio::test]
1350 async fn test_node_not_found_clears_stale_stream() {
1351 use crate::context::stream::ResponseStream;
1352 use http_body_util::{BodyExt, Full};
1353
1354 let graph = CompiledGraph {
1355 nodes: HashMap::new(),
1356 edges: HashMap::new(),
1357 entry_node_id: "missing".to_string(),
1358 terminal_node_ids: HashSet::new(),
1359 catch_all_handler: None,
1360 policy_name: "p".to_string(),
1361 resources: PluginResources::empty(),
1362 stream_capable: HashSet::new(),
1363 buffering_reasons: Vec::new(),
1364 cache_pair_warnings: Vec::new(),
1365 cache_targets: Vec::new(),
1366 };
1367 let mut ctx = test_context("/x");
1368 let boxed = Full::new(Bytes::from_static(b"partial-stream-bytes"))
1369 .map_err(|never| match never {})
1370 .boxed();
1371 ctx.response.stream = Some(ResponseStream::new(boxed));
1372
1373 let out = graph.execute(ctx).await;
1374
1375 assert_eq!(out.response.status_code, 500);
1376 assert!(
1377 out.response.stream.is_none(),
1378 "the generated node-not-found body must not coexist with a stale stream"
1379 );
1380 assert!(String::from_utf8(out.response.body.to_vec())
1381 .unwrap()
1382 .contains("node 'missing' not found"));
1383 }
1384
1385 #[tokio::test]
1393 async fn test_unhandled_error_clears_stale_stream() {
1394 use crate::context::stream::ResponseStream;
1395 use http_body_util::{BodyExt, Full};
1396
1397 let graph = failing_graph(HashMap::new(), None);
1398 let mut ctx = test_context("/x");
1399 let boxed = Full::new(Bytes::from_static(b"partial-stream-bytes"))
1400 .map_err(|never| match never {})
1401 .boxed();
1402 ctx.response.stream = Some(ResponseStream::new(boxed));
1403
1404 let out = graph.execute(ctx).await;
1405
1406 assert_eq!(out.response.status_code, 500);
1407 assert!(
1408 out.response.stream.is_none(),
1409 "the generated 500 body must not coexist with a stale stream"
1410 );
1411 assert!(String::from_utf8(out.response.body.to_vec())
1412 .unwrap()
1413 .contains("Unhandled error in routing policy"));
1414 }
1415
1416 struct DenyOnHeader;
1422
1423 #[async_trait::async_trait]
1424 impl Plugin for DenyOnHeader {
1425 fn plugin_type(&self) -> &str {
1426 "deny-on-header"
1427 }
1428 async fn execute(&self, ctx: Context) -> crate::plugins::PluginResult {
1429 if ctx.request.headers.contains_key("x-deny") {
1430 Ok(plugins::PluginOutput::on_port(ctx, "denied"))
1431 } else {
1432 Ok(plugins::PluginOutput::success(ctx))
1433 }
1434 }
1435 }
1436
1437 fn outcome_graph() -> CompiledGraph {
1440 let mut nodes: HashMap<String, Box<dyn Plugin>> = HashMap::new();
1441 nodes.insert("n".to_string(), Box::new(DenyOnHeader));
1442 nodes.insert(
1443 "client".to_string(),
1444 plugins::create_plugin("client", &HashMap::new(), &PluginResources::empty()).unwrap(),
1445 );
1446 nodes.insert(
1447 "deny-client".to_string(),
1448 plugins::create_plugin("client", &HashMap::new(), &PluginResources::empty()).unwrap(),
1449 );
1450 let mut n_edges = HashMap::new();
1451 n_edges.insert("denied".to_string(), "deny-client".to_string());
1452 n_edges.insert("success".to_string(), "client".to_string());
1453 let mut edges = HashMap::new();
1454 edges.insert("n".to_string(), n_edges);
1455 CompiledGraph {
1456 nodes,
1457 edges,
1458 entry_node_id: "n".to_string(),
1459 terminal_node_ids: HashSet::from(["client".to_string(), "deny-client".to_string()]),
1460 catch_all_handler: None,
1461 policy_name: "p".to_string(),
1462 resources: PluginResources::empty(),
1463 stream_capable: HashSet::new(),
1464 buffering_reasons: Vec::new(),
1465 cache_pair_warnings: Vec::new(),
1466 cache_targets: Vec::new(),
1467 }
1468 }
1469
1470 #[tokio::test]
1472 async fn test_outcome_port_routes_to_its_edge() {
1473 let graph = outcome_graph();
1474
1475 let mut ctx = test_context("/x");
1476 ctx.request
1477 .headers
1478 .insert("x-deny".to_string(), vec!["1".to_string()]);
1479 let rec = recorder(&ctx);
1480 let (out, rec) = graph.execute_traced(ctx, rec).await;
1481 let trace = finish(rec, &out);
1482
1483 assert_eq!(trace.steps[0].node_id, "n");
1484 assert_eq!(trace.steps[0].edge, EdgeKind::Outcome);
1485 assert_eq!(trace.steps[0].port.as_deref(), Some("denied"));
1486 assert_eq!(trace.steps[0].next_node_id.as_deref(), Some("deny-client"));
1487 assert_eq!(trace.steps.len(), 2);
1489 assert_eq!(trace.steps[1].node_id, "deny-client");
1490 }
1491
1492 #[tokio::test]
1494 async fn test_outcome_port_falls_back_to_success() {
1495 let graph = outcome_graph();
1496 let ctx = test_context("/x");
1497 let rec = recorder(&ctx);
1498 let (out, rec) = graph.execute_traced(ctx, rec).await;
1499 let trace = finish(rec, &out);
1500
1501 assert_eq!(trace.steps[0].edge, EdgeKind::Success);
1502 assert!(trace.steps[0].port.is_none());
1503 assert_eq!(trace.steps[0].next_node_id.as_deref(), Some("client"));
1504 }
1505
1506 fn cors_policy() -> PolicyConfig {
1513 let mut cfg = HashMap::new();
1514 cfg.insert(
1515 "allowed_origins".to_string(),
1516 serde_json::json!(["http://sub.domain.com"]),
1517 );
1518 PolicyConfig {
1519 name: "cors-preflight".to_string(),
1520 error_handler: None,
1521 nodes: vec![
1522 NodeConfig {
1523 id: "listener".to_string(),
1524 node_type: "listener".to_string(),
1525 config: HashMap::new(),
1526 config_ref: None,
1527 position: None,
1528 },
1529 NodeConfig {
1530 id: "cors".to_string(),
1531 node_type: "cors".to_string(),
1532 config: cfg,
1533 config_ref: None,
1534 position: None,
1535 },
1536 NodeConfig {
1537 id: "client".to_string(),
1538 node_type: "client".to_string(),
1539 config: HashMap::new(),
1540 config_ref: None,
1541 position: None,
1542 },
1543 ],
1544 edges: vec![
1545 EdgeConfig {
1546 from: "listener.out".to_string(),
1547 to: "cors.in".to_string(),
1548 },
1549 EdgeConfig {
1550 from: "cors.success".to_string(),
1551 to: "client.in".to_string(),
1552 },
1553 EdgeConfig {
1554 from: "cors.preflight".to_string(),
1555 to: "client.in".to_string(),
1556 },
1557 ],
1558 }
1559 }
1560
1561 #[tokio::test]
1562 async fn test_cors_preflight_exits_via_preflight_edge_end_to_end() {
1563 let graph = compile_policy(&cors_policy(), PluginResources::empty()).unwrap();
1564
1565 let mut ctx = test_context("/x");
1566 ctx.request.method = "OPTIONS".to_string();
1567 ctx.request.headers.insert(
1568 "origin".to_string(),
1569 vec!["http://sub.domain.com".to_string()],
1570 );
1571 let rec = recorder(&ctx);
1572 let (out, rec) = graph.execute_traced(ctx, rec).await;
1573 let trace = finish(rec, &out);
1574
1575 assert_eq!(trace.steps[0].node_id, "cors");
1576 assert_eq!(trace.steps[0].edge, EdgeKind::Outcome);
1577 assert_eq!(trace.steps[0].port.as_deref(), Some("preflight"));
1578 assert_eq!(trace.steps[0].next_node_id.as_deref(), Some("client"));
1579 assert_eq!(out.response.status_code, 204);
1580 assert_eq!(
1581 out.response.headers.get("access-control-allow-origin"),
1582 Some(&vec!["http://sub.domain.com".to_string()])
1583 );
1584 }
1585
1586 fn policy_missing_success_edge() -> PolicyConfig {
1589 PolicyConfig {
1590 name: "p".to_string(),
1591 error_handler: None,
1592 nodes: vec![
1593 NodeConfig {
1594 id: "listener".to_string(),
1595 node_type: "listener".to_string(),
1596 config: HashMap::new(),
1597 config_ref: None,
1598 position: None,
1599 },
1600 NodeConfig {
1601 id: "rw".to_string(),
1602 node_type: "proxy-rewrite".to_string(),
1603 config: HashMap::new(),
1604 config_ref: None,
1605 position: None,
1606 },
1607 NodeConfig {
1608 id: "client".to_string(),
1609 node_type: "client".to_string(),
1610 config: HashMap::new(),
1611 config_ref: None,
1612 position: None,
1613 },
1614 ],
1615 edges: vec![EdgeConfig {
1616 from: "listener.out".to_string(),
1617 to: "rw.in".to_string(),
1618 }],
1619 }
1620 }
1621
1622 #[test]
1624 fn test_compile_rejects_unwired_mandatory_port() {
1625 let policy = policy_missing_success_edge();
1626 let err = compile_policy(&policy, PluginResources::empty()).unwrap_err();
1627 assert_eq!(
1628 err,
1629 "policy 'p': output port 'success' of node 'rw' (type 'proxy-rewrite') must be wired — add an edge from 'rw.success'"
1630 );
1631 }
1632
1633 #[test]
1635 fn test_compile_rejects_fanout() {
1636 let mut policy = policy_missing_success_edge();
1637 policy.edges.push(EdgeConfig {
1638 from: "rw.success".to_string(),
1639 to: "client.in".to_string(),
1640 });
1641 policy.edges.push(EdgeConfig {
1642 from: "rw.success".to_string(),
1643 to: "client.in".to_string(),
1644 });
1645 let err = compile_policy(&policy, PluginResources::empty()).unwrap_err();
1646 assert_eq!(
1647 err,
1648 "policy 'p': duplicate edge from 'rw.success' — fan-out is not supported"
1649 );
1650 }
1651
1652 #[test]
1654 fn test_compile_rejects_undeclared_port() {
1655 let mut policy = policy_missing_success_edge();
1656 policy.edges.push(EdgeConfig {
1657 from: "rw.banana".to_string(),
1658 to: "client.in".to_string(),
1659 });
1660 let err = compile_policy(&policy, PluginResources::empty()).unwrap_err();
1661 assert_eq!(
1662 err,
1663 "policy 'p': node 'rw' (type 'proxy-rewrite') has no output port 'banana'"
1664 );
1665 }
1666
1667 #[test]
1671 fn test_compile_rejects_cycle() {
1672 let mut policy = policy_missing_success_edge();
1673 policy.nodes.push(NodeConfig {
1674 id: "rw2".to_string(),
1675 node_type: "proxy-rewrite".to_string(),
1676 config: HashMap::new(),
1677 config_ref: None,
1678 position: None,
1679 });
1680 policy.edges.push(EdgeConfig {
1681 from: "rw.success".to_string(),
1682 to: "rw2.in".to_string(),
1683 });
1684 policy.edges.push(EdgeConfig {
1685 from: "rw2.success".to_string(),
1686 to: "rw.in".to_string(),
1687 });
1688 let err = compile_policy(&policy, PluginResources::empty()).unwrap_err();
1689 assert_eq!(
1690 err,
1691 "policy 'p': policy graph contains a cycle through node 'rw' — policies must be acyclic"
1692 );
1693 }
1694
1695 #[test]
1698 fn test_compile_rejects_cycle_through_error_edge() {
1699 let mut policy = policy_missing_success_edge();
1700 policy.nodes.push(NodeConfig {
1701 id: "rw2".to_string(),
1702 node_type: "proxy-rewrite".to_string(),
1703 config: HashMap::new(),
1704 config_ref: None,
1705 position: None,
1706 });
1707 policy.edges.push(EdgeConfig {
1708 from: "rw.success".to_string(),
1709 to: "client.in".to_string(),
1710 });
1711 policy.edges.push(EdgeConfig {
1712 from: "rw.error".to_string(),
1713 to: "rw2.in".to_string(),
1714 });
1715 policy.edges.push(EdgeConfig {
1716 from: "rw2.success".to_string(),
1717 to: "rw.in".to_string(),
1718 });
1719 let err = compile_policy(&policy, PluginResources::empty()).unwrap_err();
1720 assert_eq!(
1721 err,
1722 "policy 'p': policy graph contains a cycle through node 'rw' — policies must be acyclic"
1723 );
1724 }
1725
1726 #[test]
1729 fn test_compile_allows_fan_in() {
1730 let mut policy = policy_missing_success_edge();
1731 policy.nodes.push(NodeConfig {
1732 id: "rw2".to_string(),
1733 node_type: "proxy-rewrite".to_string(),
1734 config: HashMap::new(),
1735 config_ref: None,
1736 position: None,
1737 });
1738 policy.edges.push(EdgeConfig {
1739 from: "rw.success".to_string(),
1740 to: "rw2.in".to_string(),
1741 });
1742 policy.edges.push(EdgeConfig {
1743 from: "rw.error".to_string(),
1744 to: "client.in".to_string(),
1745 });
1746 policy.edges.push(EdgeConfig {
1747 from: "rw2.success".to_string(),
1748 to: "client.in".to_string(),
1749 });
1750 assert!(compile_policy(&policy, PluginResources::empty()).is_ok());
1751 }
1752
1753 #[test]
1755 fn test_error_port_wiring_is_optional() {
1756 let mut policy = policy_missing_success_edge();
1757 policy.edges.push(EdgeConfig {
1758 from: "rw.success".to_string(),
1759 to: "client.in".to_string(),
1760 });
1761 assert!(compile_policy(&policy, PluginResources::empty()).is_ok());
1762 }
1763
1764 fn compile_test_policy(json: serde_json::Value) -> CompiledGraph {
1770 let mut value = json;
1771 if let serde_json::Value::Object(ref mut map) = value {
1772 map.entry("name")
1773 .or_insert_with(|| serde_json::Value::String("test".to_string()));
1774 }
1775 let policy: PolicyConfig =
1776 serde_json::from_value(value).expect("test policy JSON must deserialize");
1777 compile_policy(&policy, PluginResources::empty()).expect("test policy must compile")
1778 }
1779
1780 fn compile_test_policy_err(json: serde_json::Value) -> String {
1783 let mut value = json;
1784 if let serde_json::Value::Object(ref mut map) = value {
1785 map.entry("name")
1786 .or_insert_with(|| serde_json::Value::String("test".to_string()));
1787 }
1788 let policy: PolicyConfig =
1789 serde_json::from_value(value).expect("test policy JSON must deserialize");
1790 compile_policy(&policy, PluginResources::empty()).expect_err("this policy must not compile")
1791 }
1792
1793 #[test]
1796 fn test_upstream_is_stream_capable_with_header_only_tail() {
1797 let graph = compile_test_policy(serde_json::json!({
1798 "nodes": [
1799 { "id": "listener", "type": "listener", "config": {} },
1800 { "id": "up", "type": "upstream",
1801 "config": { "targets": [{ "host": "h", "port": 80 }] } },
1802 { "id": "hdr", "type": "response-rewrite",
1803 "config": { "headers": { "set": { "x-a": "b" } } } },
1804 { "id": "client", "type": "client", "config": {} }
1805 ],
1806 "edges": [
1807 { "from": "listener.out", "to": "up.in" },
1808 { "from": "up.success", "to": "hdr.in" },
1809 { "from": "hdr.success", "to": "client.in" }
1810 ]
1811 }));
1812
1813 assert!(graph.is_stream_capable("up"));
1814 assert!(graph.buffering_reasons().is_empty());
1815 }
1816
1817 #[test]
1821 fn test_filters_force_buffering_and_are_reported() {
1822 let graph = compile_test_policy(serde_json::json!({
1823 "nodes": [
1824 { "id": "listener", "type": "listener", "config": {} },
1825 { "id": "up", "type": "upstream",
1826 "config": { "targets": [{ "host": "h", "port": 80 }] } },
1827 { "id": "rw", "type": "response-rewrite",
1828 "config": { "filters": [{ "regex": "a", "replace": "b" }] } },
1829 { "id": "client", "type": "client", "config": {} }
1830 ],
1831 "edges": [
1832 { "from": "listener.out", "to": "up.in" },
1833 { "from": "up.success", "to": "rw.in" },
1834 { "from": "rw.success", "to": "client.in" }
1835 ]
1836 }));
1837
1838 assert!(!graph.is_stream_capable("up"));
1839 let reasons = graph.buffering_reasons();
1840 assert_eq!(reasons.len(), 1);
1841 assert_eq!(reasons[0].upstream_node_id, "up");
1842 assert_eq!(reasons[0].blocked_by_node_id, "rw");
1843 }
1844
1845 #[test]
1848 fn test_error_path_does_not_force_buffering() {
1849 let graph = compile_test_policy(serde_json::json!({
1850 "nodes": [
1851 { "id": "listener", "type": "listener", "config": {} },
1852 { "id": "up", "type": "upstream",
1853 "config": { "targets": [{ "host": "h", "port": 80 }] } },
1854 { "id": "errs", "type": "error-handler",
1855 "config": { "status_code": 502, "body_template": "{}" } },
1856 { "id": "client", "type": "client", "config": {} }
1857 ],
1858 "edges": [
1859 { "from": "listener.out", "to": "up.in" },
1860 { "from": "up.success", "to": "client.in" },
1861 { "from": "up.error", "to": "errs.in" },
1862 { "from": "errs.success", "to": "client.in" }
1863 ]
1864 }));
1865
1866 assert!(
1867 graph.is_stream_capable("up"),
1868 "an error-handler on the error path must not block streaming"
1869 );
1870 assert!(graph.buffering_reasons().is_empty());
1871 }
1872
1873 #[test]
1880 fn test_script_node_forces_buffering() {
1881 let graph = compile_test_policy(serde_json::json!({
1882 "nodes": [
1883 { "id": "listener", "type": "listener", "config": {} },
1884 { "id": "up", "type": "upstream",
1885 "config": { "targets": [{ "host": "h", "port": 80 }] } },
1886 { "id": "s", "type": "script",
1887 "config": { "inline": "function execute(ctx) return ctx end" } },
1888 { "id": "client", "type": "client", "config": {} }
1889 ],
1890 "edges": [
1891 { "from": "listener.out", "to": "up.in" },
1892 { "from": "up.success", "to": "s.in" },
1893 { "from": "s.success", "to": "client.in" },
1894 { "from": "s.respond", "to": "client.in" }
1895 ]
1896 }));
1897
1898 assert!(!graph.is_stream_capable("up"));
1899 let reasons = graph.buffering_reasons();
1900 assert_eq!(reasons.len(), 1);
1901 assert_eq!(reasons[0].upstream_node_id, "up");
1902 assert_eq!(reasons[0].blocked_by_node_id, "s");
1903 assert_eq!(reasons[0].node_type, "script");
1904 }
1905
1906 #[test]
1911 fn test_multi_hop_header_only_chain_is_stream_capable() {
1912 let graph = compile_test_policy(serde_json::json!({
1913 "nodes": [
1914 { "id": "listener", "type": "listener", "config": {} },
1915 { "id": "up", "type": "upstream",
1916 "config": { "targets": [{ "host": "h", "port": 80 }] } },
1917 { "id": "hdr1", "type": "response-rewrite",
1918 "config": { "headers": { "set": { "x-a": "1" } } } },
1919 { "id": "hdr2", "type": "response-rewrite",
1920 "config": { "headers": { "set": { "x-b": "2" } } } },
1921 { "id": "hdr3", "type": "response-rewrite",
1922 "config": { "headers": { "set": { "x-c": "3" } } } },
1923 { "id": "client", "type": "client", "config": {} }
1924 ],
1925 "edges": [
1926 { "from": "listener.out", "to": "up.in" },
1927 { "from": "up.success", "to": "hdr1.in" },
1928 { "from": "hdr1.success", "to": "hdr2.in" },
1929 { "from": "hdr2.success", "to": "hdr3.in" },
1930 { "from": "hdr3.success", "to": "client.in" }
1931 ]
1932 }));
1933
1934 assert!(graph.is_stream_capable("up"));
1935 assert!(graph.buffering_reasons().is_empty());
1936 }
1937
1938 #[test]
1943 fn test_multi_hop_chain_blocks_on_body_reader_at_end() {
1944 let graph = compile_test_policy(serde_json::json!({
1945 "nodes": [
1946 { "id": "listener", "type": "listener", "config": {} },
1947 { "id": "up", "type": "upstream",
1948 "config": { "targets": [{ "host": "h", "port": 80 }] } },
1949 { "id": "hdr1", "type": "response-rewrite",
1950 "config": { "headers": { "set": { "x-a": "1" } } } },
1951 { "id": "hdr2", "type": "response-rewrite",
1952 "config": { "headers": { "set": { "x-b": "2" } } } },
1953 { "id": "hdr3", "type": "response-rewrite",
1954 "config": { "filters": [{ "regex": "a", "replace": "b" }] } },
1955 { "id": "client", "type": "client", "config": {} }
1956 ],
1957 "edges": [
1958 { "from": "listener.out", "to": "up.in" },
1959 { "from": "up.success", "to": "hdr1.in" },
1960 { "from": "hdr1.success", "to": "hdr2.in" },
1961 { "from": "hdr2.success", "to": "hdr3.in" },
1962 { "from": "hdr3.success", "to": "client.in" }
1963 ]
1964 }));
1965
1966 assert!(!graph.is_stream_capable("up"));
1967 let reasons = graph.buffering_reasons();
1968 assert_eq!(reasons.len(), 1);
1969 assert_eq!(reasons[0].upstream_node_id, "up");
1970 assert_eq!(reasons[0].blocked_by_node_id, "hdr3");
1971 assert_eq!(reasons[0].node_type, "response-rewrite");
1972 }
1973
1974 #[test]
1985 fn test_proxy_cache_purge_node_forces_buffering_because_it_can_fail() {
1986 let graph = compile_test_policy(serde_json::json!({
1987 "nodes": [
1988 { "id": "listener", "type": "listener", "config": {} },
1989 { "id": "up", "type": "upstream",
1990 "config": { "targets": [{ "host": "h", "port": 80 }] } },
1991 { "id": "purge", "type": "proxy-cache",
1992 "config": { "phase": "purge", "id": "products", "policy": "local" } },
1993 { "id": "client", "type": "client", "config": {} }
1994 ],
1995 "edges": [
1996 { "from": "listener.out", "to": "up.in" },
1997 { "from": "up.success", "to": "purge.in" },
1998 { "from": "purge.success", "to": "client.in" },
1999 { "from": "purge.hit", "to": "client.in" }
2000 ]
2001 }));
2002
2003 assert!(!graph.is_stream_capable("up"));
2004 let reasons = graph.buffering_reasons();
2005 assert_eq!(reasons.len(), 1);
2006 assert_eq!(reasons[0].upstream_node_id, "up");
2007 assert_eq!(reasons[0].blocked_by_node_id, "purge");
2008 assert_eq!(reasons[0].node_type, "proxy-cache");
2009 }
2010
2011 #[test]
2018 fn test_proxy_cache_lookup_node_after_upstream_does_not_force_buffering() {
2019 let graph = compile_test_policy(serde_json::json!({
2020 "nodes": [
2021 { "id": "listener", "type": "listener", "config": {} },
2022 { "id": "up", "type": "upstream",
2023 "config": { "targets": [{ "host": "h", "port": 80 }] } },
2024 { "id": "look", "type": "proxy-cache",
2025 "config": { "phase": "lookup", "id": "products", "policy": "local" } },
2026 { "id": "client", "type": "client", "config": {} }
2027 ],
2028 "edges": [
2029 { "from": "listener.out", "to": "up.in" },
2030 { "from": "up.success", "to": "look.in" },
2031 { "from": "look.success", "to": "client.in" },
2032 { "from": "look.hit", "to": "client.in" }
2033 ]
2034 }));
2035
2036 assert!(graph.is_stream_capable("up"));
2037 assert!(graph.buffering_reasons().is_empty());
2038 }
2039
2040 #[test]
2045 fn test_two_upstreams_judged_independently() {
2046 let graph = compile_test_policy(serde_json::json!({
2047 "nodes": [
2048 { "id": "listener", "type": "listener", "config": {} },
2049 { "id": "cond", "type": "condition",
2050 "config": { "conditions": [["uri", "==", "/x"]] } },
2051 { "id": "up1", "type": "upstream",
2052 "config": { "targets": [{ "host": "h1", "port": 80 }] } },
2053 { "id": "up2", "type": "upstream",
2054 "config": { "targets": [{ "host": "h2", "port": 80 }] } },
2055 { "id": "s", "type": "script",
2056 "config": { "inline": "function execute(ctx) return ctx end" } },
2057 { "id": "client", "type": "client", "config": {} }
2058 ],
2059 "edges": [
2060 { "from": "listener.out", "to": "cond.in" },
2061 { "from": "cond.true", "to": "up1.in" },
2062 { "from": "cond.false", "to": "up2.in" },
2063 { "from": "up1.success", "to": "client.in" },
2064 { "from": "up2.success", "to": "s.in" },
2065 { "from": "s.success", "to": "client.in" },
2066 { "from": "s.respond", "to": "client.in" }
2067 ]
2068 }));
2069
2070 assert!(graph.is_stream_capable("up1"));
2071 assert!(!graph.is_stream_capable("up2"));
2072 let reasons = graph.buffering_reasons();
2073 assert_eq!(reasons.len(), 1);
2074 assert_eq!(reasons[0].upstream_node_id, "up2");
2075 assert_eq!(reasons[0].blocked_by_node_id, "s");
2076 }
2077
2078 struct StubPlugin {
2083 reads_body: bool,
2084 }
2085
2086 #[async_trait::async_trait]
2087 impl Plugin for StubPlugin {
2088 fn plugin_type(&self) -> &str {
2089 "stub"
2090 }
2091 fn reads_response_body(&self) -> bool {
2092 self.reads_body
2093 }
2094 async fn execute(&self, ctx: Context) -> crate::plugins::PluginResult {
2095 Ok(plugins::PluginOutput::success(ctx))
2096 }
2097 }
2098
2099 #[test]
2110 fn test_diamond_blocks_when_one_branch_reads_body() {
2111 let mut nodes: HashMap<String, Box<dyn Plugin>> = HashMap::new();
2112 nodes.insert("a".to_string(), Box::new(StubPlugin { reads_body: false }));
2113 nodes.insert("b".to_string(), Box::new(StubPlugin { reads_body: false }));
2114 nodes.insert("c".to_string(), Box::new(StubPlugin { reads_body: true }));
2115 nodes.insert(
2116 "client".to_string(),
2117 plugins::create_plugin("client", &HashMap::new(), &PluginResources::empty()).unwrap(),
2118 );
2119
2120 let mut edges: HashMap<String, HashMap<String, String>> = HashMap::new();
2121 edges.insert(
2122 "up".to_string(),
2123 HashMap::from([("success".to_string(), "a".to_string())]),
2124 );
2125 edges.insert(
2126 "a".to_string(),
2127 HashMap::from([
2128 ("true".to_string(), "b".to_string()),
2129 ("false".to_string(), "c".to_string()),
2130 ]),
2131 );
2132 edges.insert(
2133 "b".to_string(),
2134 HashMap::from([("success".to_string(), "client".to_string())]),
2135 );
2136 edges.insert(
2137 "c".to_string(),
2138 HashMap::from([("success".to_string(), "client".to_string())]),
2139 );
2140
2141 let policy_nodes = vec![NodeConfig {
2142 id: "up".to_string(),
2143 node_type: "upstream".to_string(),
2144 config: HashMap::new(),
2145 config_ref: None,
2146 position: None,
2147 }];
2148
2149 let (stream_capable, reasons) = infer_stream_capability(&policy_nodes, &nodes, &edges);
2150
2151 assert!(!stream_capable.contains("up"));
2152 assert_eq!(reasons.len(), 1);
2153 assert_eq!(reasons[0].upstream_node_id, "up");
2154 assert_eq!(reasons[0].blocked_by_node_id, "c");
2155 assert_eq!(reasons[0].node_type, "stub");
2156 }
2157
2158 #[tokio::test]
2171 async fn test_failover_to_non_capable_upstream_does_not_leak_may_stream() {
2172 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2173
2174 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2179 let port = listener.local_addr().unwrap().port();
2180 tokio::spawn(async move {
2181 if let Ok((mut stream, _)) = listener.accept().await {
2182 let mut buf = [0u8; 4096];
2183 let _ = stream.read(&mut buf).await;
2184 let _ = stream
2185 .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 3\r\n\r\naaa")
2186 .await;
2187 let _ = stream.shutdown().await;
2188 }
2189 });
2190
2191 let policy = PolicyConfig {
2192 name: "test".to_string(),
2193 error_handler: None,
2194 nodes: vec![
2195 NodeConfig {
2196 id: "listener".to_string(),
2197 node_type: "listener".to_string(),
2198 config: HashMap::new(),
2199 config_ref: None,
2200 position: None,
2201 },
2202 NodeConfig {
2203 id: "up1".to_string(),
2204 node_type: "upstream".to_string(),
2205 config: {
2206 let mut c = HashMap::new();
2207 c.insert(
2209 "targets".to_string(),
2210 serde_json::json!([{ "host": "127.0.0.1", "port": 1 }]),
2211 );
2212 c.insert("timeout_ms".to_string(), serde_json::json!(500));
2213 c
2214 },
2215 config_ref: None,
2216 position: None,
2217 },
2218 NodeConfig {
2219 id: "up2".to_string(),
2220 node_type: "upstream".to_string(),
2221 config: {
2222 let mut c = HashMap::new();
2223 c.insert(
2224 "targets".to_string(),
2225 serde_json::json!([{ "host": "127.0.0.1", "port": port }]),
2226 );
2227 c
2228 },
2229 config_ref: None,
2230 position: None,
2231 },
2232 NodeConfig {
2233 id: "rw".to_string(),
2234 node_type: "response-rewrite".to_string(),
2235 config: {
2236 let mut c = HashMap::new();
2237 c.insert(
2238 "filters".to_string(),
2239 serde_json::json!([{ "regex": "a", "replace": "b" }]),
2240 );
2241 c
2242 },
2243 config_ref: None,
2244 position: None,
2245 },
2246 NodeConfig {
2247 id: "client".to_string(),
2248 node_type: "client".to_string(),
2249 config: HashMap::new(),
2250 config_ref: None,
2251 position: None,
2252 },
2253 ],
2254 edges: vec![
2255 EdgeConfig {
2256 from: "listener.out".to_string(),
2257 to: "up1.in".to_string(),
2258 },
2259 EdgeConfig {
2260 from: "up1.success".to_string(),
2261 to: "client.in".to_string(),
2262 },
2263 EdgeConfig {
2264 from: "up1.error".to_string(),
2265 to: "up2.in".to_string(),
2266 },
2267 EdgeConfig {
2268 from: "up2.success".to_string(),
2269 to: "rw.in".to_string(),
2270 },
2271 EdgeConfig {
2272 from: "rw.success".to_string(),
2273 to: "client.in".to_string(),
2274 },
2275 ],
2276 };
2277
2278 let graph = compile_policy(&policy, PluginResources::empty()).unwrap();
2279 assert!(
2283 graph.is_stream_capable("up1"),
2284 "up1's direct success path to client is header-only"
2285 );
2286 assert!(
2287 !graph.is_stream_capable("up2"),
2288 "up2's success path is blocked by rw's body filter"
2289 );
2290
2291 let result = graph.execute(test_context("/test")).await;
2292
2293 assert!(
2294 result.response.stream.is_none(),
2295 "up2 must have buffered: a stale __may_stream=true left by up1's \
2296 failed attempt must not leak across the error-port hop"
2297 );
2298 assert_eq!(
2299 result.response.body.as_ref(),
2300 b"baa",
2301 "rw's filter must have actually run on a real buffered body, \
2302 proving up2 did not silently bypass it by streaming"
2303 );
2304 }
2305
2306 #[tokio::test]
2313 async fn test_logger_with_a_body_free_format_is_stream_capable() {
2314 let graph = compile_test_policy(serde_json::json!({
2315 "nodes": [
2316 { "id": "listener", "type": "listener", "config": {} },
2317 { "id": "up", "type": "upstream",
2318 "config": { "targets": [{ "host": "h", "port": 80 }] } },
2319 { "id": "log", "type": "http-logger",
2320 "config": { "uri": "http://localhost:9/log",
2321 "log_format": { "path": "{{request.path}}",
2322 "status": "{{response.status}}" } } },
2323 { "id": "client", "type": "client", "config": {} }
2324 ],
2325 "edges": [
2326 { "from": "listener.out", "to": "up.in" },
2327 { "from": "up.success", "to": "log.in" },
2328 { "from": "log.success", "to": "client.in" }
2329 ]
2330 }));
2331
2332 assert!(graph.is_stream_capable("up"));
2333 assert!(graph.buffering_reasons().is_empty());
2334 }
2335
2336 #[tokio::test]
2342 async fn test_logger_reading_the_body_still_forces_buffering_and_is_reported() {
2343 let graph = compile_test_policy(serde_json::json!({
2344 "nodes": [
2345 { "id": "listener", "type": "listener", "config": {} },
2346 { "id": "up", "type": "upstream",
2347 "config": { "targets": [{ "host": "h", "port": 80 }] } },
2348 { "id": "log", "type": "http-logger",
2349 "config": { "uri": "http://localhost:9/log",
2350 "log_format": { "body": "{{response.body}}" } } },
2351 { "id": "client", "type": "client", "config": {} }
2352 ],
2353 "edges": [
2354 { "from": "listener.out", "to": "up.in" },
2355 { "from": "up.success", "to": "log.in" },
2356 { "from": "log.success", "to": "client.in" }
2357 ]
2358 }));
2359
2360 assert!(!graph.is_stream_capable("up"));
2361 let reasons = graph.buffering_reasons();
2362 assert_eq!(reasons.len(), 1);
2363 assert_eq!(reasons[0].blocked_by_node_id, "log");
2364 }
2365
2366 #[tokio::test]
2372 async fn test_logger_without_a_format_still_forces_buffering() {
2373 let graph = compile_test_policy(serde_json::json!({
2374 "nodes": [
2375 { "id": "listener", "type": "listener", "config": {} },
2376 { "id": "up", "type": "upstream",
2377 "config": { "targets": [{ "host": "h", "port": 80 }] } },
2378 { "id": "log", "type": "http-logger",
2379 "config": { "uri": "http://localhost:9/log" } },
2380 { "id": "client", "type": "client", "config": {} }
2381 ],
2382 "edges": [
2383 { "from": "listener.out", "to": "up.in" },
2384 { "from": "up.success", "to": "log.in" },
2385 { "from": "log.success", "to": "client.in" }
2386 ]
2387 }));
2388
2389 assert!(!graph.is_stream_capable("up"));
2390 }
2391
2392 #[tokio::test]
2398 async fn test_a_cache_pair_split_across_backends_is_rejected() {
2399 let err = compile_test_policy_err(serde_json::json!({
2400 "nodes": [
2401 { "id": "listener", "type": "listener", "config": {} },
2402 { "id": "look", "type": "proxy-cache",
2403 "config": { "phase": "lookup", "id": "products", "policy": "local" } },
2404 { "id": "up", "type": "upstream",
2405 "config": { "targets": [{ "host": "h", "port": 80 }] } },
2406 { "id": "keep", "type": "proxy-cache",
2407 "config": { "phase": "store", "id": "products", "policy": "redis", "store": "s" } },
2408 { "id": "client", "type": "client", "config": {} }
2409 ],
2410 "edges": [
2411 { "from": "listener.out", "to": "look.in" },
2412 { "from": "look.success", "to": "up.in" },
2413 { "from": "look.hit", "to": "client.in" },
2414 { "from": "up.success", "to": "keep.in" },
2415 { "from": "keep.success", "to": "client.in" }
2416 ]
2417 }));
2418
2419 assert!(
2420 err.contains("products"),
2421 "the error must name the pair: {err}"
2422 );
2423 assert!(
2424 err.contains("look") && err.contains("keep"),
2425 "and both halves: {err}"
2426 );
2427 }
2428
2429 #[tokio::test]
2432 async fn test_a_cache_pair_split_across_stores_is_rejected() {
2433 let err = compile_test_policy_err(serde_json::json!({
2434 "nodes": [
2435 { "id": "listener", "type": "listener", "config": {} },
2436 { "id": "look", "type": "proxy-cache",
2437 "config": { "phase": "lookup", "id": "products", "policy": "redis", "store": "a" } },
2438 { "id": "up", "type": "upstream",
2439 "config": { "targets": [{ "host": "h", "port": 80 }] } },
2440 { "id": "keep", "type": "proxy-cache",
2441 "config": { "phase": "store", "id": "products", "policy": "redis", "store": "b" } },
2442 { "id": "client", "type": "client", "config": {} }
2443 ],
2444 "edges": [
2445 { "from": "listener.out", "to": "look.in" },
2446 { "from": "look.success", "to": "up.in" },
2447 { "from": "look.hit", "to": "client.in" },
2448 { "from": "up.success", "to": "keep.in" },
2449 { "from": "keep.success", "to": "client.in" }
2450 ]
2451 }));
2452
2453 assert!(
2454 err.contains("store"),
2455 "the error must say which key disagrees: {err}"
2456 );
2457 }
2458
2459 #[tokio::test]
2463 async fn test_two_independent_cache_pairs_do_not_collide() {
2464 let graph = compile_test_policy(serde_json::json!({
2465 "nodes": [
2466 { "id": "listener", "type": "listener", "config": {} },
2467 { "id": "look-a", "type": "proxy-cache",
2468 "config": { "phase": "lookup", "id": "alpha", "policy": "local" } },
2469 { "id": "look-b", "type": "proxy-cache",
2470 "config": { "phase": "lookup", "id": "beta", "policy": "local" } },
2471 { "id": "up", "type": "upstream",
2472 "config": { "targets": [{ "host": "h", "port": 80 }] } },
2473 { "id": "keep-a", "type": "proxy-cache",
2474 "config": { "phase": "store", "id": "alpha", "policy": "local" } },
2475 { "id": "keep-b", "type": "proxy-cache",
2476 "config": { "phase": "store", "id": "beta", "policy": "local" } },
2477 { "id": "client", "type": "client", "config": {} }
2478 ],
2479 "edges": [
2480 { "from": "listener.out", "to": "look-a.in" },
2481 { "from": "look-a.success", "to": "look-b.in" },
2482 { "from": "look-a.hit", "to": "client.in" },
2483 { "from": "look-b.success", "to": "up.in" },
2484 { "from": "look-b.hit", "to": "client.in" },
2485 { "from": "up.success", "to": "keep-a.in" },
2486 { "from": "keep-a.success", "to": "keep-b.in" },
2487 { "from": "keep-b.success", "to": "client.in" },
2488 { "from": "keep-a.hit", "to": "client.in" },
2491 { "from": "keep-b.hit", "to": "client.in" }
2492 ]
2493 }));
2494
2495 assert!(
2496 graph.cache_pair_warnings().is_empty(),
2497 "both pairs are complete and agree"
2498 );
2499 }
2500
2501 #[tokio::test]
2506 async fn test_a_lone_cache_half_is_reported_not_rejected() {
2507 let graph = compile_test_policy(serde_json::json!({
2508 "nodes": [
2509 { "id": "listener", "type": "listener", "config": {} },
2510 { "id": "look", "type": "proxy-cache",
2511 "config": { "phase": "lookup", "id": "orphan", "policy": "local" } },
2512 { "id": "up", "type": "upstream",
2513 "config": { "targets": [{ "host": "h", "port": 80 }] } },
2514 { "id": "client", "type": "client", "config": {} }
2515 ],
2516 "edges": [
2517 { "from": "listener.out", "to": "look.in" },
2518 { "from": "look.success", "to": "up.in" },
2519 { "from": "look.hit", "to": "client.in" },
2520 { "from": "up.success", "to": "client.in" }
2521 ]
2522 }));
2523
2524 let warnings = graph.cache_pair_warnings();
2525 assert_eq!(warnings.len(), 1, "one incomplete pair: {warnings:?}");
2526 assert_eq!(warnings[0].cache_id, "orphan");
2527 assert_eq!(warnings[0].present_node_id, "look");
2528 assert_eq!(warnings[0].missing_role, "store");
2529 }
2530
2531 #[tokio::test]
2535 async fn test_a_script_node_must_wire_respond() {
2536 let err = compile_test_policy_err(serde_json::json!({
2537 "nodes": [
2538 { "id": "listener", "type": "listener", "config": {} },
2539 { "id": "s", "type": "script",
2540 "config": { "runtime": "lua", "inline": "function execute(ctx) return ctx end" } },
2541 { "id": "client", "type": "client", "config": {} }
2542 ],
2543 "edges": [
2544 { "from": "listener.out", "to": "s.in" },
2545 { "from": "s.success", "to": "client.in" }
2546 ]
2547 }));
2548 assert!(err.contains("respond") && err.contains("'s'"), "{err}");
2549 }
2550
2551 #[tokio::test]
2556 async fn test_a_script_taking_respond_short_circuits_the_upstream() {
2557 let graph = compile_test_policy(serde_json::json!({
2558 "nodes": [
2559 { "id": "listener", "type": "listener", "config": {} },
2560 { "id": "block", "type": "script", "config": { "runtime": "lua", "inline":
2561 "function execute(ctx)\n local ua = (ctx.request.headers[\"user-agent\"] or {})[1] or \"\"\n if string.find(string.lower(ua), \"scrapy\") then\n ctx.response.status_code = 403\n ctx.response.body = \"blocked\"\n return ctx, \"respond\"\n end\n return ctx\nend" } },
2562 { "id": "up", "type": "mocking", "config": { "response_status": 200, "response_example": "proxied" } },
2563 { "id": "client", "type": "client", "config": {} }
2564 ],
2565 "edges": [
2566 { "from": "listener.out", "to": "block.in" },
2567 { "from": "block.respond", "to": "client.in" },
2568 { "from": "block.success", "to": "up.in" },
2569 { "from": "up.success", "to": "client.in" }
2570 ]
2571 }));
2572
2573 let mut bot = test_context("/x");
2574 bot.request
2575 .headers
2576 .insert("user-agent".to_string(), vec!["scrapy/2.0".to_string()]);
2577 let out = graph.execute(bot).await;
2578 assert_eq!(out.response.status_code, 403);
2579 assert_eq!(out.response.body, bytes::Bytes::from_static(b"blocked"));
2580
2581 let mut browser = test_context("/x");
2582 browser
2583 .request
2584 .headers
2585 .insert("user-agent".to_string(), vec!["Mozilla/5.0".to_string()]);
2586 let out = graph.execute(browser).await;
2587 assert_eq!(out.response.status_code, 200);
2588 assert_eq!(out.response.body, bytes::Bytes::from_static(b"proxied"));
2589 }
2590
2591 #[tokio::test]
2594 async fn test_a_purge_half_split_from_its_pair_is_rejected() {
2595 let err = compile_test_policy_err(serde_json::json!({
2596 "nodes": [
2597 { "id": "listener", "type": "listener", "config": {} },
2598 { "id": "look", "type": "proxy-cache",
2599 "config": { "phase": "lookup", "id": "products", "policy": "local" } },
2600 { "id": "up", "type": "upstream",
2601 "config": { "targets": [{ "host": "h", "port": 80 }] } },
2602 { "id": "drop", "type": "proxy-cache",
2603 "config": { "phase": "purge", "id": "products", "policy": "redis", "store": "s" } },
2604 { "id": "client", "type": "client", "config": {} }
2605 ],
2606 "edges": [
2607 { "from": "listener.out", "to": "look.in" },
2608 { "from": "look.success", "to": "up.in" },
2609 { "from": "look.hit", "to": "client.in" },
2610 { "from": "up.success", "to": "drop.in" },
2611 { "from": "drop.success", "to": "client.in" },
2612 { "from": "drop.hit", "to": "client.in" }
2613 ]
2614 }));
2615 assert!(err.contains("products") && err.contains("drop"), "{err}");
2616 }
2617
2618 #[tokio::test]
2621 async fn test_a_lone_purge_half_is_reported() {
2622 let graph = compile_test_policy(serde_json::json!({
2623 "nodes": [
2624 { "id": "listener", "type": "listener", "config": {} },
2625 { "id": "up", "type": "upstream",
2626 "config": { "targets": [{ "host": "h", "port": 80 }] } },
2627 { "id": "drop", "type": "proxy-cache",
2628 "config": { "phase": "purge", "id": "orphan", "policy": "local" } },
2629 { "id": "client", "type": "client", "config": {} }
2630 ],
2631 "edges": [
2632 { "from": "listener.out", "to": "up.in" },
2633 { "from": "up.success", "to": "drop.in" },
2634 { "from": "drop.success", "to": "client.in" },
2635 { "from": "drop.hit", "to": "client.in" }
2636 ]
2637 }));
2638 let w = graph.cache_pair_warnings();
2639 assert_eq!(w.len(), 1);
2640 assert_eq!(w[0].present_node_id, "drop");
2641 }
2642}