Skip to main content

featherbit/debug/
sandbox.rs

1//! The plugin sandbox: run plugins against a synthetic [`Context`].
2//!
3//! Two modes, both producing the same [`Trace`](super::Trace) shape a live
4//! request produces — so the UI viewer, the diff, and the redaction tests are
5//! written once and serve both:
6//!
7//! - **ad-hoc nodes** — a list of plugin nodes run in order, for testing one
8//!   plugin (or a handful) in isolation;
9//! - **named policy** — a configured policy replayed against a synthetic
10//!   request, for testing a whole pipeline.
11//!
12//! Ad-hoc mode does **not** get its own executor. It synthesises a
13//! [`PolicyConfig`] — prepending a `listener` and appending a `client`, which
14//! [`validate_policy`](crate::graph::validate_policy) requires — and reuses
15//! [`compile_policy`](crate::graph::compile_policy). One executor means a
16//! sandbox run can never diverge from what the gateway really does.
17
18use std::collections::HashMap;
19use std::time::{Duration, Instant};
20
21use base64::engine::general_purpose::STANDARD as BASE64;
22use base64::Engine;
23use bytes::Bytes;
24use serde::Deserialize;
25
26use crate::config::{EdgeConfig, NodeConfig, PolicyConfig};
27use crate::context::{Context, GatewayRequest, GatewayResponse, Protocol};
28use crate::debug::render::render_trace;
29use crate::debug::{new_trace_id, TraceRecorder, TraceSource};
30use crate::graph::{compile_policy, prepare_policy};
31use crate::state::SharedState;
32
33/// A header/query value given as either a bare string or a list.
34///
35/// Ergonomics: `{"headers": {"apikey": "abc"}}` should work without forcing the
36/// caller to write `["abc"]` to satisfy the multi-valued representation.
37#[derive(Debug, Clone, Deserialize)]
38#[serde(untagged)]
39pub enum StringOrList {
40    One(String),
41    Many(Vec<String>),
42}
43
44impl StringOrList {
45    fn into_vec(self) -> Vec<String> {
46        match self {
47            Self::One(s) => vec![s],
48            Self::Many(v) => v,
49        }
50    }
51}
52
53/// Optional seed for `Context.response`, so response-phase plugins
54/// (`response-rewrite`, the loggers) have something to act on.
55#[derive(Debug, Clone, Default, Deserialize)]
56#[serde(default, deny_unknown_fields)]
57pub struct ResponseInput {
58    pub status_code: Option<u16>,
59    pub headers: HashMap<String, StringOrList>,
60    pub body: Option<String>,
61}
62
63/// A synthetic context. Every field is optional: posting `{}` yields a valid
64/// `GET /` run.
65///
66/// `deny_unknown_fields` is deliberate here even though it is unusual — a
67/// typo'd `"paths"` must fail loudly rather than silently default to `/` and
68/// produce a baffling trace.
69#[derive(Debug, Clone, Default, Deserialize)]
70#[serde(default, deny_unknown_fields)]
71pub struct SandboxContextInput {
72    pub method: Option<String>,
73    pub path: Option<String>,
74    pub host: Option<String>,
75    pub scheme: Option<String>,
76    pub headers: HashMap<String, StringOrList>,
77    pub query_params: HashMap<String, StringOrList>,
78    /// Plain UTF-8 body. Mutually exclusive with `body_base64`.
79    pub body: Option<String>,
80    /// Base64 body, for binary payloads. Mutually exclusive with `body`.
81    pub body_base64: Option<String>,
82    pub remote_addr: Option<String>,
83    pub protocol: Option<Protocol>,
84    pub message: HashMap<String, serde_json::Value>,
85    pub response: Option<ResponseInput>,
86}
87
88impl SandboxContextInput {
89    /// Materialises a full [`Context`], filling in defaults.
90    pub fn into_context(self) -> Result<Context, String> {
91        if self.body.is_some() && self.body_base64.is_some() {
92            return Err("provide only one of 'body' or 'body_base64'".to_string());
93        }
94        let body = match (self.body, self.body_base64) {
95            (Some(text), _) => Bytes::from(text.into_bytes()),
96            (None, Some(b64)) => Bytes::from(
97                BASE64
98                    .decode(b64.as_bytes())
99                    .map_err(|e| format!("body_base64 is not valid base64: {e}"))?,
100            ),
101            (None, None) => Bytes::new(),
102        };
103
104        let to_map = |m: HashMap<String, StringOrList>| -> HashMap<String, Vec<String>> {
105            m.into_iter().map(|(k, v)| (k, v.into_vec())).collect()
106        };
107
108        let response = self.response.unwrap_or_default();
109        Ok(Context {
110            request: GatewayRequest {
111                method: self.method.unwrap_or_else(|| "GET".to_string()),
112                path: self.path.unwrap_or_else(|| "/".to_string()),
113                host: self.host.unwrap_or_else(|| "sandbox.local".to_string()),
114                scheme: self.scheme.unwrap_or_else(|| "http".to_string()),
115                headers: to_map(self.headers),
116                query_params: to_map(self.query_params),
117                body,
118                remote_addr: self
119                    .remote_addr
120                    .unwrap_or_else(|| "127.0.0.1:0".to_string()),
121                protocol: self.protocol.unwrap_or(Protocol::Http1),
122            },
123            response: GatewayResponse {
124                status_code: response.status_code.unwrap_or(0),
125                headers: to_map(response.headers),
126                body: response
127                    .body
128                    .map(|b| Bytes::from(b.into_bytes()))
129                    .unwrap_or_default(),
130                stream: None,
131            },
132            message: self.message,
133            errors: Vec::new(),
134        })
135    }
136}
137
138/// What to do when an ad-hoc node fails.
139#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq)]
140#[serde(rename_all = "snake_case")]
141pub enum OnError {
142    /// Leave error ports unwired, so a failing node hits the engine's
143    /// no-error-handling branch and the trace records `edge: "unhandled"`.
144    /// This makes the consequence of an unwired error port *visible* — the
145    /// single thing policy authors most often get wrong.
146    #[default]
147    Stop,
148    /// Wire every node's error port to `client`, preserving the plugin's own
149    /// status code (the wiring a real auth/redirect node needs).
150    Client,
151}
152
153/// A sandbox request: exactly one of `nodes` or `policy`.
154///
155/// `context` is taken as a raw value and normalised by [`materialize_context`],
156/// so it accepts **both** the flat hand-authored shape *and* a context copied
157/// straight out of a trace (the nested `{request, response, message, errors}`
158/// snapshot shape). Typos in the flat shape are still rejected — validation
159/// runs after normalisation.
160#[derive(Debug, Clone, Default, Deserialize)]
161#[serde(default, deny_unknown_fields)]
162pub struct SandboxRequest {
163    pub nodes: Option<Vec<NodeConfig>>,
164    pub policy: Option<String>,
165    pub on_error: OnError,
166    pub context: serde_json::Value,
167}
168
169/// Turns a sandbox `context` value — flat or the nested trace-snapshot shape —
170/// into a [`Context`].
171///
172/// A context copied from `GET /api/debug/traces/{id}` (a step's `after`, or the
173/// trace's `initial`) is nested (`request`/`response` objects, bodies as
174/// `{len, text, …}` objects) and carries display-only fields (`errors`, body
175/// `len`/`truncated`/`binary`). This flattens that into the flat input shape
176/// before deserialising, so pasting a trace context "just works".
177///
178/// Note: snapshots are **redacted** — a header shown as `<redacted>` replays
179/// literally as that string, and a truncated or binary body cannot be
180/// reconstructed. The sandbox replays what the trace could show, not the
181/// original secret bytes.
182pub fn materialize_context(raw: serde_json::Value) -> Result<Context, String> {
183    let normalized = normalize_context(raw);
184    if normalized.is_null() {
185        return SandboxContextInput::default().into_context();
186    }
187    let input: SandboxContextInput =
188        serde_json::from_value(normalized).map_err(|e| format!("invalid sandbox context: {e}"))?;
189    input.into_context()
190}
191
192/// Flattens the nested trace-snapshot shape into the flat input shape. A value
193/// that is already flat passes through essentially unchanged.
194fn normalize_context(v: serde_json::Value) -> serde_json::Value {
195    use serde_json::Value;
196    let Value::Object(mut obj) = v else { return v };
197
198    // Nested `request` object (snapshot shape) -> lift its fields to the top,
199    // converting the body object to a plain string.
200    if let Some(Value::Object(req)) = obj.remove("request") {
201        for (k, val) in req {
202            if k == "body" {
203                let is_snapshot = val.as_object().is_some_and(|o| o.contains_key("len"));
204                if let Some(text) = body_text(&val) {
205                    obj.insert("body".to_string(), Value::String(text));
206                } else if !is_snapshot {
207                    // A plain JSON body written as an object: keep it for the
208                    // coercion below. (A binary or uncaptured snapshot body has
209                    // no faithful text to replay and is dropped.)
210                    obj.insert("body".to_string(), val);
211                }
212            } else {
213                obj.insert(k, val);
214            }
215        }
216    }
217    // Response body in the snapshot shape (`{len, text, …}`) -> string, or
218    // dropped when the snapshot has no replayable text. A plain JSON object
219    // body (no `len`) is left for the coercion below.
220    if let Some(resp) = obj.get_mut("response").and_then(Value::as_object_mut) {
221        if let Some(body) = resp.get("body").cloned() {
222            let is_snapshot = body.as_object().is_some_and(|o| o.contains_key("len"));
223            match body_text(&body) {
224                Some(text) => {
225                    resp.insert("body".to_string(), Value::String(text));
226                }
227                None if is_snapshot => {
228                    resp.remove("body");
229                }
230                None => {}
231            }
232        }
233    }
234    // Display-only fields the sandbox does not model.
235    obj.remove("errors");
236
237    // Forgiving aliases and coercions for the shapes agents and humans most
238    // often produce. Strict `deny_unknown_fields` still catches real typos.
239    for (alias, canonical) in [("query", "query_params"), ("uri", "path")] {
240        if let Some(v) = obj.remove(alias) {
241            obj.entry(canonical.to_string()).or_insert(v);
242        }
243    }
244    // A JSON body given as an object/array (or a number/bool) → its text.
245    if let Some(body) = obj.get("body") {
246        if let Some(text) = scalar_or_json_text(body) {
247            obj.insert("body".to_string(), Value::String(text));
248        }
249    }
250    // Header / query values: numbers and bools become strings; lists stay lists.
251    for map_key in ["headers", "query_params"] {
252        if let Some(Value::Object(map)) = obj.get_mut(map_key) {
253            for v in map.values_mut() {
254                if matches!(v, Value::Number(_) | Value::Bool(_)) {
255                    *v = Value::String(v.to_string());
256                }
257            }
258        }
259    }
260    if let Some(resp) = obj.get_mut("response").and_then(Value::as_object_mut) {
261        if let Some(v) = resp.remove("status") {
262            resp.entry("status_code".to_string()).or_insert(v);
263        }
264        if let Some(body) = resp.get("body") {
265            if let Some(text) = scalar_or_json_text(body) {
266                resp.insert("body".to_string(), Value::String(text));
267            }
268        }
269    }
270    Value::Object(obj)
271}
272
273/// Text for a non-string scalar (`42` → `"42"`, `true` → `"true"`) or a
274/// JSON object/array (serialized compactly). `None` leaves strings, nulls
275/// and anything else untouched.
276fn scalar_or_json_text(v: &serde_json::Value) -> Option<String> {
277    use serde_json::Value;
278    match v {
279        Value::Number(_) | Value::Bool(_) => Some(v.to_string()),
280        Value::Object(_) | Value::Array(_) => Some(v.to_string()),
281        _ => None,
282    }
283}
284
285/// Extracts replayable body text: a plain string, or a snapshot body object's
286/// `text` field. `None` for a binary or uncaptured body.
287fn body_text(v: &serde_json::Value) -> Option<String> {
288    match v {
289        serde_json::Value::String(s) => Some(s.clone()),
290        serde_json::Value::Object(o) => o.get("text").and_then(|t| t.as_str()).map(String::from),
291        _ => None,
292    }
293}
294
295/// Synthesises a runnable policy from an ad-hoc node list.
296///
297/// Prepends `listener`, appends `client`, and chains the user's nodes through
298/// their success ports.
299pub fn synthesize_policy(
300    nodes: Vec<NodeConfig>,
301    on_error: OnError,
302) -> Result<PolicyConfig, String> {
303    if nodes.is_empty() {
304        return Err("'nodes' must contain at least one node".to_string());
305    }
306
307    let mut seen = std::collections::HashSet::new();
308    let mut user_nodes = Vec::with_capacity(nodes.len());
309    for (i, mut n) in nodes.into_iter().enumerate() {
310        if n.node_type == "listener" || n.node_type == "client" {
311            return Err(format!(
312                "node '{}': '{}' nodes are added automatically and cannot be supplied",
313                n.id, n.node_type
314            ));
315        }
316        if n.id.trim().is_empty() {
317            n.id = format!("{}-{}", n.node_type, i);
318        }
319        if !seen.insert(n.id.clone()) {
320            return Err(format!("duplicate node id '{}'", n.id));
321        }
322        user_nodes.push(n);
323    }
324
325    let mut edges = vec![EdgeConfig {
326        from: "listener.out".to_string(),
327        to: format!("{}.in", user_nodes[0].id),
328    }];
329    for pair in user_nodes.windows(2) {
330        edges.push(EdgeConfig {
331            from: format!("{}.success", pair[0].id),
332            to: format!("{}.in", pair[1].id),
333        });
334    }
335    edges.push(EdgeConfig {
336        from: format!("{}.success", user_nodes[user_nodes.len() - 1].id),
337        to: "client.in".to_string(),
338    });
339    if on_error == OnError::Client {
340        for n in &user_nodes {
341            edges.push(EdgeConfig {
342                from: format!("{}.error", n.id),
343                to: "client.in".to_string(),
344            });
345        }
346    }
347
348    // Any output port the chain wiring above didn't already cover — an
349    // "outcome" port on a node that isn't last in the chain — must still be
350    // wired, or compilation will reject it as an unwired mandatory port.
351    // Route it straight to the client: the sandbox has no meaningful "next
352    // node" for a short-circuit outcome like "denied".
353    let already_wired: std::collections::HashSet<String> =
354        edges.iter().map(|e| e.from.clone()).collect();
355    for n in &user_nodes {
356        let spec =
357            crate::plugins::port_spec(&n.node_type).unwrap_or(&crate::plugins::ports::DEFAULT_SPEC);
358        for p in spec.outputs {
359            if matches!(p.kind, crate::plugins::ports::PortKind::Error) {
360                continue;
361            }
362            let from = format!("{}.{}", n.id, p.name);
363            if !already_wired.contains(&from) {
364                edges.push(EdgeConfig {
365                    from,
366                    to: "client.in".to_string(),
367                });
368            }
369        }
370    }
371
372    let mut all = Vec::with_capacity(user_nodes.len() + 2);
373    all.push(NodeConfig {
374        id: "listener".to_string(),
375        node_type: "listener".to_string(),
376        config: HashMap::new(),
377        config_ref: None,
378        position: None,
379    });
380    all.extend(user_nodes);
381    all.push(NodeConfig {
382        id: "client".to_string(),
383        node_type: "client".to_string(),
384        config: HashMap::new(),
385        config_ref: None,
386        position: None,
387    });
388
389    Ok(PolicyConfig {
390        name: "__sandbox".to_string(),
391        error_handler: None,
392        nodes: all,
393        edges,
394    })
395}
396
397/// Why a sandbox run did not happen. The Admin handler maps these to HTTP;
398/// the MCP tool maps them to tool-error codes.
399#[derive(Debug)]
400pub enum SandboxError {
401    /// `debug.enabled` is false.
402    Disabled,
403    /// `debug.sandbox` is false.
404    SandboxDisabled,
405    /// Malformed request (both/neither of `nodes`/`policy`, bad node list,
406    /// invalid policy, unmaterializable context) — message is user-facing.
407    BadRequest(String),
408    /// `policy` names no stored policy.
409    UnknownPolicy(String),
410    /// The run exceeded `debug.sandbox_timeout_seconds` (the value carried).
411    Timeout(u64),
412}
413
414/// A completed sandbox run.
415pub struct SandboxRun {
416    /// `"nodes"` or `"policy"`.
417    pub mode: &'static str,
418    /// The policy name executed (`__sandbox` for ad-hoc node lists).
419    pub policy: String,
420    /// Id of the trace stored in the debug ring buffer.
421    pub stored_trace_id: String,
422    /// The rendered trace (`render_trace` shape).
423    pub trace: serde_json::Value,
424}
425
426/// Runs plugins or a stored policy against a synthetic request, for real,
427/// recording a trace. Shared by `POST /api/debug/sandbox` and the MCP
428/// `run_sandbox` tool.
429pub async fn run_sandbox(
430    state: &SharedState,
431    req: SandboxRequest,
432) -> Result<SandboxRun, SandboxError> {
433    if !state.debug.enabled {
434        return Err(SandboxError::Disabled);
435    }
436    if !state.debug.sandbox_enabled {
437        return Err(SandboxError::SandboxDisabled);
438    }
439
440    let (mode, policy_name, policy) = match (req.nodes, req.policy) {
441        (Some(_), Some(_)) | (None, None) => {
442            return Err(SandboxError::BadRequest(
443                "provide exactly one of 'nodes' or 'policy'".into(),
444            ))
445        }
446        (Some(nodes), None) => match synthesize_policy(nodes, req.on_error) {
447            Ok(p) => ("nodes", "__sandbox".to_string(), p),
448            Err(e) => return Err(SandboxError::BadRequest(e)),
449        },
450        (None, Some(name)) => {
451            let gw = state.gateway.read().await;
452            match gw.policies.iter().find(|p| p.name == name) {
453                // Recompile rather than reusing a route's graph: a policy with
454                // no route attached is exactly the one being iterated on.
455                Some(p) => ("policy", name.clone(), p.clone()),
456                None => return Err(SandboxError::UnknownPolicy(name)),
457            }
458        }
459    };
460
461    // Resolve shared plugin configs and inline supernode references the same
462    // way compile_routes does — the sandbox must never diverge from what the
463    // data plane executes. Resolution runs on a synthetic single-policy
464    // gateway so ad-hoc nodes and stored policies behave identically.
465    let (supernodes, plugin_configs) = {
466        let gw = state.gateway.read().await;
467        (gw.supernodes.clone(), gw.plugin_configs.clone())
468    };
469    let policy =
470        prepare_policy(policy, &supernodes, &plugin_configs).map_err(SandboxError::BadRequest)?;
471
472    let graph =
473        compile_policy(&policy, state.resources.clone()).map_err(SandboxError::BadRequest)?;
474
475    let ctx = materialize_context(req.context).map_err(SandboxError::BadRequest)?;
476
477    tracing::warn!(
478        "sandbox run ({}): plugins execute for real against live resources",
479        mode
480    );
481
482    let recorder = TraceRecorder::new(&ctx, state.debug.capture_options(), state.debug.max_steps);
483    let started = Instant::now();
484    let timeout = Duration::from_secs(state.debug.sandbox_timeout_seconds.max(1));
485
486    let run = graph.execute_traced(ctx, recorder);
487    let (out_ctx, recorder) = tokio::time::timeout(timeout, run)
488        .await
489        .map_err(|_| SandboxError::Timeout(state.debug.sandbox_timeout_seconds))?;
490
491    let id = new_trace_id();
492    let trace = recorder.finish(
493        id.clone(),
494        state.debug.next_seq(),
495        TraceSource::Sandbox,
496        None,
497        policy_name.clone(),
498        &out_ctx,
499        started.elapsed(),
500    );
501    let rendered = render_trace(&trace);
502    // Stored alongside live traces so a sandbox run and a real request can be
503    // compared side by side in the UI.
504    state.debug.record(trace);
505
506    Ok(SandboxRun {
507        mode,
508        policy: policy_name,
509        stored_trace_id: id,
510        trace: rendered,
511    })
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    fn node(id: &str, node_type: &str) -> NodeConfig {
519        NodeConfig {
520            id: id.to_string(),
521            node_type: node_type.to_string(),
522            config: HashMap::new(),
523            config_ref: None,
524            position: None,
525        }
526    }
527
528    fn parse(json: serde_json::Value) -> Result<SandboxRequest, serde_json::Error> {
529        serde_json::from_value(json)
530    }
531
532    /// Posting `{}` must produce a runnable context — the sandbox should not
533    /// demand a fully-specified request just to try one plugin.
534    #[test]
535    fn test_empty_input_yields_sensible_defaults() {
536        let ctx = SandboxContextInput::default().into_context().unwrap();
537        assert_eq!(ctx.request.method, "GET");
538        assert_eq!(ctx.request.path, "/");
539        assert_eq!(ctx.request.host, "sandbox.local");
540        assert_eq!(ctx.request.scheme, "http");
541        assert_eq!(ctx.request.remote_addr, "127.0.0.1:0");
542        assert_eq!(ctx.request.protocol, Protocol::Http1);
543        assert!(ctx.request.body.is_empty());
544        assert_eq!(ctx.response.status_code, 0);
545        assert!(ctx.errors.is_empty());
546    }
547
548    #[test]
549    fn test_single_string_header_coerces_to_list() {
550        let req = parse(serde_json::json!({
551            "context": { "headers": { "apikey": "abc" }, "query_params": { "q": ["a", "b"] } }
552        }))
553        .unwrap();
554        let ctx = materialize_context(req.context).unwrap();
555        assert_eq!(ctx.request.headers["apikey"], vec!["abc"]);
556        assert_eq!(ctx.request.query_params["q"], vec!["a", "b"]);
557    }
558
559    #[test]
560    fn test_body_and_body_base64_are_mutually_exclusive() {
561        let input = SandboxContextInput {
562            body: Some("a".to_string()),
563            body_base64: Some("YQ==".to_string()),
564            ..Default::default()
565        };
566        assert!(input.into_context().is_err());
567    }
568
569    #[test]
570    fn test_body_base64_decoded() {
571        let input = SandboxContextInput {
572            body_base64: Some(BASE64.encode("binary")),
573            ..Default::default()
574        };
575        let ctx = input.into_context().unwrap();
576        assert_eq!(ctx.request.body, Bytes::from_static(b"binary"));
577    }
578
579    #[test]
580    fn test_response_seed_supports_response_phase_plugins() {
581        let req = parse(serde_json::json!({
582            "context": { "response": { "status_code": 200, "body": "hi", "headers": { "x": "1" } } }
583        }))
584        .unwrap();
585        let ctx = materialize_context(req.context).unwrap();
586        assert_eq!(ctx.response.status_code, 200);
587        assert_eq!(ctx.response.body, Bytes::from_static(b"hi"));
588        assert_eq!(ctx.response.headers["x"], vec!["1"]);
589    }
590
591    /// A typo must fail loudly rather than silently defaulting — now at
592    /// materialisation, since `context` is taken raw and validated after
593    /// normalisation.
594    #[test]
595    fn test_unknown_context_field_is_rejected() {
596        let req = parse(serde_json::json!({ "context": { "paths": "/x" } })).unwrap();
597        assert!(materialize_context(req.context).is_err());
598    }
599
600    /// A context copied straight from a trace (nested request/response, body as
601    /// an object, plus `errors`) must replay without a shape error.
602    #[test]
603    fn test_accepts_trace_snapshot_shape() {
604        let snapshot = serde_json::json!({
605            "request": {
606                "method": "POST",
607                "path": "/api/items",
608                "host": "h",
609                "scheme": "http",
610                "headers": { "x-consumer": ["alice"] },
611                "query_params": { "page": ["2"] },
612                "body": { "len": 7, "text": "payload" }
613            },
614            "response": {
615                "status_code": 200,
616                "headers": { "x-powered-by": ["php"] },
617                "body": { "len": 2, "text": "ok" }
618            },
619            "message": { "user_id": "alice" },
620            "errors": [ { "node_id": "auth", "code": "X", "message": "y" } ]
621        });
622        let ctx = materialize_context(snapshot).unwrap();
623        assert_eq!(ctx.request.method, "POST");
624        assert_eq!(ctx.request.path, "/api/items");
625        assert_eq!(ctx.request.headers["x-consumer"], vec!["alice"]);
626        assert_eq!(ctx.request.query_params["page"], vec!["2"]);
627        assert_eq!(ctx.request.body, Bytes::from_static(b"payload"));
628        assert_eq!(ctx.response.status_code, 200);
629        assert_eq!(ctx.response.body, Bytes::from_static(b"ok"));
630        assert_eq!(ctx.message["user_id"], serde_json::json!("alice"));
631        // `errors` is display-only and must not leak into the replayed context.
632        assert!(ctx.errors.is_empty());
633    }
634
635    /// A snapshot whose body was binary/uncaptured (no `text`) replays with an
636    /// empty body rather than failing.
637    #[test]
638    fn test_snapshot_binary_or_uncaptured_body_becomes_empty() {
639        let snapshot = serde_json::json!({
640            "request": { "path": "/x", "body": { "len": 1024, "binary": true } }
641        });
642        let ctx = materialize_context(snapshot).unwrap();
643        assert_eq!(ctx.request.path, "/x");
644        assert!(ctx.request.body.is_empty());
645    }
646
647    #[test]
648    fn test_agent_friendly_aliases_and_coercions() {
649        // `query` and `uri` aliases; a JSON body given as an object; numeric
650        // header/query values; `response.status`.
651        let ctx = materialize_context(serde_json::json!({
652            "method": "POST",
653            "uri": "/orders",
654            "query": {"page": 2, "tags": ["a", "b"]},
655            "headers": {"x-retry": 3, "accept": ["text/plain", "application/json"]},
656            "body": {"order": {"id": 42}},
657            "response": {"status": 201, "body": {"ok": true}}
658        }))
659        .unwrap();
660        assert_eq!(ctx.request.path, "/orders");
661        assert_eq!(ctx.request.query_params["page"], vec!["2"]);
662        assert_eq!(ctx.request.query_params["tags"], vec!["a", "b"]);
663        assert_eq!(ctx.request.headers["x-retry"], vec!["3"]);
664        assert_eq!(ctx.request.headers["accept"].len(), 2);
665        assert_eq!(ctx.request.body.as_ref(), br#"{"order":{"id":42}}"#);
666        assert_eq!(ctx.response.status_code, 201);
667        assert_eq!(ctx.response.body.as_ref(), br#"{"ok":true}"#);
668    }
669
670    #[test]
671    fn test_nested_request_with_plain_object_body_is_kept() {
672        let ctx = materialize_context(serde_json::json!({
673            "request": {"path": "/x", "body": {"a": 1}}
674        }))
675        .unwrap();
676        assert_eq!(ctx.request.body.as_ref(), br#"{"a":1}"#);
677        // A real snapshot body without text is still dropped.
678        let ctx = materialize_context(serde_json::json!({
679            "request": {"path": "/x", "body": {"len": 5, "binary": true}}
680        }))
681        .unwrap();
682        assert!(ctx.request.body.is_empty());
683    }
684
685    #[test]
686    fn test_synthesized_policy_chains_nodes() {
687        let p = synthesize_policy(
688            vec![node("a", "cors"), node("b", "proxy-rewrite")],
689            OnError::Stop,
690        )
691        .unwrap();
692        let ids: Vec<&str> = p.nodes.iter().map(|n| n.id.as_str()).collect();
693        assert_eq!(ids, vec!["listener", "a", "b", "client"]);
694
695        let edges: Vec<String> = p
696            .edges
697            .iter()
698            .map(|e| format!("{}->{}", e.from, e.to))
699            .collect();
700        assert_eq!(
701            edges,
702            vec![
703                "listener.out->a.in",
704                "a.success->b.in",
705                "b.success->client.in",
706                // `a` (cors) has a mandatory outcome port not covered by the
707                // chain wiring above — auto-wired straight to `client`.
708                "a.preflight->client.in"
709            ]
710        );
711        // The policy must satisfy the same validation the editor applies.
712        assert!(crate::graph::validate_policy(&p).is_ok());
713    }
714
715    /// `on_error: "client"` wires every error port so a rejecting plugin's own
716    /// status survives instead of becoming the engine's generic 500.
717    #[test]
718    fn test_on_error_client_wires_error_edges() {
719        let p = synthesize_policy(vec![node("a", "key-auth")], OnError::Client).unwrap();
720        assert!(p
721            .edges
722            .iter()
723            .any(|e| e.from == "a.error" && e.to == "client.in"));
724
725        let stop = synthesize_policy(vec![node("a", "key-auth")], OnError::Stop).unwrap();
726        assert!(!stop.edges.iter().any(|e| e.from == "a.error"));
727    }
728
729    #[test]
730    fn test_missing_id_is_defaulted_from_type() {
731        let p = synthesize_policy(vec![node("", "cors")], OnError::Stop).unwrap();
732        assert_eq!(p.nodes[1].id, "cors-0");
733    }
734
735    #[test]
736    fn test_duplicate_ids_rejected() {
737        let err = synthesize_policy(vec![node("a", "cors"), node("a", "csrf")], OnError::Stop)
738            .unwrap_err();
739        assert!(err.contains("duplicate node id"), "got: {err}");
740    }
741
742    #[test]
743    fn test_reserved_node_types_rejected() {
744        for t in ["listener", "client"] {
745            let err = synthesize_policy(vec![node("x", t)], OnError::Stop).unwrap_err();
746            assert!(err.contains("added automatically"), "got: {err}");
747        }
748    }
749
750    #[test]
751    fn test_empty_node_list_rejected() {
752        assert!(synthesize_policy(Vec::new(), OnError::Stop).is_err());
753    }
754
755    #[test]
756    fn test_on_error_defaults_to_stop() {
757        let req = parse(serde_json::json!({ "policy": "p" })).unwrap();
758        assert_eq!(req.on_error, OnError::Stop);
759    }
760
761    #[tokio::test]
762    async fn run_sandbox_reports_disabled_and_unknown_policy() {
763        use crate::config::{GatewayConfig, SystemConfig};
764        use crate::config_store::FileConfigStore;
765        use std::sync::Arc;
766
767        let off: SystemConfig = serde_yaml::from_str("{}").unwrap();
768        let gw: GatewayConfig = serde_yaml::from_str("{}").unwrap();
769        let store = Arc::new(FileConfigStore::new(std::path::PathBuf::from(
770            "gateway.yaml",
771        )));
772        let state = crate::state::SharedState::new(off, gw.clone(), None, store.clone()).unwrap();
773        let req = SandboxRequest {
774            policy: Some("p".into()),
775            ..Default::default()
776        };
777        assert!(matches!(
778            run_sandbox(&state, req).await,
779            Err(SandboxError::Disabled)
780        ));
781
782        let on: SystemConfig = serde_yaml::from_str("debug:\n  enabled: true\n").unwrap();
783        let state = crate::state::SharedState::new(on, gw, None, store).unwrap();
784        let req = SandboxRequest {
785            policy: Some("missing".into()),
786            ..Default::default()
787        };
788        assert!(matches!(
789            run_sandbox(&state, req).await,
790            Err(SandboxError::UnknownPolicy(_))
791        ));
792        let req = SandboxRequest::default();
793        assert!(matches!(
794            run_sandbox(&state, req).await,
795            Err(SandboxError::BadRequest(_))
796        ));
797    }
798
799    #[tokio::test]
800    async fn run_sandbox_executes_nodes_mode() {
801        use crate::config::{GatewayConfig, SystemConfig};
802        use crate::config_store::FileConfigStore;
803        use std::sync::Arc;
804        let on: SystemConfig = serde_yaml::from_str("debug:\n  enabled: true\n").unwrap();
805        let gw: GatewayConfig = serde_yaml::from_str("{}").unwrap();
806        let store = Arc::new(FileConfigStore::new(std::path::PathBuf::from(
807            "gateway.yaml",
808        )));
809        let state = crate::state::SharedState::new(on, gw, None, store).unwrap();
810        // `echo` requires at least one of body/before_body/after_body — an
811        // empty config is rejected by EchoPlugin::from_config, so the
812        // smallest accepted config (`body`) is used here instead of `{}`.
813        let req: SandboxRequest = serde_json::from_value(serde_json::json!({
814            "nodes": [{"id": "e", "type": "echo", "config": {"body": "hi"}}],
815            "context": {"method": "GET", "path": "/x"}
816        }))
817        .unwrap();
818        let run = run_sandbox(&state, req).await.unwrap();
819        assert_eq!(run.mode, "nodes");
820        assert_eq!(run.policy, "__sandbox");
821        assert!(state.debug.get(&run.stored_trace_id).is_some());
822        assert!(!run.trace["steps"].as_array().unwrap().is_empty());
823    }
824}