Skip to main content

mockforge_bench/conformance/
self_test.rs

1//! Positive + per-category negative request driver against a live server.
2//!
3//! Issue #79 round 13 (4) — Srikanth's (e) ask: a way to test both
4//! positive and negative compliance scenarios separately, where the
5//! positive cases should pass and the negative cases should be
6//! rejected.
7//!
8//! This module sits *alongside* the existing conformance executor
9//! (which drives k6 / native checks on a single positive call per
10//! operation). The self-test driver synthesises per-category
11//! deliberately-bad requests and asserts that the server actually
12//! rejects them with a 4xx — useful when verifying that
13//! `validate_request_with_all` is wired correctly for the user's spec
14//! (the exact gap that round-13 (3) fixed).
15//!
16//! Scope of the initial MVP: covers the highest-signal negatives —
17//! empty body when one is required, missing required query/header
18//! params, and wrong-type path params. Doesn't try to mutate every
19//! field of a JSON-Schema-validated body; that's a follow-up.
20
21use super::spec_driven::{AnnotatedOperation, ApiKeyLocation, SecuritySchemeInfo};
22use reqwest::{Client, Method};
23use std::collections::BTreeMap;
24use std::net::IpAddr;
25use std::sync::atomic::{AtomicUsize, Ordering};
26use std::sync::{Arc, Mutex};
27use std::time::Duration;
28
29/// Round 23 (c-iii) — per-direction body cap when capturing
30/// request/response payloads to `conformance-self-test-requests.jsonl`.
31/// 16 KiB keeps a 1000-case run under ~32 MB even if every payload
32/// fills the cap, while still preserving enough of a typical JSON body
33/// (or a stack-trace error response) to debug from.
34const CAPTURE_BODY_CAP_BYTES: usize = 16 * 1024;
35
36/// Round 17.2 — cap on schema-driven negatives per operation. A spec
37/// with 100 properties per body could produce hundreds of mutations
38/// for a single operation; combined with thousands of operations
39/// that's a runaway test matrix. 12 covers the highest-signal
40/// mutations (type mismatch + required-removed + a few constraint
41/// breaks) without exploding wall time on large specs.
42const SCHEMA_MUTATION_CAP: usize = 12;
43
44/// Round 25 (k) — content-type swap probes. For operations declaring a
45/// JSON request body, each entry below produces one probe that lies
46/// about Content-Type while keeping the JSON payload. A spec-compliant
47/// server should respond 415 (or 400). Order matches the order
48/// Srikanth listed in his round-23 reply: XML, YAML, multipart, and
49/// the URL-encoded variant he added in round 24.
50const CONTENT_TYPE_SWAP_VARIANTS: &[(&str, &str)] = &[
51    ("application/xml", "request-body:content-type-mismatch:xml"),
52    ("application/yaml", "request-body:content-type-mismatch:yaml"),
53    ("multipart/form-data", "request-body:content-type-mismatch:multipart"),
54    (
55        "application/x-www-form-urlencoded",
56        "request-body:content-type-mismatch:urlencoded",
57    ),
58];
59
60/// Round 27 (k variant b) — embedded content payloads. Content-Type
61/// stays `application/json` and the envelope IS valid JSON; we just
62/// stuff a non-JSON snippet into a string field's value. The test
63/// surfaces servers that try to parse string field contents (e.g.
64/// XML-EE expanders, YAML loaders, urlencoded parsers) and crash on
65/// the payload — a 5xx here is the finding. Label, payload pairs:
66const EMBEDDED_CONTENT_VARIANTS: &[(&str, &str)] = &[
67    ("request-body:embedded-content:xml", "<root><cmd>execute()</cmd></root>"),
68    ("request-body:embedded-content:yaml", "key: value\n- item1\n- item2"),
69    (
70        "request-body:embedded-content:multipart",
71        "--boundary\r\nContent-Disposition: form-data; name=\"x\"\r\n\r\nval\r\n--boundary--",
72    ),
73    ("request-body:embedded-content:urlencoded", "a=1&b=2&c=hello%20world"),
74];
75
76/// Configuration for a self-test run.
77#[derive(Debug, Clone)]
78pub struct SelfTestConfig {
79    pub target_url: String,
80    pub skip_tls_verify: bool,
81    pub timeout: Duration,
82    /// Optional extra headers to attach to every request (e.g. auth).
83    pub extra_headers: Vec<(String, String)>,
84    /// Delay between requests to avoid hammering the server.
85    pub delay_between_requests: Duration,
86    /// Round 18.1 — base path to prepend to every spec path. When the
87    /// spec declares `/users` and the deployed API is served under
88    /// `/api`, `--base-path /api` should make the self-test hit
89    /// `https://target/api/users` instead of `https://target/users`.
90    /// Pre-fix this was ignored entirely and every operation 404'd
91    /// (Srikanth's vCenter run on 0.3.152: 1275 positives, 1275 4xx).
92    pub base_path: Option<String>,
93    /// Round 18.5 — local source IPs to bind outgoing requests to.
94    /// Each IP must already be assigned to an interface on the host.
95    /// Operations round-robin through the resulting client pool.
96    pub source_ips: Vec<IpAddr>,
97    /// Round 18.5 — fake source IPs to advertise via forwarded-IP
98    /// headers (used to exercise GEODB lookup at the destination).
99    /// Rotated per operation.
100    pub geo_source_ips: Vec<IpAddr>,
101    /// Which forwarded-IP header(s) to populate when `geo_source_ips`
102    /// is non-empty. Empty → no-op; default below sets the standard
103    /// three-header set.
104    pub geo_source_headers: Vec<String>,
105    /// Round 23 (c-iii) — when `Some`, every probe captures method, URL,
106    /// request headers/body and response status/headers/body into this
107    /// sink. Caller drains it after `run_self_test` and writes
108    /// `conformance-self-test-requests.jsonl`. None → no capture (zero
109    /// extra allocations on the hot path).
110    pub capture: Option<Arc<Mutex<Vec<CaseCapture>>>>,
111    /// Round 25 — when true, validate every probe's response body
112    /// against the spec's response schema for the actual status
113    /// returned (closes round 21.3 / Srikanth's a2 / a3 ask). The
114    /// validation result lands in `CaseCapture::response_schema_error`
115    /// (None → matched, or no schema for that status). Default false:
116    /// JSON-Schema validation of large response bodies adds wall-clock
117    /// time and the user has to opt in.
118    pub validate_response_schemas: bool,
119    /// Round 33 (#823) — human-readable label for the OpenAPI spec
120    /// this run is exercising. Stamped on every `CaseCapture` so the
121    /// per-endpoint summary can attribute rows back to a spec in
122    /// multi-spec / multi-target benches. `None` when the bench didn't
123    /// track a spec path.
124    pub spec_label: Option<String>,
125}
126
127/// Round 23 (c-iii) — one captured request/response pair, one per
128/// probe (positive or negative). Serialised as a JSON line in
129/// `conformance-self-test-requests.jsonl`. Headers are kept as
130/// `BTreeMap` for stable ordering. Bodies are truncated to
131/// `CAPTURE_BODY_CAP_BYTES`; `*_truncated` flags whether more was
132/// dropped.
133#[derive(Debug, Clone, serde::Serialize)]
134pub struct CaseCapture {
135    pub label: String,
136    pub method: String,
137    pub url: String,
138    pub request_headers: BTreeMap<String, String>,
139    pub request_body: Option<String>,
140    pub request_body_truncated: bool,
141    pub response_status: u16,
142    pub response_headers: BTreeMap<String, String>,
143    pub response_body: Option<String>,
144    pub response_body_truncated: bool,
145    pub error: Option<String>,
146    /// Round 25 — when `validate_response_schemas` is on and the spec
147    /// declares a schema for `response_status`, this carries the
148    /// validation message (or None when the body matched, or no schema
149    /// was declared for that status). Serialised verbatim in the JSONL
150    /// and rendered in the HTML viewer.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub response_schema_error: Option<String>,
153    /// Round 28 — Srikanth's "Is it possible to put expected response
154    /// code status in both jsonl and jsonl report" ask. Human-readable
155    /// expected status range: `"2xx-3xx"` for positive probes,
156    /// `"4xx"` for negatives. Lets users `jq` for misses
157    /// (`.response_status as $s | .expected_status_range == "4xx"
158    /// and ($s < 400 or $s >= 500)`) and powers the HTML viewer's
159    /// "show mismatches only" filter.
160    #[serde(default)]
161    pub expected_status_range: String,
162    /// Round 33 (#823) — the spec's path template (e.g.
163    /// `/users/{id}`) before path-param substitution. Lets the
164    /// per-endpoint summary collapse `/users/X` and `/users/Y` into
165    /// one row. Empty string when the call site predates this field
166    /// (older `CaseCapture` payloads on disk also deserialise OK).
167    #[serde(default)]
168    pub path_template: String,
169    /// Round 33 (#823) — basename (or fallback to full path) of the
170    /// OpenAPI spec file this probe came from. Lets multi-spec runs
171    /// attribute rows back to the spec they came from. `None` when
172    /// the bench didn't track a spec path.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub spec_label: Option<String>,
175}
176
177impl Default for SelfTestConfig {
178    fn default() -> Self {
179        Self {
180            target_url: "http://localhost:3000".into(),
181            skip_tls_verify: false,
182            timeout: Duration::from_secs(15),
183            extra_headers: Vec::new(),
184            delay_between_requests: Duration::from_millis(0),
185            base_path: None,
186            source_ips: Vec::new(),
187            geo_source_ips: Vec::new(),
188            geo_source_headers: default_geo_source_headers(),
189            capture: None,
190            validate_response_schemas: false,
191            spec_label: None,
192        }
193    }
194}
195
196/// Truncate `body` to `CAPTURE_BODY_CAP_BYTES` on a UTF-8 boundary,
197/// returning the trimmed string and whether truncation occurred. Used
198/// for both request and response bodies in the capture sink.
199fn truncate_body_for_capture(body: &str) -> (String, bool) {
200    if body.len() <= CAPTURE_BODY_CAP_BYTES {
201        return (body.to_string(), false);
202    }
203    let mut end = CAPTURE_BODY_CAP_BYTES;
204    while end > 0 && !body.is_char_boundary(end) {
205        end -= 1;
206    }
207    (body[..end].to_string(), true)
208}
209
210/// Default forwarded-IP header set. Covers the three conventions a
211/// real GEODB front-end is likely to read in this order of
212/// preference: Cloudflare (`CF-Connecting-IP`), Akamai/CloudFront
213/// (`True-Client-IP`), then the de-facto standard
214/// `X-Forwarded-For`. Override via `--geo-source-header` to test a
215/// specific stack.
216pub fn default_geo_source_headers() -> Vec<String> {
217    vec![
218        "X-Forwarded-For".to_string(),
219        "True-Client-IP".to_string(),
220        "CF-Connecting-IP".to_string(),
221    ]
222}
223
224/// Outcome of a single test case (positive or negative).
225#[derive(Debug, Clone, serde::Serialize)]
226pub struct CaseOutcome {
227    pub label: String,
228    pub expected_4xx: bool,
229    pub actual_status: u16,
230    /// True when the response status matches expectation
231    /// (positive → 2xx-3xx, negative → 4xx).
232    pub passed: bool,
233}
234
235/// All cases run against one annotated operation.
236#[derive(Debug, Clone, serde::Serialize)]
237pub struct OperationResult {
238    pub method: String,
239    pub path: String,
240    pub positive: Option<CaseOutcome>,
241    pub negatives: Vec<CaseOutcome>,
242}
243
244/// Summary report rolled up across all operations.
245#[derive(Debug, Default, Clone, serde::Serialize)]
246pub struct SelfTestReport {
247    pub positive_pass: usize,
248    pub positive_fail: usize,
249    /// Per category: count of negative cases the server correctly
250    /// rejected with a 4xx (we caught the spec violation).
251    pub negative_caught: BTreeMap<String, usize>,
252    /// Per category: count of negative cases that should have been
253    /// rejected but came back with a non-4xx (validator gap).
254    pub negative_missed: BTreeMap<String, usize>,
255    pub operations: Vec<OperationResult>,
256}
257
258impl SelfTestReport {
259    /// All-pass means every positive case got 2xx-3xx and every
260    /// negative case got 4xx.
261    pub fn all_passed(&self) -> bool {
262        self.positive_fail == 0 && self.negative_missed.values().sum::<usize>() == 0
263    }
264
265    /// Round 18.1 — detect the "self-test target is misconfigured"
266    /// case where every positive failed with the *same* status code.
267    /// The classic example: `--base-path /api` was forgotten so every
268    /// request hits a path the server doesn't know and returns 404.
269    /// Pre-warning, the user saw all-green negative buckets (because
270    /// "missing route" 404s look like "validator rejected") and no
271    /// indication that the run was meaningless. Returns Some(status)
272    /// when ≥10 positives all failed with the same status, else None.
273    pub fn detect_target_misconfiguration(&self) -> Option<u16> {
274        if self.positive_pass > 0 || self.positive_fail < 10 {
275            return None;
276        }
277        let mut seen: Option<u16> = None;
278        for op in &self.operations {
279            let Some(p) = &op.positive else {
280                continue;
281            };
282            if p.passed {
283                return None;
284            }
285            match seen {
286                None => seen = Some(p.actual_status),
287                Some(s) if s != p.actual_status => return None,
288                _ => {}
289            }
290        }
291        seen
292    }
293
294    /// Human-readable summary string. One line for positives, one per
295    /// category for negatives. Designed to slot into existing
296    /// `TerminalReporter` output.
297    pub fn render_summary(&self) -> String {
298        let mut out = String::new();
299        out.push_str(&format!(
300            "Positives: {} pass / {} fail\n",
301            self.positive_pass, self.positive_fail
302        ));
303        let mut keys: Vec<&String> =
304            self.negative_caught.keys().chain(self.negative_missed.keys()).collect();
305        keys.sort();
306        keys.dedup();
307        for cat in keys {
308            let caught = self.negative_caught.get(cat).copied().unwrap_or(0);
309            let missed = self.negative_missed.get(cat).copied().unwrap_or(0);
310            let mark = if missed == 0 { "✓" } else { "⚠" };
311            out.push_str(&format!(
312                "Negatives [{}]: {} caught / {} missed  {}\n",
313                cat, caught, missed, mark
314            ));
315        }
316        out
317    }
318}
319
320/// Execute the self-test plan against `config.target_url` for every
321/// `AnnotatedOperation`. Returns the aggregated report; callers
322/// decide how to display it (e.g. via `render_summary` or by writing
323/// the JSON serialisation to disk).
324pub async fn run_self_test(
325    operations: &[AnnotatedOperation],
326    config: &SelfTestConfig,
327) -> Result<SelfTestReport, reqwest::Error> {
328    // Round 18.5 — build a client pool when `source_ips` is set,
329    // one reqwest::Client per IP, each bound to its local address.
330    // Operations round-robin through the pool. Empty pool → single
331    // default client (the pre-18.5 behaviour).
332    let clients = build_client_pool(config)?;
333    let client_cursor = AtomicUsize::new(0);
334    let geo_cursor = AtomicUsize::new(0);
335
336    let mut report = SelfTestReport::default();
337    for op in operations {
338        let client_idx = client_cursor.fetch_add(1, Ordering::Relaxed) % clients.len();
339        let client = &clients[client_idx];
340        let geo_ip = if config.geo_source_ips.is_empty() {
341            None
342        } else {
343            let idx = geo_cursor.fetch_add(1, Ordering::Relaxed) % config.geo_source_ips.len();
344            Some(config.geo_source_ips[idx])
345        };
346        let result = test_operation(client, config, op, geo_ip).await;
347        if let Some(p) = &result.positive {
348            if p.passed {
349                report.positive_pass += 1;
350            } else {
351                report.positive_fail += 1;
352            }
353        }
354        for neg in &result.negatives {
355            let cat = neg.label.split(':').next().unwrap_or("other").to_string();
356            if neg.passed {
357                *report.negative_caught.entry(cat).or_insert(0) += 1;
358            } else {
359                *report.negative_missed.entry(cat).or_insert(0) += 1;
360            }
361        }
362        report.operations.push(result);
363        if !config.delay_between_requests.is_zero() {
364            tokio::time::sleep(config.delay_between_requests).await;
365        }
366    }
367    Ok(report)
368}
369
370/// Round 18.5 — append GEODB forwarded-IP headers to the
371/// operation's declared headers. Returns the original vec untouched
372/// when `geo_ip` is None or `geo_headers` is empty.
373///
374/// If the operation already declares one of the geo headers (rare
375/// but legal), we keep the operation's value — the caller's spec
376/// wins.
377fn effective_op_headers(
378    base: &[(String, String)],
379    geo_ip: Option<IpAddr>,
380    geo_headers: &[String],
381) -> Vec<(String, String)> {
382    let mut out = base.to_vec();
383    let Some(ip) = geo_ip else {
384        return out;
385    };
386    let value = ip.to_string();
387    for h in geo_headers {
388        // Case-insensitive duplicate check: don't override the
389        // spec's own declared value for the header.
390        if out.iter().any(|(k, _)| k.eq_ignore_ascii_case(h)) {
391            continue;
392        }
393        out.push((h.clone(), value.clone()));
394    }
395    out
396}
397
398/// Round 18.5 — build a pool of reqwest clients, one per declared
399/// source IP. Empty `source_ips` → a single default client.
400///
401/// The OS must already have each `source_ip` assigned to an
402/// interface; reqwest's `.local_address()` issues a `bind()` syscall
403/// at connect time, so an IP the kernel doesn't recognise surfaces
404/// as `EADDRNOTAVAIL` at request time, not at builder time.
405fn build_client_pool(config: &SelfTestConfig) -> Result<Vec<Client>, reqwest::Error> {
406    let make = |bind: Option<IpAddr>| -> Result<Client, reqwest::Error> {
407        let mut builder = Client::builder().timeout(config.timeout);
408        if config.skip_tls_verify {
409            builder = builder.danger_accept_invalid_certs(true);
410        }
411        if let Some(addr) = bind {
412            builder = builder.local_address(addr);
413        }
414        builder.build()
415    };
416    if config.source_ips.is_empty() {
417        Ok(vec![make(None)?])
418    } else {
419        config.source_ips.iter().map(|ip| make(Some(*ip))).collect()
420    }
421}
422
423async fn test_operation(
424    client: &Client,
425    config: &SelfTestConfig,
426    op: &AnnotatedOperation,
427    geo_ip: Option<IpAddr>,
428) -> OperationResult {
429    // Round 25 — track the sink length BEFORE we run any probes for
430    // this operation, so that after the probes finish we can mutate
431    // exactly the entries that belong to this op (the capture sink is
432    // shared but `run_self_test` iterates operations sequentially).
433    // Used by the response-schema validation pass below.
434    let sink_start = config.capture.as_ref().and_then(|s| s.lock().ok().map(|g| g.len()));
435
436    let url = build_url_with_base(
437        &config.target_url,
438        config.base_path.as_deref(),
439        &op.path,
440        &op.path_params,
441    );
442    let method = Method::from_bytes(op.method.to_uppercase().as_bytes()).unwrap_or(Method::GET);
443
444    // Round 18.5 — pre-compute the operation's effective headers
445    // with the geo source IP baked in. Doing it once here keeps the
446    // per-case `send_case` calls below unchanged. When `geo_ip` is
447    // None the result equals `op.header_params`.
448    let op_headers = effective_op_headers(&op.header_params, geo_ip, &config.geo_source_headers);
449
450    // ── Positive case ────────────────────────────────────────────
451    let positive = send_case(
452        client,
453        config,
454        method.clone(),
455        &url,
456        "positive",
457        false,
458        op.sample_body.as_deref(),
459        op.query_params.clone(),
460        op_headers.clone(),
461        &op.path,
462    )
463    .await;
464
465    // ── Negative cases ───────────────────────────────────────────
466    let mut negatives = Vec::new();
467
468    // (a) empty body when one is required.
469    //
470    // Round 16 — drop the `sample_body.is_some()` precondition. Operations
471    // whose body annotator couldn't synthesize a sample previously got
472    // zero negatives (so the self-test reported "all passing" even on
473    // POST /resource with a required body). The spec saying the operation
474    // *has* a request body is enough — an empty object is a valid
475    // negative regardless of whether we have a positive sample.
476    if op.request_body_content_type.is_some() {
477        negatives.push(
478            send_case(
479                client,
480                config,
481                method.clone(),
482                &url,
483                "request-body:empty",
484                true,
485                Some("{}"),
486                op.query_params.clone(),
487                op_headers.clone(),
488                &op.path,
489            )
490            .await,
491        );
492
493        // (b) wrong-shaped body (array instead of object) — exercises
494        // top-level type validation independently of which fields are
495        // required.
496        negatives.push(
497            send_case(
498                client,
499                config,
500                method.clone(),
501                &url,
502                "request-body:wrong-type",
503                true,
504                Some("[]"),
505                op.query_params.clone(),
506                op_headers.clone(),
507                &op.path,
508            )
509            .await,
510        );
511
512        // Round 25 (k) — content-type swap probes.
513        //
514        // For operations declaring `application/json` request bodies, send
515        // the SAME json payload (or a synthesised one) under four other
516        // content types: `application/xml`, `application/yaml`,
517        // `multipart/form-data`, `application/x-www-form-urlencoded`.
518        // The spec says the endpoint accepts only JSON, so a strict server
519        // should respond 415 Unsupported Media Type (or 400 if it tries
520        // to parse and fails). A 2xx means the server is accepting
521        // payloads outside its declared content negotiation, which is the
522        // failure mode behind a lot of "we crashed on a malformed XML
523        // upload" incidents.
524        //
525        // Variant (a) of Srikanth's round-23 g ask: lie about the
526        // Content-Type header. The body shape is honest JSON; only the
527        // header is swapped. Variant (b) (JSON envelope with embedded
528        // non-JSON field values) is deferred to round 26 because it
529        // requires a schema-aware field walker.
530        if op
531            .request_body_content_type
532            .as_deref()
533            .map(|ct| ct.contains("json"))
534            .unwrap_or(false)
535        {
536            let payload = op.sample_body.as_deref().unwrap_or("{}");
537            for (ct, label) in CONTENT_TYPE_SWAP_VARIANTS {
538                negatives.push(
539                    send_case_with_extra(
540                        client,
541                        config,
542                        method.clone(),
543                        &url,
544                        label,
545                        true,
546                        Some(payload),
547                        op.query_params.clone(),
548                        // Strip any Content-Type already on the operation
549                        // headers (the spec's positive value) so the
550                        // probe's value is the only one the server sees.
551                        op_headers
552                            .iter()
553                            .filter(|(k, _)| !k.eq_ignore_ascii_case("content-type"))
554                            .cloned()
555                            .collect(),
556                        // The wrong Content-Type rides on `extra_headers`
557                        // so it lands AFTER `send_case_with_extra`'s
558                        // unconditional `application/json` insertion in
559                        // request-body mode. Actually `send_case_with_extra`
560                        // only sets Content-Type when a body is present
561                        // AND there's no manual override; passing the
562                        // override here wins because reqwest preserves
563                        // the last-set header value.
564                        vec![("Content-Type".to_string(), (*ct).to_string())],
565                        &op.path,
566                    )
567                    .await,
568                );
569            }
570
571            // Round 27 (k variant b) — embedded non-JSON content
572            // inside a valid JSON envelope. Content-Type stays
573            // application/json (honest) and the body parses as JSON;
574            // only the string-valued payload changes. We expect 2xx-3xx
575            // because the envelope is spec-shape, so the probe surfaces
576            // servers that crash (5xx) trying to parse the embedded
577            // snippet as XML/YAML/etc. A 4xx is also a finding because
578            // it usually means the server's pattern/format validator
579            // tripped on the payload contents, but the user can decide
580            // from the JSONL whether that's a bug or correct narrow-
581            // string-field behaviour.
582            for (label, snippet) in EMBEDDED_CONTENT_VARIANTS {
583                let payload = op.sample_body.as_deref().unwrap_or("{}");
584                let body = embed_payload_in_first_string_field(payload, snippet);
585                negatives.push(
586                    send_case(
587                        client,
588                        config,
589                        method.clone(),
590                        &url,
591                        label,
592                        // expected_4xx=false: any non-2xx is a probe
593                        // failure. 5xx in particular is "server panicked
594                        // on the embedded content".
595                        false,
596                        Some(&body),
597                        op.query_params.clone(),
598                        op_headers.clone(),
599                        &op.path,
600                    )
601                    .await,
602                );
603            }
604        }
605
606        // Round 17.2 — schema-aware negatives.
607        //
608        // When both a positive sample AND the resolved body schema are
609        // available, mutate the sample per-field (type mismatch,
610        // min/max bounds, pattern, enum out-of-range, required-field
611        // removal) and assert each is rejected with 4xx. Capped at
612        // SCHEMA_MUTATION_CAP per operation so a 100-property body
613        // doesn't explode the test matrix.
614        if let (Some(sample_str), Some(schema)) =
615            (op.sample_body.as_deref(), op.request_body_schema.as_ref())
616        {
617            if let Ok(sample) = serde_json::from_str::<serde_json::Value>(sample_str) {
618                let mutations = super::schema_mutator::mutate_body(&sample, schema);
619                for m in mutations.into_iter().take(SCHEMA_MUTATION_CAP) {
620                    let body_str = serde_json::to_string(&m.body).unwrap_or_default();
621                    negatives.push(
622                        send_case(
623                            client,
624                            config,
625                            method.clone(),
626                            &url,
627                            &m.label,
628                            true,
629                            Some(&body_str),
630                            op.query_params.clone(),
631                            // Round 24 (f) — was `op.header_params`, which
632                            // skipped the geo-IP header. Use `op_headers`
633                            // so the geo IP rides with the negative probe
634                            // too (positive vs negative coverage must be
635                            // symmetric, otherwise a GEODB front-end sees
636                            // the rotating IP only on positives).
637                            op_headers.clone(),
638                            &op.path,
639                        )
640                        .await,
641                    );
642                }
643            }
644        }
645    }
646
647    // Round 17.2 — URI-length probe. Spec-agnostic but schema-aware in
648    // spirit: most servers cap URIs at 8 KB or so. Append a 9 KB query
649    // string to the URL and expect 414 URI Too Long (or 400). Skipped
650    // for operations that already have a heavy positive query.
651    {
652        let pad = "p=".to_string() + &"x".repeat(9_000);
653        let bad_url = if url.contains('?') {
654            format!("{url}&{pad}")
655        } else {
656            format!("{url}?{pad}")
657        };
658        negatives.push(
659            send_case(
660                client,
661                config,
662                method.clone(),
663                &bad_url,
664                "parameters:uri-too-long",
665                true,
666                op.sample_body.as_deref(),
667                op.query_params.clone(),
668                // Round 24 (f) — see schema-mutation note above. Use
669                // `op_headers` (carries geo IP) instead of bare
670                // `op.header_params`.
671                op_headers.clone(),
672                &op.path,
673            )
674            .await,
675        );
676    }
677
678    // (e) Round 16 — path-param type probe. Send the first path
679    // parameter as a literal `"self-test-invalid-id"`: a string that
680    // contains hyphens, won't parse as an integer, won't parse as a
681    // UUID, and won't match any typical regex pattern. Operations
682    // whose spec types the param as `integer` or `string` with a
683    // `format`/`pattern` will catch this (caught: server returned
684    // 4xx); operations whose spec lets path params be free-form
685    // strings will let it through (missed: server returned 2xx).
686    // Either outcome is informative: a category that's all "missed"
687    // tells the user their spec is loose on path-param types, which
688    // is itself worth knowing. Addresses Srikanth's "always all
689    // passing" report — operations with a path param now produce at
690    // least one probe instead of zero.
691    if !op.path_params.is_empty() {
692        let mut url_with_placeholder = op.path.clone();
693        if let Some((first_name, _)) = op.path_params.first() {
694            // Substitute every other path-param with its sample so the
695            // route shape stays intact and only the first param is bad.
696            for (name, value) in op.path_params.iter().skip(1) {
697                if !value.is_empty() {
698                    url_with_placeholder =
699                        url_with_placeholder.replace(&format!("{{{name}}}"), value);
700                }
701            }
702            // Substitute the first param with a guaranteed-invalid
703            // sentinel that's unlikely to match any reasonable schema:
704            // contains characters disallowed in numeric IDs *and* UUIDs.
705            url_with_placeholder =
706                url_with_placeholder.replace(&format!("{{{first_name}}}"), "self-test-invalid-id");
707            // Round 18.1 — honour `base_path` here too, otherwise the
708            // probe URL differs from the positive case and the
709            // resulting 404 is misattributed to "bad path param".
710            let bad_url = build_url_with_base(
711                &config.target_url,
712                config.base_path.as_deref(),
713                &url_with_placeholder,
714                &[],
715            );
716            negatives.push(
717                send_case(
718                    client,
719                    config,
720                    method.clone(),
721                    &bad_url,
722                    "parameters:bad-path-param",
723                    true,
724                    op.sample_body.as_deref(),
725                    op.query_params.clone(),
726                    op_headers.clone(),
727                    &op.path,
728                )
729                .await,
730            );
731        }
732    }
733
734    // (c) drop the first required query param
735    if !op.query_params.is_empty() {
736        let mut q = op.query_params.clone();
737        q.remove(0);
738        negatives.push(
739            send_case(
740                client,
741                config,
742                method.clone(),
743                &url,
744                "parameters:missing-query",
745                true,
746                op.sample_body.as_deref(),
747                q,
748                op_headers.clone(),
749                &op.path,
750            )
751            .await,
752        );
753    }
754
755    // (s) Round 17.3 — security probes.
756    //
757    // Operations whose spec declares a security requirement get a
758    // dedicated set of negatives. The point isn't to test whether the
759    // server's *real* auth works (the positive case already does that
760    // via `extra_headers`) — it's to check whether deliberately-bad
761    // credentials are still rejected, which is exactly the failure
762    // mode that lets an attacker through a half-wired validator.
763    //
764    // Each probe replaces or omits the relevant auth credential and
765    // expects 401 / 403. A 2xx here is a hard finding: "spec says
766    // this endpoint is protected, server let unauthenticated /
767    // wrong-credential traffic through".
768    //
769    // Bounded: at most one probe per declared scheme kind, so an
770    // operation with 3 security requirements doesn't 4× the request
771    // volume. Skips entirely when `op.security_schemes` is empty.
772    for probe in build_security_probes(&op.security_schemes) {
773        // Strip any pre-existing Authorization or known API-key
774        // header from extra_headers + header_params so the probe
775        // value is the *only* credential the server sees.
776        let stripped_extra = strip_auth(&config.extra_headers, &op.security_schemes);
777        let stripped_headers = strip_auth(&op.header_params, &op.security_schemes);
778        let stripped_query = strip_auth_query(&op.query_params, &op.security_schemes);
779        let mut req_headers = stripped_headers;
780        for (k, v) in &probe.headers {
781            req_headers.push((k.clone(), v.clone()));
782        }
783        // Round 24 (f) — security probes build req_headers from
784        // `op.header_params` directly (we need the stripped-auth
785        // variant), so the geo-IP header doesn't ride along
786        // automatically. Append it here so a GEODB / WAF in front
787        // of the auth layer still sees the rotating source IP.
788        if let Some(ip) = geo_ip {
789            let ip_str = ip.to_string();
790            for h in &config.geo_source_headers {
791                let already = req_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(h));
792                if !already {
793                    req_headers.push((h.clone(), ip_str.clone()));
794                }
795            }
796        }
797        let mut req_query = stripped_query;
798        for (k, v) in &probe.query {
799            req_query.push((k.clone(), v.clone()));
800        }
801        negatives.push(
802            send_case_with_extra(
803                client,
804                config,
805                method.clone(),
806                &url,
807                &probe.label,
808                true,
809                op.sample_body.as_deref(),
810                req_query,
811                req_headers,
812                stripped_extra,
813                &op.path,
814            )
815            .await,
816        );
817    }
818
819    // (d) drop the first required header
820    if !op.header_params.is_empty() {
821        // Round 24 (f) — start from `op_headers` (so the geo IP rides
822        // along) and only strip the first OPERATION-declared header.
823        // Slicing past `op.header_params.len()` would otherwise risk
824        // dropping the geo header itself; `op_headers` is built as
825        // `op.header_params ++ geo` so index 0 is always operational.
826        let mut h = op_headers.clone();
827        if !h.is_empty() {
828            h.remove(0);
829        }
830        negatives.push(
831            send_case(
832                client,
833                config,
834                method.clone(),
835                &url,
836                "parameters:missing-header",
837                true,
838                op.sample_body.as_deref(),
839                op.query_params.clone(),
840                h,
841                &op.path,
842            )
843            .await,
844        );
845    }
846
847    // (w) Round 17.5 — OWASP/WAF unification.
848    //
849    // Pull one canonical payload per OWASP category from the existing
850    // `SecurityPayloads` library and emit an injection probe per
851    // category. Targets in priority order: (1) substitute the first
852    // query param's value, (2) substitute the first string field of
853    // the positive JSON body, (3) skip if neither is available.
854    //
855    // Label format `owasp:<category>`, so the existing
856    // `negative_caught` / `negative_missed` rollup groups all OWASP
857    // findings under one `owasp` bucket. Expected 4xx (server should
858    // reject malicious input). A 5xx is a hard finding (server
859    // crashed on the payload); a 2xx is a soft finding (input passed
860    // through unfiltered — may or may not be a real vuln).
861    //
862    // Bounded: at most one probe per category (7 categories total).
863    // Skips the operation entirely if no injection target is
864    // available — open GET endpoints with no params get zero OWASP
865    // probes, no false signal.
866    for probe in build_owasp_probes(op) {
867        negatives.push(
868            send_case(
869                client,
870                config,
871                method.clone(),
872                &url,
873                &probe.label,
874                true,
875                probe.body.as_deref(),
876                probe.query,
877                // Round 24 (f) — OWASP injection probes must also
878                // carry the geo IP, otherwise a WAF / GEODB rule
879                // tuned to a specific source IP would silently let
880                // them through.
881                op_headers.clone(),
882                &op.path,
883            )
884            .await,
885        );
886    }
887
888    // Round 25 — response-body shape validation pass. For each capture
889    // this op pushed onto the sink, look up the spec's schema for the
890    // actual response status and validate. Result lands in
891    // `response_schema_error` (Some(message) on failure, None on
892    // pass or no-schema-for-this-status). Runs only when the user
893    // opted in AND capture is on (we need the body).
894    if config.validate_response_schemas {
895        if let (Some(sink), Some(start)) = (config.capture.as_ref(), sink_start) {
896            if !op.response_schemas.is_empty() {
897                if let Ok(mut guard) = sink.lock() {
898                    let end = guard.len();
899                    for i in start..end {
900                        let Some(entry) = guard.get_mut(i) else {
901                            continue;
902                        };
903                        let Some(body) = entry.response_body.as_deref() else {
904                            continue;
905                        };
906                        let Some(schema) = op.response_schemas.get(&entry.response_status) else {
907                            continue;
908                        };
909                        entry.response_schema_error = validate_body_against_schema(body, schema);
910                    }
911                }
912            }
913        }
914    }
915
916    OperationResult {
917        method: op.method.clone(),
918        path: op.path.clone(),
919        positive: Some(positive),
920        negatives,
921    }
922}
923
924/// Round 25 — validate a JSON body string against an OpenAPI response
925/// schema (already converted to a `serde_json::Value`). Returns
926/// `Some(message)` describing the first violation, or `None` on a
927/// clean pass / non-JSON body / schema-build failure (in which case
928/// the absence of an error means "we didn't have anything to compare
929/// against", not "passed"; the caller-side semantics treat absence as
930/// success because that's what the user sees as silence).
931/// Round 27 (k variant b) — return a JSON body string identical to
932/// `sample` except that the first string-valued leaf has been
933/// replaced with `snippet`. Walks objects depth-first and stops at
934/// the first string. If `sample` is not parseable JSON, or has no
935/// string fields, falls back to wrapping the snippet under a `data`
936/// key so the probe still has a body to send: `{"data": <snippet>}`.
937/// The result is always valid JSON ready for `application/json`.
938fn embed_payload_in_first_string_field(sample: &str, snippet: &str) -> String {
939    let mut parsed: serde_json::Value = match serde_json::from_str(sample) {
940        Ok(v) => v,
941        Err(_) => return format!(r#"{{"data":{}}}"#, json_quote(snippet)),
942    };
943    if !replace_first_string(&mut parsed, snippet) {
944        return format!(r#"{{"data":{}}}"#, json_quote(snippet));
945    }
946    serde_json::to_string(&parsed)
947        .unwrap_or_else(|_| format!(r#"{{"data":{}}}"#, json_quote(snippet)))
948}
949
950/// Helper for `embed_payload_in_first_string_field`: recursively
951/// walk the value and replace the FIRST string leaf encountered.
952/// Returns true when a replacement happened. Honors document order
953/// for objects (BTreeMap-backed `serde_json::Map` iterates in
954/// insertion order) so the choice of which field to mutate is
955/// stable across runs.
956fn replace_first_string(v: &mut serde_json::Value, snippet: &str) -> bool {
957    match v {
958        serde_json::Value::String(s) => {
959            *s = snippet.to_string();
960            true
961        }
962        serde_json::Value::Object(map) => {
963            for (_k, child) in map.iter_mut() {
964                if replace_first_string(child, snippet) {
965                    return true;
966                }
967            }
968            false
969        }
970        serde_json::Value::Array(arr) => {
971            for child in arr.iter_mut() {
972                if replace_first_string(child, snippet) {
973                    return true;
974                }
975            }
976            false
977        }
978        _ => false,
979    }
980}
981
982/// Helper for `embed_payload_in_first_string_field`'s fallback: take
983/// an arbitrary string and quote it for embedding inside a JSON
984/// literal. `serde_json::to_string(&value)` handles escaping
985/// correctly for unicode + control chars + quotes.
986fn json_quote(s: &str) -> String {
987    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
988}
989
990fn validate_body_against_schema(body: &str, schema: &serde_json::Value) -> Option<String> {
991    let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
992    let validator = jsonschema::validator_for(schema).ok()?;
993    let mut errors = validator.iter_errors(&parsed);
994    let first = errors.next()?;
995    // Round 28 — Srikanth on 0.3.170 wanted the message to show the
996    // actual expected schema alongside the kind label so it reads as
997    // "expected schema {...} but got <kind>". We emit a compact JSON
998    // serialisation of the schema as a suffix; the kind label still
999    // names what went wrong in plain English for quick scanning.
1000    // Round 26 — Srikanth on 0.3.169: the prior `format!("{:?}", first.kind)
1001    // .split('(').next()` produced "Type { kind: Single" (broken Rust
1002    // syntax, mismatched braces). Switch to the human-readable mapping
1003    // already used in executor.rs: handle the common kinds (Type,
1004    // Required, AdditionalProperties, Enum, MinLength, MaxLength,
1005    // Minimum, Maximum, Pattern) explicitly; fall back to the
1006    // jsonschema crate's Display impl on the error (which produces
1007    // something like "{...} is not of type \"string\"") for the long
1008    // tail. Combined with `at <instance-path>` for the field location.
1009    let path = first.instance_path.to_string();
1010    let path = if path.is_empty() { "/" } else { path.as_str() };
1011    // Round 31 — Srikanth on 0.3.174 hit the vCenter case where the
1012    // error is "required field missing: comment" but the printed
1013    // schema was the WHOLE parent object schema (with descriptions of
1014    // every property), not just the missing field's sub-schema. The
1015    // jsonschema crate emits `Required` errors with
1016    // `instance_path == /` (the parent), so the round-30 sub-schema
1017    // walker had no extra info to focus the suffix. Carry the missing
1018    // property name out of the kind match so we can descend one more
1019    // step into `properties[property]` for the printed schema.
1020    let mut required_property: Option<String> = None;
1021    let kind_msg: String = match &first.kind {
1022        jsonschema::error::ValidationErrorKind::Type { kind } => {
1023            // `kind` is `TypeKind::Single(JsonType)` or
1024            // `TypeKind::Multiple(JsonTypeSet)`. `JsonType` has its
1025            // own `Display` impl ("string", "object", etc.).
1026            match kind {
1027                jsonschema::error::TypeKind::Single(t) => format!("expected type {t}"),
1028                jsonschema::error::TypeKind::Multiple(_) => "expected one of multiple types".into(),
1029            }
1030        }
1031        jsonschema::error::ValidationErrorKind::Required { property } => {
1032            // `property.to_string()` returns the Display of the JSON
1033            // value, which for a string is `"name"` (with quotes).
1034            // Strip them for the lookup; keep them in the human message.
1035            let raw = property.to_string();
1036            let unquoted = raw
1037                .strip_prefix('"')
1038                .and_then(|s| s.strip_suffix('"'))
1039                .unwrap_or(&raw)
1040                .to_string();
1041            required_property = Some(unquoted);
1042            format!("required field missing: {property}")
1043        }
1044        jsonschema::error::ValidationErrorKind::AdditionalProperties { unexpected } => {
1045            format!("unexpected additional properties: {unexpected:?}")
1046        }
1047        jsonschema::error::ValidationErrorKind::Enum { options } => {
1048            format!("value not in allowed enum: {options}")
1049        }
1050        jsonschema::error::ValidationErrorKind::MinLength { limit } => {
1051            format!("string shorter than min length ({limit})")
1052        }
1053        jsonschema::error::ValidationErrorKind::MaxLength { limit } => {
1054            format!("string longer than max length ({limit})")
1055        }
1056        jsonschema::error::ValidationErrorKind::Minimum { limit } => {
1057            format!("value below minimum ({limit})")
1058        }
1059        jsonschema::error::ValidationErrorKind::Maximum { limit } => {
1060            format!("value above maximum ({limit})")
1061        }
1062        jsonschema::error::ValidationErrorKind::Pattern { pattern } => {
1063            format!("value did not match pattern {pattern}")
1064        }
1065        // Long tail: lean on jsonschema's Display impl, which is the
1066        // built-in human-readable error message ("X is not of type Y").
1067        // Strip trailing newlines so the JSONL line stays one line.
1068        _ => first.to_string().trim().to_string(),
1069    };
1070    // Round 30 — Srikanth on 0.3.173 asked how a deeper nested mismatch
1071    // reads. The prior output printed the WHOLE top-level schema even for
1072    // a single-field mismatch, which buried the actual constraint that
1073    // failed. Walk the instance pointer through the schema's properties
1074    // chain and print the most specific sub-schema we can find. Falls
1075    // back to the full schema for paths the walker can't resolve
1076    // (additionalProperties, oneOf, allOf, $ref un-resolved, etc.).
1077    let mut focused_schema = sub_schema_at_pointer(schema, path).unwrap_or_else(|| schema.clone());
1078    // Round 31 — for Required errors, descend one more step into
1079    // `properties[<missing>]` so the printed schema is the missing
1080    // field's own constraint, not the whole parent.
1081    if let Some(prop_name) = required_property.as_ref() {
1082        if let Some(prop_schema) =
1083            focused_schema.get("properties").and_then(|p| p.get(prop_name.as_str()))
1084        {
1085            focused_schema = prop_schema.clone();
1086        }
1087    }
1088    let schema_str = serde_json::to_string(&focused_schema).unwrap_or_else(|_| "<schema>".into());
1089    let schema_str = if schema_str.len() > 300 {
1090        format!("{}...", &schema_str[..300])
1091    } else {
1092        schema_str
1093    };
1094    // Round 29 — Srikanth on 0.3.172 was confused by `at /:` thinking
1095    // it referenced the URL path; it's actually a JSON pointer into
1096    // the RESPONSE BODY. Reword so that's unambiguous: explicit
1097    // "response body" prefix and a human label for the root case.
1098    let location = if path == "/" {
1099        "response body root".to_string()
1100    } else {
1101        format!("response body at {path}")
1102    };
1103    Some(format!("{location}: {kind_msg}; expected schema {schema_str}"))
1104}
1105
1106/// Round 30 — walk a JSON-Pointer-style instance path through a JSON
1107/// Schema and return the sub-schema describing the value at that
1108/// position. For path `/name/age` on
1109/// `{"properties":{"name":{"properties":{"age":{"type":"integer"}}}}}`
1110/// returns `{"type":"integer"}`. Returns `None` for paths the walker
1111/// can't follow (array indices into `items` with no per-index schema,
1112/// `additionalProperties`, `oneOf`/`allOf`, unresolved `$ref`); callers
1113/// should fall back to the full schema in that case.
1114fn sub_schema_at_pointer(schema: &serde_json::Value, pointer: &str) -> Option<serde_json::Value> {
1115    if pointer.is_empty() || pointer == "/" {
1116        return Some(schema.clone());
1117    }
1118    let mut current = schema;
1119    for seg in pointer.trim_start_matches('/').split('/') {
1120        let unescaped = seg.replace("~1", "/").replace("~0", "~");
1121        if let Some(props) = current.get("properties") {
1122            if let Some(sub) = props.get(&unescaped) {
1123                current = sub;
1124                continue;
1125            }
1126        }
1127        if let Some(items) = current.get("items") {
1128            if items.is_object() {
1129                current = items;
1130                continue;
1131            }
1132        }
1133        return None;
1134    }
1135    Some(current.clone())
1136}
1137
1138/// Round 17.5 — one OWASP injection probe to send.
1139#[derive(Debug, Clone)]
1140struct OwaspProbe {
1141    label: String,
1142    body: Option<String>,
1143    query: Vec<(String, String)>,
1144}
1145
1146/// Build one OWASP probe per `SecurityCategory` for `op`. Targets the
1147/// first query param if any, else the first string field of the
1148/// positive JSON body. Returns empty if neither target is available.
1149fn build_owasp_probes(op: &AnnotatedOperation) -> Vec<OwaspProbe> {
1150    use crate::security_payloads::{SecurityCategory, SecurityPayloads};
1151
1152    let categories = [
1153        SecurityCategory::SqlInjection,
1154        SecurityCategory::Xss,
1155        SecurityCategory::CommandInjection,
1156        SecurityCategory::PathTraversal,
1157        SecurityCategory::Ssti,
1158        SecurityCategory::LdapInjection,
1159        SecurityCategory::Xxe,
1160    ];
1161
1162    // Pick an injection target ONCE per operation; reuse it across
1163    // categories. (A single op gets up to 7 probes — one per category
1164    // — all attacking the same field.)
1165    let injection_target = pick_injection_target(op);
1166    let Some(target) = injection_target else {
1167        return Vec::new();
1168    };
1169
1170    let mut probes = Vec::new();
1171    for cat in categories {
1172        // Take the *first* payload from each category. The
1173        // collection's first entry is the canonical low-risk
1174        // representative; later entries include time-based / blind
1175        // probes that aren't useful as a one-shot rejection test.
1176        let Some(payload) = SecurityPayloads::get_by_category(cat).into_iter().next() else {
1177            continue;
1178        };
1179        let mut query = op.query_params.clone();
1180        let mut body = op.sample_body.clone();
1181        match &target {
1182            InjectionTarget::Query(idx) => {
1183                if let Some(slot) = query.get_mut(*idx) {
1184                    slot.1 = payload.payload.clone();
1185                }
1186            }
1187            InjectionTarget::BodyStringField(field) => {
1188                body = inject_into_body_field(body.as_deref(), field, &payload.payload);
1189            }
1190        }
1191        probes.push(OwaspProbe {
1192            label: format!("owasp:{}", cat),
1193            body,
1194            query,
1195        });
1196    }
1197    probes
1198}
1199
1200#[derive(Debug, Clone)]
1201enum InjectionTarget {
1202    Query(usize),
1203    BodyStringField(String),
1204}
1205
1206fn pick_injection_target(op: &AnnotatedOperation) -> Option<InjectionTarget> {
1207    if !op.query_params.is_empty() {
1208        return Some(InjectionTarget::Query(0));
1209    }
1210    let sample = op.sample_body.as_deref()?;
1211    let parsed: serde_json::Value = serde_json::from_str(sample).ok()?;
1212    let obj = parsed.as_object()?;
1213    for (k, v) in obj {
1214        if v.is_string() {
1215            return Some(InjectionTarget::BodyStringField(k.clone()));
1216        }
1217    }
1218    None
1219}
1220
1221/// Replace the value of `field` in a JSON-object body with `payload`.
1222/// Returns the mutated body as a JSON string. Returns `None` if the
1223/// body doesn't parse as a JSON object.
1224fn inject_into_body_field(body: Option<&str>, field: &str, payload: &str) -> Option<String> {
1225    let raw = body?;
1226    let mut parsed: serde_json::Value = serde_json::from_str(raw).ok()?;
1227    let obj = parsed.as_object_mut()?;
1228    obj.insert(field.to_string(), serde_json::json!(payload));
1229    serde_json::to_string(&parsed).ok()
1230}
1231
1232#[allow(clippy::too_many_arguments)]
1233/// Round 17.3 — one synthesised bad credential to send.
1234#[derive(Debug, Clone)]
1235struct SecurityProbe {
1236    /// Self-test label, e.g. `security:bad-bearer`.
1237    label: String,
1238    /// Headers to attach to the probe request.
1239    headers: Vec<(String, String)>,
1240    /// Query parameters to attach (API key in query case).
1241    query: Vec<(String, String)>,
1242}
1243
1244/// For each declared security scheme, produce one bad-credential
1245/// probe plus a single "no auth at all" probe that exercises the
1246/// missing-credential code path. Deduplicates by scheme kind so an
1247/// operation declaring `[bearer, bearer]` only yields one Bearer
1248/// probe.
1249fn build_security_probes(schemes: &[SecuritySchemeInfo]) -> Vec<SecurityProbe> {
1250    if schemes.is_empty() {
1251        return Vec::new();
1252    }
1253    let mut probes: Vec<SecurityProbe> = Vec::new();
1254    let mut seen_bearer = false;
1255    let mut seen_basic = false;
1256    // `(loc_tag, name)` — ApiKeyLocation doesn't implement Ord, so
1257    // we tag it with a short discriminant string for dedup.
1258    let mut seen_apikey: std::collections::BTreeSet<(&'static str, String)> = Default::default();
1259    for s in schemes {
1260        match s {
1261            SecuritySchemeInfo::Bearer if !seen_bearer => {
1262                seen_bearer = true;
1263                probes.push(SecurityProbe {
1264                    label: "security:bad-bearer".into(),
1265                    headers: vec![(
1266                        "Authorization".into(),
1267                        "Bearer self-test-invalid-token".into(),
1268                    )],
1269                    query: Vec::new(),
1270                });
1271            }
1272            SecuritySchemeInfo::Basic if !seen_basic => {
1273                seen_basic = true;
1274                // base64("self-test:invalid") — valid base64, wrong creds.
1275                probes.push(SecurityProbe {
1276                    label: "security:bad-basic".into(),
1277                    headers: vec![(
1278                        "Authorization".into(),
1279                        "Basic c2VsZi10ZXN0OmludmFsaWQ=".into(),
1280                    )],
1281                    query: Vec::new(),
1282                });
1283            }
1284            SecuritySchemeInfo::ApiKey { location, name } => {
1285                let loc_tag = match location {
1286                    ApiKeyLocation::Header => "header",
1287                    ApiKeyLocation::Query => "query",
1288                    ApiKeyLocation::Cookie => "cookie",
1289                };
1290                if seen_apikey.contains(&(loc_tag, name.clone())) {
1291                    continue;
1292                }
1293                seen_apikey.insert((loc_tag, name.clone()));
1294                let label = format!("security:bad-apikey:{}", name);
1295                let bad = "self-test-invalid-key".to_string();
1296                match location {
1297                    ApiKeyLocation::Header => probes.push(SecurityProbe {
1298                        label,
1299                        headers: vec![(name.clone(), bad)],
1300                        query: Vec::new(),
1301                    }),
1302                    ApiKeyLocation::Query => probes.push(SecurityProbe {
1303                        label,
1304                        headers: Vec::new(),
1305                        query: vec![(name.clone(), bad)],
1306                    }),
1307                    ApiKeyLocation::Cookie => probes.push(SecurityProbe {
1308                        label,
1309                        headers: vec![("Cookie".into(), format!("{}={}", name, bad))],
1310                        query: Vec::new(),
1311                    }),
1312                }
1313            }
1314            _ => {}
1315        }
1316    }
1317    // Always add a "no auth at all" probe when *any* security scheme
1318    // is declared — useful even if all schemes failed to resolve to a
1319    // testable kind, because it surfaces validators that aren't
1320    // checking auth presence at all.
1321    probes.push(SecurityProbe {
1322        label: "security:no-auth".into(),
1323        headers: Vec::new(),
1324        query: Vec::new(),
1325    });
1326    probes
1327}
1328
1329/// Remove Authorization and any API-key headers declared by the
1330/// operation's security schemes from `headers`, so a security probe
1331/// can supply its own credential (or none) cleanly.
1332fn strip_auth(
1333    headers: &[(String, String)],
1334    schemes: &[SecuritySchemeInfo],
1335) -> Vec<(String, String)> {
1336    let mut apikey_headers: std::collections::BTreeSet<String> = Default::default();
1337    for s in schemes {
1338        if let SecuritySchemeInfo::ApiKey {
1339            location: ApiKeyLocation::Header,
1340            name,
1341        } = s
1342        {
1343            apikey_headers.insert(name.to_lowercase());
1344        }
1345        if let SecuritySchemeInfo::ApiKey {
1346            location: ApiKeyLocation::Cookie,
1347            ..
1348        } = s
1349        {
1350            apikey_headers.insert("cookie".into());
1351        }
1352    }
1353    headers
1354        .iter()
1355        .filter(|(k, _)| {
1356            let lk = k.to_lowercase();
1357            lk != "authorization" && !apikey_headers.contains(&lk)
1358        })
1359        .cloned()
1360        .collect()
1361}
1362
1363/// Remove API-key query parameters declared by the operation's
1364/// security schemes from `query`, so a probe can supply its own.
1365fn strip_auth_query(
1366    query: &[(String, String)],
1367    schemes: &[SecuritySchemeInfo],
1368) -> Vec<(String, String)> {
1369    let mut apikey_query: std::collections::BTreeSet<String> = Default::default();
1370    for s in schemes {
1371        if let SecuritySchemeInfo::ApiKey {
1372            location: ApiKeyLocation::Query,
1373            name,
1374        } = s
1375        {
1376            apikey_query.insert(name.clone());
1377        }
1378    }
1379    query.iter().filter(|(k, _)| !apikey_query.contains(k)).cloned().collect()
1380}
1381
1382/// Variant of `send_case` that takes an explicit `extra_headers`
1383/// (rather than reading them from `config`). Used by security probes
1384/// to substitute or strip the configured Authorization header.
1385#[allow(clippy::too_many_arguments)]
1386async fn send_case_with_extra(
1387    client: &Client,
1388    config: &SelfTestConfig,
1389    method: Method,
1390    url: &str,
1391    label: &str,
1392    expected_4xx: bool,
1393    body: Option<&str>,
1394    query: Vec<(String, String)>,
1395    headers: Vec<(String, String)>,
1396    extra_headers: Vec<(String, String)>,
1397    // Round 33 (#823) — spec path template (e.g. `/users/{id}`)
1398    // for the operation this probe belongs to. Stamped on the
1399    // capture so the per-endpoint summary can group by template.
1400    path_template: &str,
1401) -> CaseOutcome {
1402    let mut req = client.request(method.clone(), url);
1403    let mut capture_headers: BTreeMap<String, String> = BTreeMap::new();
1404    for (k, v) in &query {
1405        req = req.query(&[(k.as_str(), v.as_str())]);
1406    }
1407    // Round 28 — reqwest's `.header(k, v)` APPENDS rather than replaces
1408    // (.headers().insert() would replace but isn't on the builder).
1409    // The previous round-25 fix relied on "last-write-wins" semantics
1410    // that don't exist; for content-type-swap probes the request went
1411    // out with BOTH `Content-Type: application/json` AND `Content-Type:
1412    // application/xml`, and axum's `Json<>` extractor picked the JSON
1413    // one and accepted, so the server-side validator never saw the
1414    // mismatch. Build a `HeaderMap` ourselves so the override
1415    // replaces the body-block default exactly once.
1416    let mut final_headers: reqwest::header::HeaderMap = reqwest::header::HeaderMap::new();
1417    if let Some(_b) = body {
1418        if let Ok(v) = reqwest::header::HeaderValue::from_str("application/json") {
1419            final_headers.insert(reqwest::header::CONTENT_TYPE, v);
1420        }
1421        capture_headers.insert("Content-Type".to_string(), "application/json".to_string());
1422    }
1423    for (k, v) in &headers {
1424        if let (Ok(hn), Ok(hv)) = (
1425            reqwest::header::HeaderName::from_bytes(k.as_bytes()),
1426            reqwest::header::HeaderValue::from_str(v),
1427        ) {
1428            final_headers.insert(hn, hv);
1429        }
1430        capture_headers.insert(k.clone(), v.clone());
1431    }
1432    for (k, v) in &extra_headers {
1433        if let (Ok(hn), Ok(hv)) = (
1434            reqwest::header::HeaderName::from_bytes(k.as_bytes()),
1435            reqwest::header::HeaderValue::from_str(v),
1436        ) {
1437            final_headers.insert(hn, hv);
1438        }
1439        capture_headers.insert(k.clone(), v.clone());
1440    }
1441    if let Some(b) = body {
1442        req = req.body(b.to_string());
1443    }
1444    req = req.headers(final_headers);
1445    let (actual_status, response_capture) = match req.send().await {
1446        Ok(resp) => {
1447            let status = resp.status().as_u16();
1448            if let Some(sink) = &config.capture {
1449                let resp_headers: BTreeMap<String, String> = resp
1450                    .headers()
1451                    .iter()
1452                    .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
1453                    .collect();
1454                let text = resp.text().await.unwrap_or_default();
1455                let (rb, truncated) = truncate_body_for_capture(&text);
1456                (status, Some((Some((rb, truncated)), resp_headers, None, sink.clone())))
1457            } else {
1458                (status, None)
1459            }
1460        }
1461        Err(e) => {
1462            let err_str = e.to_string();
1463            if let Some(sink) = &config.capture {
1464                (0, Some((None, BTreeMap::new(), Some(err_str), sink.clone())))
1465            } else {
1466                (0, None)
1467            }
1468        }
1469    };
1470    let passed = if expected_4xx {
1471        (400..500).contains(&actual_status)
1472    } else {
1473        (200..400).contains(&actual_status)
1474    };
1475    if let Some((resp_body, resp_headers, error, sink)) = response_capture {
1476        let (request_body, request_body_truncated) = match body {
1477            Some(b) => {
1478                let (rb, t) = truncate_body_for_capture(b);
1479                (Some(rb), t)
1480            }
1481            None => (None, false),
1482        };
1483        let (response_body, response_body_truncated) = match resp_body {
1484            Some((rb, t)) => (Some(rb), t),
1485            None => (None, false),
1486        };
1487        let entry = CaseCapture {
1488            label: label.to_string(),
1489            method: method.to_string(),
1490            url: build_query_url(url, &query),
1491            request_headers: capture_headers,
1492            request_body,
1493            request_body_truncated,
1494            response_status: actual_status,
1495            response_headers: resp_headers,
1496            response_body,
1497            response_body_truncated,
1498            error,
1499            // Filled in by the per-operation validation pass after
1500            // every probe finishes; the capture itself is unaware of
1501            // the schema map.
1502            response_schema_error: None,
1503            // Round 28 — derive the expected range from the probe's
1504            // `expected_4xx` flag so the JSONL line and HTML viewer
1505            // can show mismatches without re-deriving on the read side.
1506            expected_status_range: if expected_4xx {
1507                "4xx".into()
1508            } else {
1509                "2xx-3xx".into()
1510            },
1511            // Round 33 (#823) — path_template carries the spec's
1512            // pre-substitution path so the per-endpoint summary can
1513            // collapse `/users/X` and `/users/Y` into one row.
1514            // spec_label is constant per run, read from the config.
1515            path_template: path_template.to_string(),
1516            spec_label: config.spec_label.clone(),
1517        };
1518        if let Ok(mut guard) = sink.lock() {
1519            guard.push(entry);
1520        }
1521    }
1522    CaseOutcome {
1523        label: label.to_string(),
1524        expected_4xx,
1525        actual_status,
1526        passed,
1527    }
1528}
1529
1530// HTTP request shape needs all of these: client, config (for capture
1531// sink + extra headers), method, url, label (probe id), expected_4xx
1532// (pass/fail decision), body, query, headers. A struct wrapper would
1533// just move the arity from positional to field access without making
1534// the call sites clearer.
1535#[allow(clippy::too_many_arguments)]
1536async fn send_case(
1537    client: &Client,
1538    config: &SelfTestConfig,
1539    method: Method,
1540    url: &str,
1541    label: &str,
1542    expected_4xx: bool,
1543    body: Option<&str>,
1544    query: Vec<(String, String)>,
1545    headers: Vec<(String, String)>,
1546    path_template: &str,
1547) -> CaseOutcome {
1548    // Forwarding to `send_case_with_extra` keeps the capture logic in
1549    // one place so request/response tracing can't drift between the
1550    // two entrypoints.
1551    send_case_with_extra(
1552        client,
1553        config,
1554        method,
1555        url,
1556        label,
1557        expected_4xx,
1558        body,
1559        query,
1560        headers,
1561        config.extra_headers.clone(),
1562        path_template,
1563    )
1564    .await
1565}
1566
1567/// Round 23 (c-iii) — rebuild the query-stringified URL for capture so
1568/// the JSONL trace shows the URL that actually went over the wire
1569/// (reqwest applies `.query(..)` after the request URL string is
1570/// rendered, so capturing the raw `url` argument alone loses the
1571/// query params).
1572fn build_query_url(base: &str, query: &[(String, String)]) -> String {
1573    if query.is_empty() {
1574        return base.to_string();
1575    }
1576    let qs: String = query
1577        .iter()
1578        .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
1579        .collect::<Vec<_>>()
1580        .join("&");
1581    if base.contains('?') {
1582        format!("{base}&{qs}")
1583    } else {
1584        format!("{base}?{qs}")
1585    }
1586}
1587
1588/// Substitute `{param}` placeholders in the spec path with their
1589/// sample values from `path_params`, then prepend `target_url`. Empty
1590/// values are kept as `{param}` so an upstream router still matches
1591/// the template — useful when `path_params` is empty and we want to
1592/// hit the same route the spec defines.
1593///
1594/// All current call sites went through `build_url_with_base` after
1595/// round 18.1, so this no-base-path helper is unused; keep it as the
1596/// documented shim for future external callers (one-arg simplification).
1597#[allow(dead_code)]
1598fn build_url(target: &str, path_template: &str, path_params: &[(String, String)]) -> String {
1599    build_url_with_base(target, None, path_template, path_params)
1600}
1601
1602/// Round 18.1 — variant of `build_url` that takes a `base_path`
1603/// (e.g. `Some("/api")`). When set, prepends it to the spec path so a
1604/// spec declaring `/users` against a target served behind `/api`
1605/// resolves to `<target>/api/users`. `base_path` is normalised: leading
1606/// `/` is auto-added, trailing `/` is stripped.
1607fn build_url_with_base(
1608    target: &str,
1609    base_path: Option<&str>,
1610    path_template: &str,
1611    path_params: &[(String, String)],
1612) -> String {
1613    let mut url = path_template.to_string();
1614    for (name, value) in path_params {
1615        let placeholder = format!("{{{}}}", name);
1616        if !value.is_empty() {
1617            url = url.replace(&placeholder, value);
1618        }
1619    }
1620    let target = target.trim_end_matches('/');
1621    let prefix = match base_path {
1622        Some(bp) if !bp.is_empty() => {
1623            let trimmed = bp.trim_end_matches('/');
1624            if trimmed.starts_with('/') {
1625                trimmed.to_string()
1626            } else {
1627                format!("/{}", trimmed)
1628            }
1629        }
1630        _ => String::new(),
1631    };
1632    let path = if url.starts_with('/') {
1633        url
1634    } else {
1635        format!("/{url}")
1636    };
1637    format!("{target}{prefix}{path}")
1638}
1639
1640#[cfg(test)]
1641mod tests {
1642    use super::*;
1643
1644    fn op(
1645        method: &str,
1646        path: &str,
1647        body: Option<&str>,
1648        query: Vec<(&str, &str)>,
1649        headers: Vec<(&str, &str)>,
1650        path_params: Vec<(&str, &str)>,
1651    ) -> AnnotatedOperation {
1652        AnnotatedOperation {
1653            method: method.into(),
1654            path: path.into(),
1655            features: Vec::new(),
1656            request_body_content_type: body.map(|_| "application/json".into()),
1657            sample_body: body.map(|s| s.to_string()),
1658            query_params: query.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1659            header_params: headers.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1660            path_params: path_params.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1661            response_schema: None,
1662            response_schemas: std::collections::BTreeMap::new(),
1663            request_body_schema: None,
1664            security_schemes: Vec::new(),
1665        }
1666    }
1667
1668    #[test]
1669    fn build_url_substitutes_path_params() {
1670        let url = build_url(
1671            "https://api.test/",
1672            "/users/{id}/posts/{pid}",
1673            &[("id".into(), "42".into()), ("pid".into(), "7".into())],
1674        );
1675        assert_eq!(url, "https://api.test/users/42/posts/7");
1676    }
1677
1678    /// Round 18.1 — a run where every positive 404s should be flagged
1679    /// as a likely target misconfiguration, not silently treated as a
1680    /// successful conformance run.
1681    #[test]
1682    fn detect_target_misconfiguration_when_all_positives_share_status() {
1683        let mut report = SelfTestReport {
1684            positive_pass: 0,
1685            positive_fail: 50,
1686            ..Default::default()
1687        };
1688        for i in 0..50 {
1689            report.operations.push(OperationResult {
1690                method: "GET".into(),
1691                path: format!("/r/{i}"),
1692                positive: Some(CaseOutcome {
1693                    label: "positive".into(),
1694                    expected_4xx: false,
1695                    actual_status: 404,
1696                    passed: false,
1697                }),
1698                negatives: Vec::new(),
1699            });
1700        }
1701        assert_eq!(report.detect_target_misconfiguration(), Some(404));
1702    }
1703
1704    #[test]
1705    fn detect_target_misconfiguration_returns_none_when_some_pass() {
1706        let mut report = SelfTestReport {
1707            positive_pass: 5,
1708            positive_fail: 50,
1709            ..Default::default()
1710        };
1711        for i in 0..55 {
1712            report.operations.push(OperationResult {
1713                method: "GET".into(),
1714                path: format!("/r/{i}"),
1715                positive: Some(CaseOutcome {
1716                    label: "positive".into(),
1717                    expected_4xx: false,
1718                    actual_status: if i < 5 { 200 } else { 404 },
1719                    passed: i < 5,
1720                }),
1721                negatives: Vec::new(),
1722            });
1723        }
1724        assert_eq!(report.detect_target_misconfiguration(), None);
1725    }
1726
1727    /// Round 18.1 — `--base-path /api` should prepend `/api` to
1728    /// every spec path. Pre-fix, the self-test ignored base_path and
1729    /// 404'd every positive when the deployed API was behind a path
1730    /// prefix.
1731    #[test]
1732    fn build_url_applies_base_path_when_present() {
1733        let url = build_url_with_base(
1734            "https://api.example.com",
1735            Some("/api"),
1736            "/users/{id}",
1737            &[("id".into(), "42".into())],
1738        );
1739        assert_eq!(url, "https://api.example.com/api/users/42");
1740    }
1741
1742    /// Round 18.1 — base_path is normalised: missing leading slash
1743    /// gets one added, trailing slash is stripped, empty string is
1744    /// the same as None.
1745    #[test]
1746    fn build_url_normalises_base_path() {
1747        let no_slash = build_url_with_base("https://t", Some("api"), "/x", &[]);
1748        assert_eq!(no_slash, "https://t/api/x");
1749        let trailing = build_url_with_base("https://t", Some("/api/"), "/x", &[]);
1750        assert_eq!(trailing, "https://t/api/x");
1751        let empty = build_url_with_base("https://t", Some(""), "/x", &[]);
1752        assert_eq!(empty, "https://t/x");
1753        let none = build_url_with_base("https://t", None, "/x", &[]);
1754        assert_eq!(none, "https://t/x");
1755    }
1756
1757    #[test]
1758    fn build_url_keeps_placeholders_when_no_sample() {
1759        let url = build_url("https://api.test", "/users/{id}", &[]);
1760        assert_eq!(url, "https://api.test/users/{id}");
1761    }
1762
1763    #[test]
1764    fn report_summary_calls_out_misses() {
1765        let r = SelfTestReport {
1766            positive_pass: 3,
1767            positive_fail: 0,
1768            negative_caught: BTreeMap::from([("request-body".into(), 2)]),
1769            negative_missed: BTreeMap::from([("request-body".into(), 1)]),
1770            operations: Vec::new(),
1771        };
1772        let summary = r.render_summary();
1773        assert!(summary.contains("Positives: 3 pass / 0 fail"));
1774        assert!(summary.contains("Negatives [request-body]: 2 caught / 1 missed"));
1775        assert!(summary.contains("⚠"));
1776        assert!(!r.all_passed());
1777    }
1778
1779    #[test]
1780    fn report_all_passed_when_no_miss() {
1781        let r = SelfTestReport {
1782            positive_pass: 5,
1783            positive_fail: 0,
1784            negative_caught: BTreeMap::from([("parameters".into(), 3)]),
1785            negative_missed: BTreeMap::new(),
1786            operations: Vec::new(),
1787        };
1788        assert!(r.all_passed());
1789        assert!(r.render_summary().contains("✓"));
1790    }
1791
1792    #[tokio::test]
1793    async fn run_self_test_against_unreachable_target_marks_all_failed() {
1794        // Use an obviously-dead port so we exercise the timeout/error
1795        // path without needing a live server in tests.
1796        let cfg = SelfTestConfig {
1797            target_url: "http://127.0.0.1:1".into(),
1798            timeout: Duration::from_millis(200),
1799            ..Default::default()
1800        };
1801        let ops = vec![op(
1802            "POST",
1803            "/users",
1804            Some("{\"name\":\"a\"}"),
1805            vec![],
1806            vec![],
1807            vec![],
1808        )];
1809        let report = run_self_test(&ops, &cfg).await.expect("client builds");
1810        // All cases hit the connect-error path → actual_status=0.
1811        // Positive expects 2xx-3xx → 0 is fail. Negatives expect 4xx
1812        // → 0 is also fail (we missed catching).
1813        assert_eq!(report.positive_fail, 1);
1814        assert!(report.negative_missed.values().sum::<usize>() >= 1);
1815        assert!(!report.all_passed());
1816    }
1817
1818    /// Round 17.2 — operations with both a positive sample AND a
1819    /// resolved request-body schema produce schema-driven negatives
1820    /// in addition to the spec-agnostic empty/wrong-type ones. The
1821    /// labels carry the field path so a per-category report can tell
1822    /// you exactly which field caught.
1823    #[tokio::test]
1824    async fn schema_driven_negatives_fire_when_schema_present() {
1825        use openapiv3::{ObjectType, ReferenceOr, Schema, SchemaData, SchemaKind, Type};
1826        let cfg = SelfTestConfig {
1827            target_url: "http://127.0.0.1:1".into(),
1828            timeout: Duration::from_millis(200),
1829            ..Default::default()
1830        };
1831        // Build an operation whose schema has a required `name` string
1832        // and an `age` integer. The mutator should produce, at
1833        // minimum: required-removed:name, required-removed:age,
1834        // type-mismatch:name, type-mismatch:age, integer-as-float:age,
1835        // plus the root-level type-mismatch.
1836        let mut obj = ObjectType::default();
1837        obj.properties.insert(
1838            "name".to_string(),
1839            ReferenceOr::Item(Box::new(Schema {
1840                schema_data: SchemaData::default(),
1841                schema_kind: SchemaKind::Type(Type::String(Default::default())),
1842            })),
1843        );
1844        obj.properties.insert(
1845            "age".to_string(),
1846            ReferenceOr::Item(Box::new(Schema {
1847                schema_data: SchemaData::default(),
1848                schema_kind: SchemaKind::Type(Type::Integer(Default::default())),
1849            })),
1850        );
1851        obj.required = vec!["name".into(), "age".into()];
1852        let schema = Schema {
1853            schema_data: SchemaData::default(),
1854            schema_kind: SchemaKind::Type(Type::Object(obj)),
1855        };
1856
1857        let mut o =
1858            op("POST", "/users", Some(r#"{"name":"Ada","age":30}"#), vec![], vec![], vec![]);
1859        o.request_body_schema = Some(schema);
1860        let report = run_self_test(&[o], &cfg).await.expect("client builds");
1861        // Bucket labels from the operation result.
1862        let labels: std::collections::BTreeSet<String> = report
1863            .operations
1864            .iter()
1865            .flat_map(|op| op.negatives.iter().map(|n| n.label.clone()))
1866            .collect();
1867        assert!(
1868            labels.iter().any(|l| l.starts_with("request-body:type-mismatch:")),
1869            "missing type-mismatch negative; got {labels:?}"
1870        );
1871        assert!(
1872            labels.iter().any(|l| l.starts_with("request-body:required-removed:")),
1873            "missing required-removed negative; got {labels:?}"
1874        );
1875        assert!(
1876            labels.iter().any(|l| l == "parameters:uri-too-long"),
1877            "missing URI-length negative; got {labels:?}"
1878        );
1879    }
1880
1881    /// Round 16 — operations with a body OR a path-param now produce
1882    /// negatives even without a sample body. Previously a POST whose
1883    /// body annotator failed produced *zero* negatives, so the self-test
1884    /// always reported "all passing" for that endpoint.
1885    #[tokio::test]
1886    async fn no_sample_body_still_produces_request_body_negatives() {
1887        let cfg = SelfTestConfig {
1888            target_url: "http://127.0.0.1:1".into(),
1889            timeout: Duration::from_millis(200),
1890            ..Default::default()
1891        };
1892        // POST with a body content type but no sample (annotator gap).
1893        let ops = vec![op("POST", "/x", None, vec![], vec![], vec![])];
1894        // No sample_body but request_body_content_type set:
1895        let mut ops_fixed = ops;
1896        ops_fixed[0].request_body_content_type = Some("application/json".into());
1897        let report = run_self_test(&ops_fixed, &cfg).await.expect("client builds");
1898        // Both request-body negatives (empty + wrong-type) should fire,
1899        // landing in `negative_missed` because the unreachable target
1900        // returns no 4xx. The point: count > 0.
1901        assert!(
1902            report.negative_missed.values().sum::<usize>() >= 2,
1903            "expected ≥2 request-body negatives, got {:?}",
1904            report.negative_missed
1905        );
1906    }
1907
1908    /// Round 16 — operations with a path-param now get a probe even
1909    /// when there's no body / required query / required header.
1910    /// Previously `/teams/{team-id}` with no other required fields
1911    /// produced zero negatives → always "all passing".
1912    #[tokio::test]
1913    async fn path_param_only_endpoint_produces_a_probe() {
1914        let cfg = SelfTestConfig {
1915            target_url: "http://127.0.0.1:1".into(),
1916            timeout: Duration::from_millis(200),
1917            ..Default::default()
1918        };
1919        let ops = vec![op(
1920            "GET",
1921            "/teams/{team-id}",
1922            None,
1923            vec![],
1924            vec![],
1925            vec![("team-id", "1")],
1926        )];
1927        let report = run_self_test(&ops, &cfg).await.expect("client builds");
1928        let total: usize = report.negative_caught.values().sum::<usize>()
1929            + report.negative_missed.values().sum::<usize>();
1930        assert!(total >= 1, "expected ≥1 path-param probe, got {:?}", report);
1931    }
1932
1933    /// Round 18.5 — when `geo_ip` is set, every default forwarded-
1934    /// IP header gets the IP appended (X-Forwarded-For,
1935    /// True-Client-IP, CF-Connecting-IP).
1936    #[test]
1937    fn effective_op_headers_appends_geo_ip_to_default_headers() {
1938        let ip: IpAddr = "203.0.113.42".parse().unwrap();
1939        let headers = effective_op_headers(
1940            &[("Accept".into(), "application/json".into())],
1941            Some(ip),
1942            &default_geo_source_headers(),
1943        );
1944        let names: Vec<&str> = headers.iter().map(|(k, _)| k.as_str()).collect();
1945        assert!(names.contains(&"Accept"));
1946        assert!(names.contains(&"X-Forwarded-For"));
1947        assert!(names.contains(&"True-Client-IP"));
1948        assert!(names.contains(&"CF-Connecting-IP"));
1949        // Every geo header carries the same IP value.
1950        let geo_values: Vec<&str> =
1951            headers.iter().filter(|(k, _)| k != "Accept").map(|(_, v)| v.as_str()).collect();
1952        for v in geo_values {
1953            assert_eq!(v, "203.0.113.42");
1954        }
1955    }
1956
1957    /// Round 18.5 — operations that already declare a forwarded-IP
1958    /// header (rare but legal — some specs hard-code one) keep their
1959    /// declared value; we don't clobber the spec.
1960    #[test]
1961    fn effective_op_headers_respects_spec_declared_header() {
1962        let ip: IpAddr = "203.0.113.99".parse().unwrap();
1963        let headers = effective_op_headers(
1964            &[("x-forwarded-for".into(), "10.0.0.1".into())],
1965            Some(ip),
1966            &["X-Forwarded-For".to_string()],
1967        );
1968        // The spec's lower-case value wins; we shouldn't add a
1969        // second X-Forwarded-For row that overrides it.
1970        let xff: Vec<&str> = headers
1971            .iter()
1972            .filter(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-for"))
1973            .map(|(_, v)| v.as_str())
1974            .collect();
1975        assert_eq!(xff, vec!["10.0.0.1"]);
1976    }
1977
1978    /// Round 18.5 — None geo_ip and/or empty header list is a no-op.
1979    #[test]
1980    fn effective_op_headers_is_a_noop_without_geo_ip() {
1981        let base = vec![("Accept".into(), "json".into())];
1982        let h1 = effective_op_headers(&base, None, &default_geo_source_headers());
1983        assert_eq!(h1, base);
1984        let ip: IpAddr = "10.0.0.1".parse().unwrap();
1985        let h2 = effective_op_headers(&base, Some(ip), &[]);
1986        assert_eq!(h2, base);
1987    }
1988
1989    /// Round 18.5 — empty `source_ips` builds a single default
1990    /// client; a non-empty list builds N clients each attempting to
1991    /// bind. We can't reliably test the actual bind on CI (no
1992    /// loopback aliases), but a loopback IP is always bind-able.
1993    #[test]
1994    fn build_client_pool_one_per_source_ip() {
1995        let mut cfg = SelfTestConfig {
1996            target_url: "http://127.0.0.1:1".into(),
1997            timeout: Duration::from_millis(200),
1998            ..Default::default()
1999        };
2000        // Empty → one default client.
2001        assert_eq!(build_client_pool(&cfg).expect("default builds").len(), 1);
2002        // Non-empty → one per IP. Loopback bind is portable.
2003        cfg.source_ips = vec!["127.0.0.1".parse().unwrap()];
2004        assert_eq!(build_client_pool(&cfg).expect("bind loopback").len(), 1);
2005    }
2006
2007    /// Round 18.5 — geo IPs round-robin across operations. Hits an
2008    /// unreachable target so we can inspect the case outcomes; the
2009    /// point is to confirm `op_headers` carried the geo IP through
2010    /// (CaseOutcome doesn't surface headers directly, so we just
2011    /// verify the run completes without panicking and the result
2012    /// shape is correct when source_ips is non-empty too).
2013    #[tokio::test]
2014    async fn run_self_test_with_geo_source_completes() {
2015        let cfg = SelfTestConfig {
2016            target_url: "http://127.0.0.1:1".into(),
2017            timeout: Duration::from_millis(200),
2018            geo_source_ips: vec![
2019                "203.0.113.1".parse().unwrap(),
2020                "203.0.113.2".parse().unwrap(),
2021            ],
2022            ..Default::default()
2023        };
2024        let ops = vec![
2025            op("GET", "/a", None, vec![], vec![], vec![]),
2026            op("GET", "/b", None, vec![], vec![], vec![]),
2027            op("GET", "/c", None, vec![], vec![], vec![]),
2028        ];
2029        let report = run_self_test(&ops, &cfg).await.expect("client builds");
2030        assert_eq!(report.operations.len(), 3);
2031    }
2032
2033    /// Round 24 (f) — Srikanth saw the geo header on positive probes
2034    /// only; the four negative-probe call sites were passing
2035    /// `op.header_params` directly instead of `op_headers`, so the
2036    /// geo IP got dropped. This test runs a self-test that includes
2037    /// negative probes (uri-too-long, missing-query, etc.) under
2038    /// `--conformance-self-test-capture`, then asserts that EVERY
2039    /// captured probe (positive AND negative) carries one of the
2040    /// configured forwarded-IP headers.
2041    #[tokio::test]
2042    async fn geo_headers_present_on_every_probe_with_capture() {
2043        let sink: Arc<Mutex<Vec<CaseCapture>>> = Arc::new(Mutex::new(Vec::new()));
2044        let cfg = SelfTestConfig {
2045            target_url: "http://127.0.0.1:1".into(),
2046            timeout: Duration::from_millis(50),
2047            geo_source_ips: vec!["203.0.113.5".parse().unwrap()],
2048            capture: Some(sink.clone()),
2049            ..Default::default()
2050        };
2051        // An operation rich enough to trip several negative-probe
2052        // branches: header param (→ missing-header), query param
2053        // (→ missing-query), and a sample body (→ schema mutations
2054        // wouldn't fire without a schema, but uri-too-long always
2055        // does).
2056        let ops = vec![op(
2057            "GET",
2058            "/items",
2059            Some("{}"),
2060            vec![("id", "1")],
2061            vec![("X-Trace", "x")],
2062            vec![],
2063        )];
2064        let _ = run_self_test(&ops, &cfg).await.expect("client builds");
2065        let captures = sink.lock().unwrap();
2066        assert!(!captures.is_empty(), "self-test should record probes");
2067        // For every captured probe, at least one of the default geo
2068        // headers must be present and equal to the configured IP.
2069        let geo_headers: std::collections::HashSet<&str> =
2070            ["X-Forwarded-For", "True-Client-IP", "CF-Connecting-IP"].into_iter().collect();
2071        for c in captures.iter() {
2072            let has_geo = c
2073                .request_headers
2074                .iter()
2075                .any(|(k, v)| geo_headers.contains(k.as_str()) && v == "203.0.113.5");
2076            assert!(
2077                has_geo,
2078                "probe `{}` is missing the geo IP header; got headers: {:?}",
2079                c.label, c.request_headers
2080            );
2081        }
2082    }
2083
2084    /// Round 25 (k) — operations with a JSON request body now get four
2085    /// content-type-swap probes (xml / yaml / multipart / urlencoded).
2086    /// Verify they:
2087    ///   1. fire only when the operation declares a JSON body
2088    ///   2. carry the wrong Content-Type the probe is testing for
2089    ///   3. don't fire on body-less operations
2090    #[tokio::test]
2091    async fn content_type_swap_probes_fire_for_json_bodies() {
2092        let sink: Arc<Mutex<Vec<CaseCapture>>> = Arc::new(Mutex::new(Vec::new()));
2093        let cfg = SelfTestConfig {
2094            target_url: "http://127.0.0.1:1".into(),
2095            timeout: Duration::from_millis(50),
2096            capture: Some(sink.clone()),
2097            ..Default::default()
2098        };
2099        let ops = vec![
2100            op("POST", "/users", Some("{\"name\":\"a\"}"), vec![], vec![], vec![]),
2101            op("GET", "/ping", None, vec![], vec![], vec![]),
2102        ];
2103        let _ = run_self_test(&ops, &cfg).await.expect("client builds");
2104        let captures = sink.lock().unwrap();
2105
2106        let swap_labels: Vec<&str> = captures
2107            .iter()
2108            .filter(|c| c.label.starts_with("request-body:content-type-mismatch:"))
2109            .map(|c| c.label.as_str())
2110            .collect();
2111        assert_eq!(
2112            swap_labels.len(),
2113            4,
2114            "expected 4 content-type-swap probes (one per variant), got: {swap_labels:?}"
2115        );
2116        let expected_labels = [
2117            "request-body:content-type-mismatch:xml",
2118            "request-body:content-type-mismatch:yaml",
2119            "request-body:content-type-mismatch:multipart",
2120            "request-body:content-type-mismatch:urlencoded",
2121        ];
2122        for want in expected_labels {
2123            assert!(swap_labels.contains(&want), "missing swap probe `{want}`");
2124        }
2125
2126        // Each swap probe must carry the wrong Content-Type it's
2127        // testing for — that's the whole point.
2128        for c in captures.iter() {
2129            let Some(suffix) = c.label.strip_prefix("request-body:content-type-mismatch:") else {
2130                continue;
2131            };
2132            let want_ct = match suffix {
2133                "xml" => "application/xml",
2134                "yaml" => "application/yaml",
2135                "multipart" => "multipart/form-data",
2136                "urlencoded" => "application/x-www-form-urlencoded",
2137                _ => continue,
2138            };
2139            let got_ct = c
2140                .request_headers
2141                .iter()
2142                .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2143                .map(|(_, v)| v.as_str())
2144                .unwrap_or("");
2145            assert_eq!(got_ct, want_ct, "swap probe `{}` sent wrong CT", c.label);
2146        }
2147
2148        // The body-less operation must NOT produce content-type-swap
2149        // probes (no body → no content type to lie about).
2150        let body_less_swaps = captures
2151            .iter()
2152            .filter(|c| {
2153                c.label.starts_with("request-body:content-type-mismatch:")
2154                    && c.url.ends_with("/ping")
2155            })
2156            .count();
2157        assert_eq!(
2158            body_less_swaps, 0,
2159            "GET /ping has no request body; should not produce content-type-swap probes"
2160        );
2161    }
2162
2163    /// Round 27 (k variant b) — Srikanth's round-23 follow-up on (k):
2164    /// JSON envelope with embedded non-JSON field values. For each
2165    /// JSON-body operation, four extra probes fire that send valid
2166    /// JSON with an XML/YAML/multipart/urlencoded snippet stuffed
2167    /// into a string field. Content-Type stays `application/json`;
2168    /// expected is 2xx-3xx (the body parses); a 5xx flags a server
2169    /// that crashed on the embedded content.
2170    #[tokio::test]
2171    async fn embedded_content_probes_fire_with_honest_content_type() {
2172        let sink: Arc<Mutex<Vec<CaseCapture>>> = Arc::new(Mutex::new(Vec::new()));
2173        let cfg = SelfTestConfig {
2174            target_url: "http://127.0.0.1:1".into(),
2175            timeout: Duration::from_millis(50),
2176            capture: Some(sink.clone()),
2177            ..Default::default()
2178        };
2179        let ops = vec![op(
2180            "POST",
2181            "/users",
2182            Some("{\"name\":\"alice\",\"age\":30}"),
2183            vec![],
2184            vec![],
2185            vec![],
2186        )];
2187        let _ = run_self_test(&ops, &cfg).await.expect("client builds");
2188        let captures = sink.lock().unwrap();
2189        let embedded: Vec<&CaseCapture> = captures
2190            .iter()
2191            .filter(|c| c.label.starts_with("request-body:embedded-content:"))
2192            .collect();
2193        assert_eq!(
2194            embedded.len(),
2195            4,
2196            "expected 4 embedded-content probes, got: {:?}",
2197            embedded.iter().map(|c| &c.label).collect::<Vec<_>>()
2198        );
2199        // Every embedded probe must carry the honest application/json
2200        // Content-Type (NOT lie like the variant-a content-type-swap
2201        // probes do) and a request body that still parses as JSON.
2202        for c in &embedded {
2203            let ct = c
2204                .request_headers
2205                .iter()
2206                .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2207                .map(|(_, v)| v.as_str())
2208                .unwrap_or("");
2209            assert!(
2210                ct.contains("application/json"),
2211                "embedded probe `{}` should keep Content-Type honest, got {ct}",
2212                c.label
2213            );
2214            let body = c.request_body.as_deref().unwrap_or("");
2215            assert!(
2216                serde_json::from_str::<serde_json::Value>(body).is_ok(),
2217                "embedded probe `{}` body should still be valid JSON, got: {body}",
2218                c.label
2219            );
2220        }
2221    }
2222
2223    /// `embed_payload_in_first_string_field` walks objects depth-first
2224    /// and replaces only the FIRST string-valued leaf, leaving the
2225    /// surrounding structure intact.
2226    #[test]
2227    fn embed_payload_replaces_first_string_only() {
2228        let sample = r#"{"name":"alice","age":30,"tags":["admin","user"]}"#;
2229        let mutated = embed_payload_in_first_string_field(sample, "<x/>");
2230        let v: serde_json::Value = serde_json::from_str(&mutated).unwrap();
2231        assert_eq!(v["name"], serde_json::json!("<x/>"));
2232        // age stays an integer (not stringified by the mutation).
2233        assert_eq!(v["age"], serde_json::json!(30));
2234        // tags array's strings stay untouched (we only replace the
2235        // first encountered string leaf, depth-first).
2236        assert_eq!(v["tags"][0], serde_json::json!("admin"));
2237        assert_eq!(v["tags"][1], serde_json::json!("user"));
2238    }
2239
2240    /// When the sample has NO string field, the helper falls back to
2241    /// `{"data": "<snippet>"}` so the probe still has something to
2242    /// POST. The fallback must produce valid JSON regardless of what
2243    /// characters the snippet contains.
2244    #[test]
2245    fn embed_payload_falls_back_when_no_string_field() {
2246        let no_strings = r#"{"a":1,"b":[2,3]}"#;
2247        let mutated = embed_payload_in_first_string_field(no_strings, "<x><y></y></x>");
2248        let v: serde_json::Value = serde_json::from_str(&mutated).unwrap();
2249        assert_eq!(v["data"], serde_json::json!("<x><y></y></x>"));
2250    }
2251
2252    #[test]
2253    fn embed_payload_handles_invalid_json_sample() {
2254        let not_json = "garbage";
2255        let mutated = embed_payload_in_first_string_field(not_json, "a=1&b=2");
2256        let v: serde_json::Value = serde_json::from_str(&mutated).unwrap();
2257        assert_eq!(v["data"], serde_json::json!("a=1&b=2"));
2258    }
2259
2260    /// Round 26 — Srikanth saw `at /: Type { kind: Single` in his
2261    /// 0.3.169 capture for the vCenter `infraprofile/configs` 202
2262    /// response (spec promised `type: string`, server returned a
2263    /// JSON object). The output was a broken-syntax debug string.
2264    /// This test reproduces his exact spec+body and asserts the
2265    /// message is readable.
2266    #[test]
2267    fn response_schema_error_message_is_readable() {
2268        let schema = serde_json::json!({"type": "string"});
2269        let body = r#"{"data":{},"id":"generated_id","status":"created"}"#;
2270        let err = validate_body_against_schema(body, &schema).expect("type-mismatch fires");
2271        // The message must NOT contain Rust debug syntax leftovers
2272        // ("Type { kind:", trailing "{" or "(" tokens). It SHOULD say
2273        // what type was expected.
2274        assert!(!err.contains("Type { kind"), "stale debug output: {err}");
2275        assert!(!err.contains("{ kind:"), "stale debug output: {err}");
2276        assert!(err.contains("string"), "should name expected type: {err}");
2277        // Round 29 — Srikanth on 0.3.172 was confused by `at /:`,
2278        // thinking it pointed to the URL path. The new format
2279        // explicitly says "response body root" for the root case
2280        // (and "response body at /<pointer>" for nested fields).
2281        assert!(
2282            err.contains("response body root"),
2283            "should label root explicitly so reader knows it's not the URL: {err}"
2284        );
2285        // Round 28 — Srikanth wanted the expected schema embedded
2286        // in the message so it reads as 'expected schema {"type":"string"}'.
2287        assert!(
2288            err.contains("expected schema") && err.contains("\"type\":\"string\""),
2289            "should include expected schema JSON: {err}"
2290        );
2291    }
2292
2293    /// Round 29 — for non-root paths the format reads
2294    /// "response body at /name: ...". Catches the case where the
2295    /// root rewording accidentally dropped the JSON-pointer for
2296    /// nested fields.
2297    #[test]
2298    fn response_schema_error_uses_response_body_prefix_for_nested_paths() {
2299        let schema = serde_json::json!({
2300            "type": "object",
2301            "required": ["name"],
2302            "properties": {"name": {"type": "string"}}
2303        });
2304        let body = r#"{"name": 123}"#;
2305        let err = validate_body_against_schema(body, &schema).expect("type-mismatch fires");
2306        assert!(
2307            err.contains("response body at /name"),
2308            "nested path should read 'response body at /name': {err}"
2309        );
2310        assert!(!err.contains("response body root"), "wrong label for nested: {err}");
2311        // Round 30 — the "expected schema" suffix should be the
2312        // sub-schema at /name, not the entire object schema. Reader
2313        // shouldn't have to scan a 300-char object to find the
2314        // constraint that failed.
2315        assert!(
2316            err.contains(r#"expected schema {"type":"string"}"#),
2317            "should show only the /name sub-schema, not the full object: {err}"
2318        );
2319    }
2320
2321    /// Round 30 — Srikanth asked how a deeper nested mismatch reads.
2322    /// Schema: `name.type` should be a string; body has it as a number.
2323    /// JSON pointer is `/name/type`.
2324    #[test]
2325    fn response_schema_error_uses_response_body_prefix_for_deep_nested_paths() {
2326        let schema = serde_json::json!({
2327            "type": "object",
2328            "properties": {
2329                "name": {
2330                    "type": "object",
2331                    "properties": {"type": {"type": "string"}}
2332                }
2333            }
2334        });
2335        let body = r#"{"name": {"type": 123}}"#;
2336        let err = validate_body_against_schema(body, &schema).expect("type-mismatch fires");
2337        assert!(
2338            err.contains("response body at /name/type"),
2339            "deep nested path should read 'response body at /name/type': {err}"
2340        );
2341        // Round 30 — for deep paths the sub-schema is the leaf
2342        // {"type":"string"}, not the wrapping object schemas.
2343        assert!(
2344            err.contains(r#"expected schema {"type":"string"}"#),
2345            "should show only the /name/type leaf sub-schema: {err}"
2346        );
2347    }
2348
2349    /// Round 30 — when the instance pointer can't be resolved through
2350    /// the schema's `properties` chain (e.g. additionalProperties hit),
2351    /// `sub_schema_at_pointer` returns None and the message falls back
2352    /// to the full schema. Verifies the fallback path is wired.
2353    #[test]
2354    fn sub_schema_at_pointer_falls_back_for_unresolvable_paths() {
2355        let schema = serde_json::json!({"type":"object","additionalProperties":true});
2356        // Walker can't resolve /unknown, so we get the full schema back.
2357        assert_eq!(
2358            sub_schema_at_pointer(&schema, "/unknown"),
2359            None,
2360            "unresolvable path should return None to trigger fallback"
2361        );
2362        // Root path returns the whole schema.
2363        assert_eq!(sub_schema_at_pointer(&schema, "/"), Some(schema.clone()));
2364        assert_eq!(sub_schema_at_pointer(&schema, ""), Some(schema));
2365    }
2366
2367    #[test]
2368    fn response_schema_error_required_field_is_readable() {
2369        let schema = serde_json::json!({
2370            "type": "object",
2371            "required": ["id"],
2372            "properties": {"id": {"type": "integer"}}
2373        });
2374        let body = r#"{"other": 1}"#;
2375        let err = validate_body_against_schema(body, &schema).expect("required-missing fires");
2376        assert!(err.contains("required field missing"), "{err}");
2377        assert!(err.contains("id"), "{err}");
2378    }
2379
2380    /// Round 31 — Srikanth's vCenter case on 0.3.174: the
2381    /// `Appliance.Recovery.Backup.SystemName.Archive.Info` schema has
2382    /// a multi-paragraph description and ~6 required fields, of which
2383    /// `comment` was missing in the response. Before this fix the
2384    /// printed schema was the WHOLE parent object schema (parent's
2385    /// description bleeding in, all sibling property schemas dumped)
2386    /// truncated to 300 chars; after the fix it's the missing field's
2387    /// own schema. Verifies (a) parent description is gone and
2388    /// (b) sibling property names don't appear in the message.
2389    #[test]
2390    fn response_schema_error_required_focuses_on_missing_field_only() {
2391        let schema = serde_json::json!({
2392            "description": "The Appliance.Recovery.Backup.SystemName.Archive.Info schema represents backup archive information.\n\nThis schema was added in vSphere API 6.7.",
2393            "type": "object",
2394            "required": ["comment", "location", "parts", "system_name", "timestamp", "version"],
2395            "properties": {
2396                "comment": {
2397                    "type": "string",
2398                    "description": "Custom comment added by the user for this backup."
2399                },
2400                "location": {"type": "string", "description": "Backup location URL."},
2401                "parts": {"type": "array", "items": {"type": "string"}},
2402                "system_name": {"type": "string"},
2403                "timestamp": {"type": "string", "format": "date-time"},
2404                "version": {"type": "string"}
2405            }
2406        });
2407        let body = r#"{"location":"x","parts":[],"system_name":"y","timestamp":"z","version":"v"}"#;
2408        let err = validate_body_against_schema(body, &schema).expect("required-missing fires");
2409        assert!(err.contains("required field missing: \"comment\""), "{err}");
2410        // Parent's description should not appear; only the `comment`
2411        // field's own description (if any) may.
2412        assert!(
2413            !err.contains("Appliance.Recovery.Backup"),
2414            "parent description should not bleed into focused schema: {err}"
2415        );
2416        // No sibling property names should appear in the focused schema
2417        // suffix.
2418        for sibling in ["location", "parts", "system_name", "timestamp", "version"] {
2419            assert!(
2420                !err.contains(&format!("\"{sibling}\"")),
2421                "sibling field {sibling} should not appear in focused schema: {err}"
2422            );
2423        }
2424    }
2425
2426    #[test]
2427    fn response_schema_error_none_on_match() {
2428        let schema = serde_json::json!({"type": "string"});
2429        assert_eq!(validate_body_against_schema("\"hello\"", &schema), None);
2430    }
2431
2432    #[test]
2433    fn json_serialises_report() {
2434        let r = SelfTestReport {
2435            positive_pass: 1,
2436            positive_fail: 0,
2437            negative_caught: BTreeMap::new(),
2438            negative_missed: BTreeMap::new(),
2439            operations: vec![OperationResult {
2440                method: "GET".into(),
2441                path: "/x".into(),
2442                positive: Some(CaseOutcome {
2443                    label: "positive".into(),
2444                    expected_4xx: false,
2445                    actual_status: 200,
2446                    passed: true,
2447                }),
2448                negatives: Vec::new(),
2449            }],
2450        };
2451        let json = serde_json::to_value(&r).expect("serialises");
2452        assert_eq!(json["positive_pass"], serde_json::json!(1));
2453        assert_eq!(json["operations"][0]["positive"]["actual_status"], serde_json::json!(200));
2454    }
2455}