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/// Configuration for a self-test run.
61#[derive(Debug, Clone)]
62pub struct SelfTestConfig {
63    pub target_url: String,
64    pub skip_tls_verify: bool,
65    pub timeout: Duration,
66    /// Optional extra headers to attach to every request (e.g. auth).
67    pub extra_headers: Vec<(String, String)>,
68    /// Delay between requests to avoid hammering the server.
69    pub delay_between_requests: Duration,
70    /// Round 18.1 — base path to prepend to every spec path. When the
71    /// spec declares `/users` and the deployed API is served under
72    /// `/api`, `--base-path /api` should make the self-test hit
73    /// `https://target/api/users` instead of `https://target/users`.
74    /// Pre-fix this was ignored entirely and every operation 404'd
75    /// (Srikanth's vCenter run on 0.3.152: 1275 positives, 1275 4xx).
76    pub base_path: Option<String>,
77    /// Round 18.5 — local source IPs to bind outgoing requests to.
78    /// Each IP must already be assigned to an interface on the host.
79    /// Operations round-robin through the resulting client pool.
80    pub source_ips: Vec<IpAddr>,
81    /// Round 18.5 — fake source IPs to advertise via forwarded-IP
82    /// headers (used to exercise GEODB lookup at the destination).
83    /// Rotated per operation.
84    pub geo_source_ips: Vec<IpAddr>,
85    /// Which forwarded-IP header(s) to populate when `geo_source_ips`
86    /// is non-empty. Empty → no-op; default below sets the standard
87    /// three-header set.
88    pub geo_source_headers: Vec<String>,
89    /// Round 23 (c-iii) — when `Some`, every probe captures method, URL,
90    /// request headers/body and response status/headers/body into this
91    /// sink. Caller drains it after `run_self_test` and writes
92    /// `conformance-self-test-requests.jsonl`. None → no capture (zero
93    /// extra allocations on the hot path).
94    pub capture: Option<Arc<Mutex<Vec<CaseCapture>>>>,
95    /// Round 25 — when true, validate every probe's response body
96    /// against the spec's response schema for the actual status
97    /// returned (closes round 21.3 / Srikanth's a2 / a3 ask). The
98    /// validation result lands in `CaseCapture::response_schema_error`
99    /// (None → matched, or no schema for that status). Default false:
100    /// JSON-Schema validation of large response bodies adds wall-clock
101    /// time and the user has to opt in.
102    pub validate_response_schemas: bool,
103}
104
105/// Round 23 (c-iii) — one captured request/response pair, one per
106/// probe (positive or negative). Serialised as a JSON line in
107/// `conformance-self-test-requests.jsonl`. Headers are kept as
108/// `BTreeMap` for stable ordering. Bodies are truncated to
109/// `CAPTURE_BODY_CAP_BYTES`; `*_truncated` flags whether more was
110/// dropped.
111#[derive(Debug, Clone, serde::Serialize)]
112pub struct CaseCapture {
113    pub label: String,
114    pub method: String,
115    pub url: String,
116    pub request_headers: BTreeMap<String, String>,
117    pub request_body: Option<String>,
118    pub request_body_truncated: bool,
119    pub response_status: u16,
120    pub response_headers: BTreeMap<String, String>,
121    pub response_body: Option<String>,
122    pub response_body_truncated: bool,
123    pub error: Option<String>,
124    /// Round 25 — when `validate_response_schemas` is on and the spec
125    /// declares a schema for `response_status`, this carries the
126    /// validation message (or None when the body matched, or no schema
127    /// was declared for that status). Serialised verbatim in the JSONL
128    /// and rendered in the HTML viewer.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub response_schema_error: Option<String>,
131}
132
133impl Default for SelfTestConfig {
134    fn default() -> Self {
135        Self {
136            target_url: "http://localhost:3000".into(),
137            skip_tls_verify: false,
138            timeout: Duration::from_secs(15),
139            extra_headers: Vec::new(),
140            delay_between_requests: Duration::from_millis(0),
141            base_path: None,
142            source_ips: Vec::new(),
143            geo_source_ips: Vec::new(),
144            geo_source_headers: default_geo_source_headers(),
145            capture: None,
146            validate_response_schemas: false,
147        }
148    }
149}
150
151/// Truncate `body` to `CAPTURE_BODY_CAP_BYTES` on a UTF-8 boundary,
152/// returning the trimmed string and whether truncation occurred. Used
153/// for both request and response bodies in the capture sink.
154fn truncate_body_for_capture(body: &str) -> (String, bool) {
155    if body.len() <= CAPTURE_BODY_CAP_BYTES {
156        return (body.to_string(), false);
157    }
158    let mut end = CAPTURE_BODY_CAP_BYTES;
159    while end > 0 && !body.is_char_boundary(end) {
160        end -= 1;
161    }
162    (body[..end].to_string(), true)
163}
164
165/// Default forwarded-IP header set. Covers the three conventions a
166/// real GEODB front-end is likely to read in this order of
167/// preference: Cloudflare (`CF-Connecting-IP`), Akamai/CloudFront
168/// (`True-Client-IP`), then the de-facto standard
169/// `X-Forwarded-For`. Override via `--geo-source-header` to test a
170/// specific stack.
171pub fn default_geo_source_headers() -> Vec<String> {
172    vec![
173        "X-Forwarded-For".to_string(),
174        "True-Client-IP".to_string(),
175        "CF-Connecting-IP".to_string(),
176    ]
177}
178
179/// Outcome of a single test case (positive or negative).
180#[derive(Debug, Clone, serde::Serialize)]
181pub struct CaseOutcome {
182    pub label: String,
183    pub expected_4xx: bool,
184    pub actual_status: u16,
185    /// True when the response status matches expectation
186    /// (positive → 2xx-3xx, negative → 4xx).
187    pub passed: bool,
188}
189
190/// All cases run against one annotated operation.
191#[derive(Debug, Clone, serde::Serialize)]
192pub struct OperationResult {
193    pub method: String,
194    pub path: String,
195    pub positive: Option<CaseOutcome>,
196    pub negatives: Vec<CaseOutcome>,
197}
198
199/// Summary report rolled up across all operations.
200#[derive(Debug, Default, Clone, serde::Serialize)]
201pub struct SelfTestReport {
202    pub positive_pass: usize,
203    pub positive_fail: usize,
204    /// Per category: count of negative cases the server correctly
205    /// rejected with a 4xx (we caught the spec violation).
206    pub negative_caught: BTreeMap<String, usize>,
207    /// Per category: count of negative cases that should have been
208    /// rejected but came back with a non-4xx (validator gap).
209    pub negative_missed: BTreeMap<String, usize>,
210    pub operations: Vec<OperationResult>,
211}
212
213impl SelfTestReport {
214    /// All-pass means every positive case got 2xx-3xx and every
215    /// negative case got 4xx.
216    pub fn all_passed(&self) -> bool {
217        self.positive_fail == 0 && self.negative_missed.values().sum::<usize>() == 0
218    }
219
220    /// Round 18.1 — detect the "self-test target is misconfigured"
221    /// case where every positive failed with the *same* status code.
222    /// The classic example: `--base-path /api` was forgotten so every
223    /// request hits a path the server doesn't know and returns 404.
224    /// Pre-warning, the user saw all-green negative buckets (because
225    /// "missing route" 404s look like "validator rejected") and no
226    /// indication that the run was meaningless. Returns Some(status)
227    /// when ≥10 positives all failed with the same status, else None.
228    pub fn detect_target_misconfiguration(&self) -> Option<u16> {
229        if self.positive_pass > 0 || self.positive_fail < 10 {
230            return None;
231        }
232        let mut seen: Option<u16> = None;
233        for op in &self.operations {
234            let Some(p) = &op.positive else {
235                continue;
236            };
237            if p.passed {
238                return None;
239            }
240            match seen {
241                None => seen = Some(p.actual_status),
242                Some(s) if s != p.actual_status => return None,
243                _ => {}
244            }
245        }
246        seen
247    }
248
249    /// Human-readable summary string. One line for positives, one per
250    /// category for negatives. Designed to slot into existing
251    /// `TerminalReporter` output.
252    pub fn render_summary(&self) -> String {
253        let mut out = String::new();
254        out.push_str(&format!(
255            "Positives: {} pass / {} fail\n",
256            self.positive_pass, self.positive_fail
257        ));
258        let mut keys: Vec<&String> =
259            self.negative_caught.keys().chain(self.negative_missed.keys()).collect();
260        keys.sort();
261        keys.dedup();
262        for cat in keys {
263            let caught = self.negative_caught.get(cat).copied().unwrap_or(0);
264            let missed = self.negative_missed.get(cat).copied().unwrap_or(0);
265            let mark = if missed == 0 { "✓" } else { "⚠" };
266            out.push_str(&format!(
267                "Negatives [{}]: {} caught / {} missed  {}\n",
268                cat, caught, missed, mark
269            ));
270        }
271        out
272    }
273}
274
275/// Execute the self-test plan against `config.target_url` for every
276/// `AnnotatedOperation`. Returns the aggregated report; callers
277/// decide how to display it (e.g. via `render_summary` or by writing
278/// the JSON serialisation to disk).
279pub async fn run_self_test(
280    operations: &[AnnotatedOperation],
281    config: &SelfTestConfig,
282) -> Result<SelfTestReport, reqwest::Error> {
283    // Round 18.5 — build a client pool when `source_ips` is set,
284    // one reqwest::Client per IP, each bound to its local address.
285    // Operations round-robin through the pool. Empty pool → single
286    // default client (the pre-18.5 behaviour).
287    let clients = build_client_pool(config)?;
288    let client_cursor = AtomicUsize::new(0);
289    let geo_cursor = AtomicUsize::new(0);
290
291    let mut report = SelfTestReport::default();
292    for op in operations {
293        let client_idx = client_cursor.fetch_add(1, Ordering::Relaxed) % clients.len();
294        let client = &clients[client_idx];
295        let geo_ip = if config.geo_source_ips.is_empty() {
296            None
297        } else {
298            let idx = geo_cursor.fetch_add(1, Ordering::Relaxed) % config.geo_source_ips.len();
299            Some(config.geo_source_ips[idx])
300        };
301        let result = test_operation(client, config, op, geo_ip).await;
302        if let Some(p) = &result.positive {
303            if p.passed {
304                report.positive_pass += 1;
305            } else {
306                report.positive_fail += 1;
307            }
308        }
309        for neg in &result.negatives {
310            let cat = neg.label.split(':').next().unwrap_or("other").to_string();
311            if neg.passed {
312                *report.negative_caught.entry(cat).or_insert(0) += 1;
313            } else {
314                *report.negative_missed.entry(cat).or_insert(0) += 1;
315            }
316        }
317        report.operations.push(result);
318        if !config.delay_between_requests.is_zero() {
319            tokio::time::sleep(config.delay_between_requests).await;
320        }
321    }
322    Ok(report)
323}
324
325/// Round 18.5 — append GEODB forwarded-IP headers to the
326/// operation's declared headers. Returns the original vec untouched
327/// when `geo_ip` is None or `geo_headers` is empty.
328///
329/// If the operation already declares one of the geo headers (rare
330/// but legal), we keep the operation's value — the caller's spec
331/// wins.
332fn effective_op_headers(
333    base: &[(String, String)],
334    geo_ip: Option<IpAddr>,
335    geo_headers: &[String],
336) -> Vec<(String, String)> {
337    let mut out = base.to_vec();
338    let Some(ip) = geo_ip else {
339        return out;
340    };
341    let value = ip.to_string();
342    for h in geo_headers {
343        // Case-insensitive duplicate check: don't override the
344        // spec's own declared value for the header.
345        if out.iter().any(|(k, _)| k.eq_ignore_ascii_case(h)) {
346            continue;
347        }
348        out.push((h.clone(), value.clone()));
349    }
350    out
351}
352
353/// Round 18.5 — build a pool of reqwest clients, one per declared
354/// source IP. Empty `source_ips` → a single default client.
355///
356/// The OS must already have each `source_ip` assigned to an
357/// interface; reqwest's `.local_address()` issues a `bind()` syscall
358/// at connect time, so an IP the kernel doesn't recognise surfaces
359/// as `EADDRNOTAVAIL` at request time, not at builder time.
360fn build_client_pool(config: &SelfTestConfig) -> Result<Vec<Client>, reqwest::Error> {
361    let make = |bind: Option<IpAddr>| -> Result<Client, reqwest::Error> {
362        let mut builder = Client::builder().timeout(config.timeout);
363        if config.skip_tls_verify {
364            builder = builder.danger_accept_invalid_certs(true);
365        }
366        if let Some(addr) = bind {
367            builder = builder.local_address(addr);
368        }
369        builder.build()
370    };
371    if config.source_ips.is_empty() {
372        Ok(vec![make(None)?])
373    } else {
374        config.source_ips.iter().map(|ip| make(Some(*ip))).collect()
375    }
376}
377
378async fn test_operation(
379    client: &Client,
380    config: &SelfTestConfig,
381    op: &AnnotatedOperation,
382    geo_ip: Option<IpAddr>,
383) -> OperationResult {
384    // Round 25 — track the sink length BEFORE we run any probes for
385    // this operation, so that after the probes finish we can mutate
386    // exactly the entries that belong to this op (the capture sink is
387    // shared but `run_self_test` iterates operations sequentially).
388    // Used by the response-schema validation pass below.
389    let sink_start = config.capture.as_ref().and_then(|s| s.lock().ok().map(|g| g.len()));
390
391    let url = build_url_with_base(
392        &config.target_url,
393        config.base_path.as_deref(),
394        &op.path,
395        &op.path_params,
396    );
397    let method = Method::from_bytes(op.method.to_uppercase().as_bytes()).unwrap_or(Method::GET);
398
399    // Round 18.5 — pre-compute the operation's effective headers
400    // with the geo source IP baked in. Doing it once here keeps the
401    // per-case `send_case` calls below unchanged. When `geo_ip` is
402    // None the result equals `op.header_params`.
403    let op_headers = effective_op_headers(&op.header_params, geo_ip, &config.geo_source_headers);
404
405    // ── Positive case ────────────────────────────────────────────
406    let positive = send_case(
407        client,
408        config,
409        method.clone(),
410        &url,
411        "positive",
412        false,
413        op.sample_body.as_deref(),
414        op.query_params.clone(),
415        op_headers.clone(),
416    )
417    .await;
418
419    // ── Negative cases ───────────────────────────────────────────
420    let mut negatives = Vec::new();
421
422    // (a) empty body when one is required.
423    //
424    // Round 16 — drop the `sample_body.is_some()` precondition. Operations
425    // whose body annotator couldn't synthesize a sample previously got
426    // zero negatives (so the self-test reported "all passing" even on
427    // POST /resource with a required body). The spec saying the operation
428    // *has* a request body is enough — an empty object is a valid
429    // negative regardless of whether we have a positive sample.
430    if op.request_body_content_type.is_some() {
431        negatives.push(
432            send_case(
433                client,
434                config,
435                method.clone(),
436                &url,
437                "request-body:empty",
438                true,
439                Some("{}"),
440                op.query_params.clone(),
441                op_headers.clone(),
442            )
443            .await,
444        );
445
446        // (b) wrong-shaped body (array instead of object) — exercises
447        // top-level type validation independently of which fields are
448        // required.
449        negatives.push(
450            send_case(
451                client,
452                config,
453                method.clone(),
454                &url,
455                "request-body:wrong-type",
456                true,
457                Some("[]"),
458                op.query_params.clone(),
459                op_headers.clone(),
460            )
461            .await,
462        );
463
464        // Round 25 (k) — content-type swap probes.
465        //
466        // For operations declaring `application/json` request bodies, send
467        // the SAME json payload (or a synthesised one) under four other
468        // content types: `application/xml`, `application/yaml`,
469        // `multipart/form-data`, `application/x-www-form-urlencoded`.
470        // The spec says the endpoint accepts only JSON, so a strict server
471        // should respond 415 Unsupported Media Type (or 400 if it tries
472        // to parse and fails). A 2xx means the server is accepting
473        // payloads outside its declared content negotiation, which is the
474        // failure mode behind a lot of "we crashed on a malformed XML
475        // upload" incidents.
476        //
477        // Variant (a) of Srikanth's round-23 g ask: lie about the
478        // Content-Type header. The body shape is honest JSON; only the
479        // header is swapped. Variant (b) (JSON envelope with embedded
480        // non-JSON field values) is deferred to round 26 because it
481        // requires a schema-aware field walker.
482        if op
483            .request_body_content_type
484            .as_deref()
485            .map(|ct| ct.contains("json"))
486            .unwrap_or(false)
487        {
488            let payload = op.sample_body.as_deref().unwrap_or("{}");
489            for (ct, label) in CONTENT_TYPE_SWAP_VARIANTS {
490                negatives.push(
491                    send_case_with_extra(
492                        client,
493                        config,
494                        method.clone(),
495                        &url,
496                        label,
497                        true,
498                        Some(payload),
499                        op.query_params.clone(),
500                        // Strip any Content-Type already on the operation
501                        // headers (the spec's positive value) so the
502                        // probe's value is the only one the server sees.
503                        op_headers
504                            .iter()
505                            .filter(|(k, _)| !k.eq_ignore_ascii_case("content-type"))
506                            .cloned()
507                            .collect(),
508                        // The wrong Content-Type rides on `extra_headers`
509                        // so it lands AFTER `send_case_with_extra`'s
510                        // unconditional `application/json` insertion in
511                        // request-body mode. Actually `send_case_with_extra`
512                        // only sets Content-Type when a body is present
513                        // AND there's no manual override; passing the
514                        // override here wins because reqwest preserves
515                        // the last-set header value.
516                        vec![("Content-Type".to_string(), (*ct).to_string())],
517                    )
518                    .await,
519                );
520            }
521        }
522
523        // Round 17.2 — schema-aware negatives.
524        //
525        // When both a positive sample AND the resolved body schema are
526        // available, mutate the sample per-field (type mismatch,
527        // min/max bounds, pattern, enum out-of-range, required-field
528        // removal) and assert each is rejected with 4xx. Capped at
529        // SCHEMA_MUTATION_CAP per operation so a 100-property body
530        // doesn't explode the test matrix.
531        if let (Some(sample_str), Some(schema)) =
532            (op.sample_body.as_deref(), op.request_body_schema.as_ref())
533        {
534            if let Ok(sample) = serde_json::from_str::<serde_json::Value>(sample_str) {
535                let mutations = super::schema_mutator::mutate_body(&sample, schema);
536                for m in mutations.into_iter().take(SCHEMA_MUTATION_CAP) {
537                    let body_str = serde_json::to_string(&m.body).unwrap_or_default();
538                    negatives.push(
539                        send_case(
540                            client,
541                            config,
542                            method.clone(),
543                            &url,
544                            &m.label,
545                            true,
546                            Some(&body_str),
547                            op.query_params.clone(),
548                            // Round 24 (f) — was `op.header_params`, which
549                            // skipped the geo-IP header. Use `op_headers`
550                            // so the geo IP rides with the negative probe
551                            // too (positive vs negative coverage must be
552                            // symmetric, otherwise a GEODB front-end sees
553                            // the rotating IP only on positives).
554                            op_headers.clone(),
555                        )
556                        .await,
557                    );
558                }
559            }
560        }
561    }
562
563    // Round 17.2 — URI-length probe. Spec-agnostic but schema-aware in
564    // spirit: most servers cap URIs at 8 KB or so. Append a 9 KB query
565    // string to the URL and expect 414 URI Too Long (or 400). Skipped
566    // for operations that already have a heavy positive query.
567    {
568        let pad = "p=".to_string() + &"x".repeat(9_000);
569        let bad_url = if url.contains('?') {
570            format!("{url}&{pad}")
571        } else {
572            format!("{url}?{pad}")
573        };
574        negatives.push(
575            send_case(
576                client,
577                config,
578                method.clone(),
579                &bad_url,
580                "parameters:uri-too-long",
581                true,
582                op.sample_body.as_deref(),
583                op.query_params.clone(),
584                // Round 24 (f) — see schema-mutation note above. Use
585                // `op_headers` (carries geo IP) instead of bare
586                // `op.header_params`.
587                op_headers.clone(),
588            )
589            .await,
590        );
591    }
592
593    // (e) Round 16 — path-param type probe. Send the first path
594    // parameter as a literal `"self-test-invalid-id"`: a string that
595    // contains hyphens, won't parse as an integer, won't parse as a
596    // UUID, and won't match any typical regex pattern. Operations
597    // whose spec types the param as `integer` or `string` with a
598    // `format`/`pattern` will catch this (caught: server returned
599    // 4xx); operations whose spec lets path params be free-form
600    // strings will let it through (missed: server returned 2xx).
601    // Either outcome is informative: a category that's all "missed"
602    // tells the user their spec is loose on path-param types, which
603    // is itself worth knowing. Addresses Srikanth's "always all
604    // passing" report — operations with a path param now produce at
605    // least one probe instead of zero.
606    if !op.path_params.is_empty() {
607        let mut url_with_placeholder = op.path.clone();
608        if let Some((first_name, _)) = op.path_params.first() {
609            // Substitute every other path-param with its sample so the
610            // route shape stays intact and only the first param is bad.
611            for (name, value) in op.path_params.iter().skip(1) {
612                if !value.is_empty() {
613                    url_with_placeholder =
614                        url_with_placeholder.replace(&format!("{{{name}}}"), value);
615                }
616            }
617            // Substitute the first param with a guaranteed-invalid
618            // sentinel that's unlikely to match any reasonable schema:
619            // contains characters disallowed in numeric IDs *and* UUIDs.
620            url_with_placeholder =
621                url_with_placeholder.replace(&format!("{{{first_name}}}"), "self-test-invalid-id");
622            // Round 18.1 — honour `base_path` here too, otherwise the
623            // probe URL differs from the positive case and the
624            // resulting 404 is misattributed to "bad path param".
625            let bad_url = build_url_with_base(
626                &config.target_url,
627                config.base_path.as_deref(),
628                &url_with_placeholder,
629                &[],
630            );
631            negatives.push(
632                send_case(
633                    client,
634                    config,
635                    method.clone(),
636                    &bad_url,
637                    "parameters:bad-path-param",
638                    true,
639                    op.sample_body.as_deref(),
640                    op.query_params.clone(),
641                    op_headers.clone(),
642                )
643                .await,
644            );
645        }
646    }
647
648    // (c) drop the first required query param
649    if !op.query_params.is_empty() {
650        let mut q = op.query_params.clone();
651        q.remove(0);
652        negatives.push(
653            send_case(
654                client,
655                config,
656                method.clone(),
657                &url,
658                "parameters:missing-query",
659                true,
660                op.sample_body.as_deref(),
661                q,
662                op_headers.clone(),
663            )
664            .await,
665        );
666    }
667
668    // (s) Round 17.3 — security probes.
669    //
670    // Operations whose spec declares a security requirement get a
671    // dedicated set of negatives. The point isn't to test whether the
672    // server's *real* auth works (the positive case already does that
673    // via `extra_headers`) — it's to check whether deliberately-bad
674    // credentials are still rejected, which is exactly the failure
675    // mode that lets an attacker through a half-wired validator.
676    //
677    // Each probe replaces or omits the relevant auth credential and
678    // expects 401 / 403. A 2xx here is a hard finding: "spec says
679    // this endpoint is protected, server let unauthenticated /
680    // wrong-credential traffic through".
681    //
682    // Bounded: at most one probe per declared scheme kind, so an
683    // operation with 3 security requirements doesn't 4× the request
684    // volume. Skips entirely when `op.security_schemes` is empty.
685    for probe in build_security_probes(&op.security_schemes) {
686        // Strip any pre-existing Authorization or known API-key
687        // header from extra_headers + header_params so the probe
688        // value is the *only* credential the server sees.
689        let stripped_extra = strip_auth(&config.extra_headers, &op.security_schemes);
690        let stripped_headers = strip_auth(&op.header_params, &op.security_schemes);
691        let stripped_query = strip_auth_query(&op.query_params, &op.security_schemes);
692        let mut req_headers = stripped_headers;
693        for (k, v) in &probe.headers {
694            req_headers.push((k.clone(), v.clone()));
695        }
696        // Round 24 (f) — security probes build req_headers from
697        // `op.header_params` directly (we need the stripped-auth
698        // variant), so the geo-IP header doesn't ride along
699        // automatically. Append it here so a GEODB / WAF in front
700        // of the auth layer still sees the rotating source IP.
701        if let Some(ip) = geo_ip {
702            let ip_str = ip.to_string();
703            for h in &config.geo_source_headers {
704                let already = req_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(h));
705                if !already {
706                    req_headers.push((h.clone(), ip_str.clone()));
707                }
708            }
709        }
710        let mut req_query = stripped_query;
711        for (k, v) in &probe.query {
712            req_query.push((k.clone(), v.clone()));
713        }
714        negatives.push(
715            send_case_with_extra(
716                client,
717                config,
718                method.clone(),
719                &url,
720                &probe.label,
721                true,
722                op.sample_body.as_deref(),
723                req_query,
724                req_headers,
725                stripped_extra,
726            )
727            .await,
728        );
729    }
730
731    // (d) drop the first required header
732    if !op.header_params.is_empty() {
733        // Round 24 (f) — start from `op_headers` (so the geo IP rides
734        // along) and only strip the first OPERATION-declared header.
735        // Slicing past `op.header_params.len()` would otherwise risk
736        // dropping the geo header itself; `op_headers` is built as
737        // `op.header_params ++ geo` so index 0 is always operational.
738        let mut h = op_headers.clone();
739        if !h.is_empty() {
740            h.remove(0);
741        }
742        negatives.push(
743            send_case(
744                client,
745                config,
746                method.clone(),
747                &url,
748                "parameters:missing-header",
749                true,
750                op.sample_body.as_deref(),
751                op.query_params.clone(),
752                h,
753            )
754            .await,
755        );
756    }
757
758    // (w) Round 17.5 — OWASP/WAF unification.
759    //
760    // Pull one canonical payload per OWASP category from the existing
761    // `SecurityPayloads` library and emit an injection probe per
762    // category. Targets in priority order: (1) substitute the first
763    // query param's value, (2) substitute the first string field of
764    // the positive JSON body, (3) skip if neither is available.
765    //
766    // Label format `owasp:<category>`, so the existing
767    // `negative_caught` / `negative_missed` rollup groups all OWASP
768    // findings under one `owasp` bucket. Expected 4xx (server should
769    // reject malicious input). A 5xx is a hard finding (server
770    // crashed on the payload); a 2xx is a soft finding (input passed
771    // through unfiltered — may or may not be a real vuln).
772    //
773    // Bounded: at most one probe per category (7 categories total).
774    // Skips the operation entirely if no injection target is
775    // available — open GET endpoints with no params get zero OWASP
776    // probes, no false signal.
777    for probe in build_owasp_probes(op) {
778        negatives.push(
779            send_case(
780                client,
781                config,
782                method.clone(),
783                &url,
784                &probe.label,
785                true,
786                probe.body.as_deref(),
787                probe.query,
788                // Round 24 (f) — OWASP injection probes must also
789                // carry the geo IP, otherwise a WAF / GEODB rule
790                // tuned to a specific source IP would silently let
791                // them through.
792                op_headers.clone(),
793            )
794            .await,
795        );
796    }
797
798    // Round 25 — response-body shape validation pass. For each capture
799    // this op pushed onto the sink, look up the spec's schema for the
800    // actual response status and validate. Result lands in
801    // `response_schema_error` (Some(message) on failure, None on
802    // pass or no-schema-for-this-status). Runs only when the user
803    // opted in AND capture is on (we need the body).
804    if config.validate_response_schemas {
805        if let (Some(sink), Some(start)) = (config.capture.as_ref(), sink_start) {
806            if !op.response_schemas.is_empty() {
807                if let Ok(mut guard) = sink.lock() {
808                    let end = guard.len();
809                    for i in start..end {
810                        let Some(entry) = guard.get_mut(i) else {
811                            continue;
812                        };
813                        let Some(body) = entry.response_body.as_deref() else {
814                            continue;
815                        };
816                        let Some(schema) = op.response_schemas.get(&entry.response_status) else {
817                            continue;
818                        };
819                        entry.response_schema_error = validate_body_against_schema(body, schema);
820                    }
821                }
822            }
823        }
824    }
825
826    OperationResult {
827        method: op.method.clone(),
828        path: op.path.clone(),
829        positive: Some(positive),
830        negatives,
831    }
832}
833
834/// Round 25 — validate a JSON body string against an OpenAPI response
835/// schema (already converted to a `serde_json::Value`). Returns
836/// `Some(message)` describing the first violation, or `None` on a
837/// clean pass / non-JSON body / schema-build failure (in which case
838/// the absence of an error means "we didn't have anything to compare
839/// against", not "passed"; the caller-side semantics treat absence as
840/// success because that's what the user sees as silence).
841fn validate_body_against_schema(body: &str, schema: &serde_json::Value) -> Option<String> {
842    let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
843    let validator = jsonschema::validator_for(schema).ok()?;
844    let mut errors = validator.iter_errors(&parsed);
845    let first = errors.next()?;
846    // Round 26 — Srikanth on 0.3.169: the prior `format!("{:?}", first.kind)
847    // .split('(').next()` produced "Type { kind: Single" (broken Rust
848    // syntax, mismatched braces). Switch to the human-readable mapping
849    // already used in executor.rs: handle the common kinds (Type,
850    // Required, AdditionalProperties, Enum, MinLength, MaxLength,
851    // Minimum, Maximum, Pattern) explicitly; fall back to the
852    // jsonschema crate's Display impl on the error (which produces
853    // something like "{...} is not of type \"string\"") for the long
854    // tail. Combined with `at <instance-path>` for the field location.
855    let path = first.instance_path.to_string();
856    let path = if path.is_empty() { "/" } else { path.as_str() };
857    let kind_msg: String = match &first.kind {
858        jsonschema::error::ValidationErrorKind::Type { kind } => {
859            // `kind` is `TypeKind::Single(JsonType)` or
860            // `TypeKind::Multiple(JsonTypeSet)`. `JsonType` has its
861            // own `Display` impl ("string", "object", etc.).
862            match kind {
863                jsonschema::error::TypeKind::Single(t) => format!("expected type {t}"),
864                jsonschema::error::TypeKind::Multiple(_) => "expected one of multiple types".into(),
865            }
866        }
867        jsonschema::error::ValidationErrorKind::Required { property } => {
868            format!("required field missing: {property}")
869        }
870        jsonschema::error::ValidationErrorKind::AdditionalProperties { unexpected } => {
871            format!("unexpected additional properties: {unexpected:?}")
872        }
873        jsonschema::error::ValidationErrorKind::Enum { options } => {
874            format!("value not in allowed enum: {options}")
875        }
876        jsonschema::error::ValidationErrorKind::MinLength { limit } => {
877            format!("string shorter than min length ({limit})")
878        }
879        jsonschema::error::ValidationErrorKind::MaxLength { limit } => {
880            format!("string longer than max length ({limit})")
881        }
882        jsonschema::error::ValidationErrorKind::Minimum { limit } => {
883            format!("value below minimum ({limit})")
884        }
885        jsonschema::error::ValidationErrorKind::Maximum { limit } => {
886            format!("value above maximum ({limit})")
887        }
888        jsonschema::error::ValidationErrorKind::Pattern { pattern } => {
889            format!("value did not match pattern {pattern}")
890        }
891        // Long tail: lean on jsonschema's Display impl, which is the
892        // built-in human-readable error message ("X is not of type Y").
893        // Strip trailing newlines so the JSONL line stays one line.
894        _ => first.to_string().trim().to_string(),
895    };
896    Some(format!("at {path}: {kind_msg}"))
897}
898
899/// Round 17.5 — one OWASP injection probe to send.
900#[derive(Debug, Clone)]
901struct OwaspProbe {
902    label: String,
903    body: Option<String>,
904    query: Vec<(String, String)>,
905}
906
907/// Build one OWASP probe per `SecurityCategory` for `op`. Targets the
908/// first query param if any, else the first string field of the
909/// positive JSON body. Returns empty if neither target is available.
910fn build_owasp_probes(op: &AnnotatedOperation) -> Vec<OwaspProbe> {
911    use crate::security_payloads::{SecurityCategory, SecurityPayloads};
912
913    let categories = [
914        SecurityCategory::SqlInjection,
915        SecurityCategory::Xss,
916        SecurityCategory::CommandInjection,
917        SecurityCategory::PathTraversal,
918        SecurityCategory::Ssti,
919        SecurityCategory::LdapInjection,
920        SecurityCategory::Xxe,
921    ];
922
923    // Pick an injection target ONCE per operation; reuse it across
924    // categories. (A single op gets up to 7 probes — one per category
925    // — all attacking the same field.)
926    let injection_target = pick_injection_target(op);
927    let Some(target) = injection_target else {
928        return Vec::new();
929    };
930
931    let mut probes = Vec::new();
932    for cat in categories {
933        // Take the *first* payload from each category. The
934        // collection's first entry is the canonical low-risk
935        // representative; later entries include time-based / blind
936        // probes that aren't useful as a one-shot rejection test.
937        let Some(payload) = SecurityPayloads::get_by_category(cat).into_iter().next() else {
938            continue;
939        };
940        let mut query = op.query_params.clone();
941        let mut body = op.sample_body.clone();
942        match &target {
943            InjectionTarget::Query(idx) => {
944                if let Some(slot) = query.get_mut(*idx) {
945                    slot.1 = payload.payload.clone();
946                }
947            }
948            InjectionTarget::BodyStringField(field) => {
949                body = inject_into_body_field(body.as_deref(), field, &payload.payload);
950            }
951        }
952        probes.push(OwaspProbe {
953            label: format!("owasp:{}", cat),
954            body,
955            query,
956        });
957    }
958    probes
959}
960
961#[derive(Debug, Clone)]
962enum InjectionTarget {
963    Query(usize),
964    BodyStringField(String),
965}
966
967fn pick_injection_target(op: &AnnotatedOperation) -> Option<InjectionTarget> {
968    if !op.query_params.is_empty() {
969        return Some(InjectionTarget::Query(0));
970    }
971    let sample = op.sample_body.as_deref()?;
972    let parsed: serde_json::Value = serde_json::from_str(sample).ok()?;
973    let obj = parsed.as_object()?;
974    for (k, v) in obj {
975        if v.is_string() {
976            return Some(InjectionTarget::BodyStringField(k.clone()));
977        }
978    }
979    None
980}
981
982/// Replace the value of `field` in a JSON-object body with `payload`.
983/// Returns the mutated body as a JSON string. Returns `None` if the
984/// body doesn't parse as a JSON object.
985fn inject_into_body_field(body: Option<&str>, field: &str, payload: &str) -> Option<String> {
986    let raw = body?;
987    let mut parsed: serde_json::Value = serde_json::from_str(raw).ok()?;
988    let obj = parsed.as_object_mut()?;
989    obj.insert(field.to_string(), serde_json::json!(payload));
990    serde_json::to_string(&parsed).ok()
991}
992
993#[allow(clippy::too_many_arguments)]
994/// Round 17.3 — one synthesised bad credential to send.
995#[derive(Debug, Clone)]
996struct SecurityProbe {
997    /// Self-test label, e.g. `security:bad-bearer`.
998    label: String,
999    /// Headers to attach to the probe request.
1000    headers: Vec<(String, String)>,
1001    /// Query parameters to attach (API key in query case).
1002    query: Vec<(String, String)>,
1003}
1004
1005/// For each declared security scheme, produce one bad-credential
1006/// probe plus a single "no auth at all" probe that exercises the
1007/// missing-credential code path. Deduplicates by scheme kind so an
1008/// operation declaring `[bearer, bearer]` only yields one Bearer
1009/// probe.
1010fn build_security_probes(schemes: &[SecuritySchemeInfo]) -> Vec<SecurityProbe> {
1011    if schemes.is_empty() {
1012        return Vec::new();
1013    }
1014    let mut probes: Vec<SecurityProbe> = Vec::new();
1015    let mut seen_bearer = false;
1016    let mut seen_basic = false;
1017    // `(loc_tag, name)` — ApiKeyLocation doesn't implement Ord, so
1018    // we tag it with a short discriminant string for dedup.
1019    let mut seen_apikey: std::collections::BTreeSet<(&'static str, String)> = Default::default();
1020    for s in schemes {
1021        match s {
1022            SecuritySchemeInfo::Bearer if !seen_bearer => {
1023                seen_bearer = true;
1024                probes.push(SecurityProbe {
1025                    label: "security:bad-bearer".into(),
1026                    headers: vec![(
1027                        "Authorization".into(),
1028                        "Bearer self-test-invalid-token".into(),
1029                    )],
1030                    query: Vec::new(),
1031                });
1032            }
1033            SecuritySchemeInfo::Basic if !seen_basic => {
1034                seen_basic = true;
1035                // base64("self-test:invalid") — valid base64, wrong creds.
1036                probes.push(SecurityProbe {
1037                    label: "security:bad-basic".into(),
1038                    headers: vec![(
1039                        "Authorization".into(),
1040                        "Basic c2VsZi10ZXN0OmludmFsaWQ=".into(),
1041                    )],
1042                    query: Vec::new(),
1043                });
1044            }
1045            SecuritySchemeInfo::ApiKey { location, name } => {
1046                let loc_tag = match location {
1047                    ApiKeyLocation::Header => "header",
1048                    ApiKeyLocation::Query => "query",
1049                    ApiKeyLocation::Cookie => "cookie",
1050                };
1051                if seen_apikey.contains(&(loc_tag, name.clone())) {
1052                    continue;
1053                }
1054                seen_apikey.insert((loc_tag, name.clone()));
1055                let label = format!("security:bad-apikey:{}", name);
1056                let bad = "self-test-invalid-key".to_string();
1057                match location {
1058                    ApiKeyLocation::Header => probes.push(SecurityProbe {
1059                        label,
1060                        headers: vec![(name.clone(), bad)],
1061                        query: Vec::new(),
1062                    }),
1063                    ApiKeyLocation::Query => probes.push(SecurityProbe {
1064                        label,
1065                        headers: Vec::new(),
1066                        query: vec![(name.clone(), bad)],
1067                    }),
1068                    ApiKeyLocation::Cookie => probes.push(SecurityProbe {
1069                        label,
1070                        headers: vec![("Cookie".into(), format!("{}={}", name, bad))],
1071                        query: Vec::new(),
1072                    }),
1073                }
1074            }
1075            _ => {}
1076        }
1077    }
1078    // Always add a "no auth at all" probe when *any* security scheme
1079    // is declared — useful even if all schemes failed to resolve to a
1080    // testable kind, because it surfaces validators that aren't
1081    // checking auth presence at all.
1082    probes.push(SecurityProbe {
1083        label: "security:no-auth".into(),
1084        headers: Vec::new(),
1085        query: Vec::new(),
1086    });
1087    probes
1088}
1089
1090/// Remove Authorization and any API-key headers declared by the
1091/// operation's security schemes from `headers`, so a security probe
1092/// can supply its own credential (or none) cleanly.
1093fn strip_auth(
1094    headers: &[(String, String)],
1095    schemes: &[SecuritySchemeInfo],
1096) -> Vec<(String, String)> {
1097    let mut apikey_headers: std::collections::BTreeSet<String> = Default::default();
1098    for s in schemes {
1099        if let SecuritySchemeInfo::ApiKey {
1100            location: ApiKeyLocation::Header,
1101            name,
1102        } = s
1103        {
1104            apikey_headers.insert(name.to_lowercase());
1105        }
1106        if let SecuritySchemeInfo::ApiKey {
1107            location: ApiKeyLocation::Cookie,
1108            ..
1109        } = s
1110        {
1111            apikey_headers.insert("cookie".into());
1112        }
1113    }
1114    headers
1115        .iter()
1116        .filter(|(k, _)| {
1117            let lk = k.to_lowercase();
1118            lk != "authorization" && !apikey_headers.contains(&lk)
1119        })
1120        .cloned()
1121        .collect()
1122}
1123
1124/// Remove API-key query parameters declared by the operation's
1125/// security schemes from `query`, so a probe can supply its own.
1126fn strip_auth_query(
1127    query: &[(String, String)],
1128    schemes: &[SecuritySchemeInfo],
1129) -> Vec<(String, String)> {
1130    let mut apikey_query: std::collections::BTreeSet<String> = Default::default();
1131    for s in schemes {
1132        if let SecuritySchemeInfo::ApiKey {
1133            location: ApiKeyLocation::Query,
1134            name,
1135        } = s
1136        {
1137            apikey_query.insert(name.clone());
1138        }
1139    }
1140    query.iter().filter(|(k, _)| !apikey_query.contains(k)).cloned().collect()
1141}
1142
1143/// Variant of `send_case` that takes an explicit `extra_headers`
1144/// (rather than reading them from `config`). Used by security probes
1145/// to substitute or strip the configured Authorization header.
1146#[allow(clippy::too_many_arguments)]
1147async fn send_case_with_extra(
1148    client: &Client,
1149    config: &SelfTestConfig,
1150    method: Method,
1151    url: &str,
1152    label: &str,
1153    expected_4xx: bool,
1154    body: Option<&str>,
1155    query: Vec<(String, String)>,
1156    headers: Vec<(String, String)>,
1157    extra_headers: Vec<(String, String)>,
1158) -> CaseOutcome {
1159    let mut req = client.request(method.clone(), url);
1160    let mut capture_headers: BTreeMap<String, String> = BTreeMap::new();
1161    for (k, v) in &query {
1162        req = req.query(&[(k.as_str(), v.as_str())]);
1163    }
1164    // Attach the body FIRST with a default Content-Type. Subsequent
1165    // header passes (the operation's headers, then extra_headers) can
1166    // overwrite the Content-Type — that's what makes the round-25 (k)
1167    // content-type-swap probes work: they pass a wrong Content-Type
1168    // via extra_headers and reqwest's last-write-wins keeps it.
1169    if let Some(b) = body {
1170        req = req
1171            .header(reqwest::header::CONTENT_TYPE, "application/json")
1172            .body(b.to_string());
1173        capture_headers.insert("Content-Type".to_string(), "application/json".to_string());
1174    }
1175    for (k, v) in &headers {
1176        req = req.header(k, v);
1177        capture_headers.insert(k.clone(), v.clone());
1178    }
1179    for (k, v) in &extra_headers {
1180        req = req.header(k, v);
1181        capture_headers.insert(k.clone(), v.clone());
1182    }
1183    let (actual_status, response_capture) = match req.send().await {
1184        Ok(resp) => {
1185            let status = resp.status().as_u16();
1186            if let Some(sink) = &config.capture {
1187                let resp_headers: BTreeMap<String, String> = resp
1188                    .headers()
1189                    .iter()
1190                    .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
1191                    .collect();
1192                let text = resp.text().await.unwrap_or_default();
1193                let (rb, truncated) = truncate_body_for_capture(&text);
1194                (status, Some((Some((rb, truncated)), resp_headers, None, sink.clone())))
1195            } else {
1196                (status, None)
1197            }
1198        }
1199        Err(e) => {
1200            let err_str = e.to_string();
1201            if let Some(sink) = &config.capture {
1202                (0, Some((None, BTreeMap::new(), Some(err_str), sink.clone())))
1203            } else {
1204                (0, None)
1205            }
1206        }
1207    };
1208    let passed = if expected_4xx {
1209        (400..500).contains(&actual_status)
1210    } else {
1211        (200..400).contains(&actual_status)
1212    };
1213    if let Some((resp_body, resp_headers, error, sink)) = response_capture {
1214        let (request_body, request_body_truncated) = match body {
1215            Some(b) => {
1216                let (rb, t) = truncate_body_for_capture(b);
1217                (Some(rb), t)
1218            }
1219            None => (None, false),
1220        };
1221        let (response_body, response_body_truncated) = match resp_body {
1222            Some((rb, t)) => (Some(rb), t),
1223            None => (None, false),
1224        };
1225        let entry = CaseCapture {
1226            label: label.to_string(),
1227            method: method.to_string(),
1228            url: build_query_url(url, &query),
1229            request_headers: capture_headers,
1230            request_body,
1231            request_body_truncated,
1232            response_status: actual_status,
1233            response_headers: resp_headers,
1234            response_body,
1235            response_body_truncated,
1236            error,
1237            // Filled in by the per-operation validation pass after
1238            // every probe finishes; the capture itself is unaware of
1239            // the schema map.
1240            response_schema_error: None,
1241        };
1242        if let Ok(mut guard) = sink.lock() {
1243            guard.push(entry);
1244        }
1245    }
1246    CaseOutcome {
1247        label: label.to_string(),
1248        expected_4xx,
1249        actual_status,
1250        passed,
1251    }
1252}
1253
1254// HTTP request shape needs all of these: client, config (for capture
1255// sink + extra headers), method, url, label (probe id), expected_4xx
1256// (pass/fail decision), body, query, headers. A struct wrapper would
1257// just move the arity from positional to field access without making
1258// the call sites clearer.
1259#[allow(clippy::too_many_arguments)]
1260async fn send_case(
1261    client: &Client,
1262    config: &SelfTestConfig,
1263    method: Method,
1264    url: &str,
1265    label: &str,
1266    expected_4xx: bool,
1267    body: Option<&str>,
1268    query: Vec<(String, String)>,
1269    headers: Vec<(String, String)>,
1270) -> CaseOutcome {
1271    // Forwarding to `send_case_with_extra` keeps the capture logic in
1272    // one place so request/response tracing can't drift between the
1273    // two entrypoints.
1274    send_case_with_extra(
1275        client,
1276        config,
1277        method,
1278        url,
1279        label,
1280        expected_4xx,
1281        body,
1282        query,
1283        headers,
1284        config.extra_headers.clone(),
1285    )
1286    .await
1287}
1288
1289/// Round 23 (c-iii) — rebuild the query-stringified URL for capture so
1290/// the JSONL trace shows the URL that actually went over the wire
1291/// (reqwest applies `.query(..)` after the request URL string is
1292/// rendered, so capturing the raw `url` argument alone loses the
1293/// query params).
1294fn build_query_url(base: &str, query: &[(String, String)]) -> String {
1295    if query.is_empty() {
1296        return base.to_string();
1297    }
1298    let qs: String = query
1299        .iter()
1300        .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
1301        .collect::<Vec<_>>()
1302        .join("&");
1303    if base.contains('?') {
1304        format!("{base}&{qs}")
1305    } else {
1306        format!("{base}?{qs}")
1307    }
1308}
1309
1310/// Substitute `{param}` placeholders in the spec path with their
1311/// sample values from `path_params`, then prepend `target_url`. Empty
1312/// values are kept as `{param}` so an upstream router still matches
1313/// the template — useful when `path_params` is empty and we want to
1314/// hit the same route the spec defines.
1315///
1316/// All current call sites went through `build_url_with_base` after
1317/// round 18.1, so this no-base-path helper is unused; keep it as the
1318/// documented shim for future external callers (one-arg simplification).
1319#[allow(dead_code)]
1320fn build_url(target: &str, path_template: &str, path_params: &[(String, String)]) -> String {
1321    build_url_with_base(target, None, path_template, path_params)
1322}
1323
1324/// Round 18.1 — variant of `build_url` that takes a `base_path`
1325/// (e.g. `Some("/api")`). When set, prepends it to the spec path so a
1326/// spec declaring `/users` against a target served behind `/api`
1327/// resolves to `<target>/api/users`. `base_path` is normalised: leading
1328/// `/` is auto-added, trailing `/` is stripped.
1329fn build_url_with_base(
1330    target: &str,
1331    base_path: Option<&str>,
1332    path_template: &str,
1333    path_params: &[(String, String)],
1334) -> String {
1335    let mut url = path_template.to_string();
1336    for (name, value) in path_params {
1337        let placeholder = format!("{{{}}}", name);
1338        if !value.is_empty() {
1339            url = url.replace(&placeholder, value);
1340        }
1341    }
1342    let target = target.trim_end_matches('/');
1343    let prefix = match base_path {
1344        Some(bp) if !bp.is_empty() => {
1345            let trimmed = bp.trim_end_matches('/');
1346            if trimmed.starts_with('/') {
1347                trimmed.to_string()
1348            } else {
1349                format!("/{}", trimmed)
1350            }
1351        }
1352        _ => String::new(),
1353    };
1354    let path = if url.starts_with('/') {
1355        url
1356    } else {
1357        format!("/{url}")
1358    };
1359    format!("{target}{prefix}{path}")
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364    use super::*;
1365
1366    fn op(
1367        method: &str,
1368        path: &str,
1369        body: Option<&str>,
1370        query: Vec<(&str, &str)>,
1371        headers: Vec<(&str, &str)>,
1372        path_params: Vec<(&str, &str)>,
1373    ) -> AnnotatedOperation {
1374        AnnotatedOperation {
1375            method: method.into(),
1376            path: path.into(),
1377            features: Vec::new(),
1378            request_body_content_type: body.map(|_| "application/json".into()),
1379            sample_body: body.map(|s| s.to_string()),
1380            query_params: query.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1381            header_params: headers.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1382            path_params: path_params.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1383            response_schema: None,
1384            response_schemas: std::collections::BTreeMap::new(),
1385            request_body_schema: None,
1386            security_schemes: Vec::new(),
1387        }
1388    }
1389
1390    #[test]
1391    fn build_url_substitutes_path_params() {
1392        let url = build_url(
1393            "https://api.test/",
1394            "/users/{id}/posts/{pid}",
1395            &[("id".into(), "42".into()), ("pid".into(), "7".into())],
1396        );
1397        assert_eq!(url, "https://api.test/users/42/posts/7");
1398    }
1399
1400    /// Round 18.1 — a run where every positive 404s should be flagged
1401    /// as a likely target misconfiguration, not silently treated as a
1402    /// successful conformance run.
1403    #[test]
1404    fn detect_target_misconfiguration_when_all_positives_share_status() {
1405        let mut report = SelfTestReport {
1406            positive_pass: 0,
1407            positive_fail: 50,
1408            ..Default::default()
1409        };
1410        for i in 0..50 {
1411            report.operations.push(OperationResult {
1412                method: "GET".into(),
1413                path: format!("/r/{i}"),
1414                positive: Some(CaseOutcome {
1415                    label: "positive".into(),
1416                    expected_4xx: false,
1417                    actual_status: 404,
1418                    passed: false,
1419                }),
1420                negatives: Vec::new(),
1421            });
1422        }
1423        assert_eq!(report.detect_target_misconfiguration(), Some(404));
1424    }
1425
1426    #[test]
1427    fn detect_target_misconfiguration_returns_none_when_some_pass() {
1428        let mut report = SelfTestReport {
1429            positive_pass: 5,
1430            positive_fail: 50,
1431            ..Default::default()
1432        };
1433        for i in 0..55 {
1434            report.operations.push(OperationResult {
1435                method: "GET".into(),
1436                path: format!("/r/{i}"),
1437                positive: Some(CaseOutcome {
1438                    label: "positive".into(),
1439                    expected_4xx: false,
1440                    actual_status: if i < 5 { 200 } else { 404 },
1441                    passed: i < 5,
1442                }),
1443                negatives: Vec::new(),
1444            });
1445        }
1446        assert_eq!(report.detect_target_misconfiguration(), None);
1447    }
1448
1449    /// Round 18.1 — `--base-path /api` should prepend `/api` to
1450    /// every spec path. Pre-fix, the self-test ignored base_path and
1451    /// 404'd every positive when the deployed API was behind a path
1452    /// prefix.
1453    #[test]
1454    fn build_url_applies_base_path_when_present() {
1455        let url = build_url_with_base(
1456            "https://api.example.com",
1457            Some("/api"),
1458            "/users/{id}",
1459            &[("id".into(), "42".into())],
1460        );
1461        assert_eq!(url, "https://api.example.com/api/users/42");
1462    }
1463
1464    /// Round 18.1 — base_path is normalised: missing leading slash
1465    /// gets one added, trailing slash is stripped, empty string is
1466    /// the same as None.
1467    #[test]
1468    fn build_url_normalises_base_path() {
1469        let no_slash = build_url_with_base("https://t", Some("api"), "/x", &[]);
1470        assert_eq!(no_slash, "https://t/api/x");
1471        let trailing = build_url_with_base("https://t", Some("/api/"), "/x", &[]);
1472        assert_eq!(trailing, "https://t/api/x");
1473        let empty = build_url_with_base("https://t", Some(""), "/x", &[]);
1474        assert_eq!(empty, "https://t/x");
1475        let none = build_url_with_base("https://t", None, "/x", &[]);
1476        assert_eq!(none, "https://t/x");
1477    }
1478
1479    #[test]
1480    fn build_url_keeps_placeholders_when_no_sample() {
1481        let url = build_url("https://api.test", "/users/{id}", &[]);
1482        assert_eq!(url, "https://api.test/users/{id}");
1483    }
1484
1485    #[test]
1486    fn report_summary_calls_out_misses() {
1487        let r = SelfTestReport {
1488            positive_pass: 3,
1489            positive_fail: 0,
1490            negative_caught: BTreeMap::from([("request-body".into(), 2)]),
1491            negative_missed: BTreeMap::from([("request-body".into(), 1)]),
1492            operations: Vec::new(),
1493        };
1494        let summary = r.render_summary();
1495        assert!(summary.contains("Positives: 3 pass / 0 fail"));
1496        assert!(summary.contains("Negatives [request-body]: 2 caught / 1 missed"));
1497        assert!(summary.contains("⚠"));
1498        assert!(!r.all_passed());
1499    }
1500
1501    #[test]
1502    fn report_all_passed_when_no_miss() {
1503        let r = SelfTestReport {
1504            positive_pass: 5,
1505            positive_fail: 0,
1506            negative_caught: BTreeMap::from([("parameters".into(), 3)]),
1507            negative_missed: BTreeMap::new(),
1508            operations: Vec::new(),
1509        };
1510        assert!(r.all_passed());
1511        assert!(r.render_summary().contains("✓"));
1512    }
1513
1514    #[tokio::test]
1515    async fn run_self_test_against_unreachable_target_marks_all_failed() {
1516        // Use an obviously-dead port so we exercise the timeout/error
1517        // path without needing a live server in tests.
1518        let cfg = SelfTestConfig {
1519            target_url: "http://127.0.0.1:1".into(),
1520            timeout: Duration::from_millis(200),
1521            ..Default::default()
1522        };
1523        let ops = vec![op(
1524            "POST",
1525            "/users",
1526            Some("{\"name\":\"a\"}"),
1527            vec![],
1528            vec![],
1529            vec![],
1530        )];
1531        let report = run_self_test(&ops, &cfg).await.expect("client builds");
1532        // All cases hit the connect-error path → actual_status=0.
1533        // Positive expects 2xx-3xx → 0 is fail. Negatives expect 4xx
1534        // → 0 is also fail (we missed catching).
1535        assert_eq!(report.positive_fail, 1);
1536        assert!(report.negative_missed.values().sum::<usize>() >= 1);
1537        assert!(!report.all_passed());
1538    }
1539
1540    /// Round 17.2 — operations with both a positive sample AND a
1541    /// resolved request-body schema produce schema-driven negatives
1542    /// in addition to the spec-agnostic empty/wrong-type ones. The
1543    /// labels carry the field path so a per-category report can tell
1544    /// you exactly which field caught.
1545    #[tokio::test]
1546    async fn schema_driven_negatives_fire_when_schema_present() {
1547        use openapiv3::{ObjectType, ReferenceOr, Schema, SchemaData, SchemaKind, Type};
1548        let cfg = SelfTestConfig {
1549            target_url: "http://127.0.0.1:1".into(),
1550            timeout: Duration::from_millis(200),
1551            ..Default::default()
1552        };
1553        // Build an operation whose schema has a required `name` string
1554        // and an `age` integer. The mutator should produce, at
1555        // minimum: required-removed:name, required-removed:age,
1556        // type-mismatch:name, type-mismatch:age, integer-as-float:age,
1557        // plus the root-level type-mismatch.
1558        let mut obj = ObjectType::default();
1559        obj.properties.insert(
1560            "name".to_string(),
1561            ReferenceOr::Item(Box::new(Schema {
1562                schema_data: SchemaData::default(),
1563                schema_kind: SchemaKind::Type(Type::String(Default::default())),
1564            })),
1565        );
1566        obj.properties.insert(
1567            "age".to_string(),
1568            ReferenceOr::Item(Box::new(Schema {
1569                schema_data: SchemaData::default(),
1570                schema_kind: SchemaKind::Type(Type::Integer(Default::default())),
1571            })),
1572        );
1573        obj.required = vec!["name".into(), "age".into()];
1574        let schema = Schema {
1575            schema_data: SchemaData::default(),
1576            schema_kind: SchemaKind::Type(Type::Object(obj)),
1577        };
1578
1579        let mut o =
1580            op("POST", "/users", Some(r#"{"name":"Ada","age":30}"#), vec![], vec![], vec![]);
1581        o.request_body_schema = Some(schema);
1582        let report = run_self_test(&[o], &cfg).await.expect("client builds");
1583        // Bucket labels from the operation result.
1584        let labels: std::collections::BTreeSet<String> = report
1585            .operations
1586            .iter()
1587            .flat_map(|op| op.negatives.iter().map(|n| n.label.clone()))
1588            .collect();
1589        assert!(
1590            labels.iter().any(|l| l.starts_with("request-body:type-mismatch:")),
1591            "missing type-mismatch negative; got {labels:?}"
1592        );
1593        assert!(
1594            labels.iter().any(|l| l.starts_with("request-body:required-removed:")),
1595            "missing required-removed negative; got {labels:?}"
1596        );
1597        assert!(
1598            labels.iter().any(|l| l == "parameters:uri-too-long"),
1599            "missing URI-length negative; got {labels:?}"
1600        );
1601    }
1602
1603    /// Round 16 — operations with a body OR a path-param now produce
1604    /// negatives even without a sample body. Previously a POST whose
1605    /// body annotator failed produced *zero* negatives, so the self-test
1606    /// always reported "all passing" for that endpoint.
1607    #[tokio::test]
1608    async fn no_sample_body_still_produces_request_body_negatives() {
1609        let cfg = SelfTestConfig {
1610            target_url: "http://127.0.0.1:1".into(),
1611            timeout: Duration::from_millis(200),
1612            ..Default::default()
1613        };
1614        // POST with a body content type but no sample (annotator gap).
1615        let ops = vec![op("POST", "/x", None, vec![], vec![], vec![])];
1616        // No sample_body but request_body_content_type set:
1617        let mut ops_fixed = ops;
1618        ops_fixed[0].request_body_content_type = Some("application/json".into());
1619        let report = run_self_test(&ops_fixed, &cfg).await.expect("client builds");
1620        // Both request-body negatives (empty + wrong-type) should fire,
1621        // landing in `negative_missed` because the unreachable target
1622        // returns no 4xx. The point: count > 0.
1623        assert!(
1624            report.negative_missed.values().sum::<usize>() >= 2,
1625            "expected ≥2 request-body negatives, got {:?}",
1626            report.negative_missed
1627        );
1628    }
1629
1630    /// Round 16 — operations with a path-param now get a probe even
1631    /// when there's no body / required query / required header.
1632    /// Previously `/teams/{team-id}` with no other required fields
1633    /// produced zero negatives → always "all passing".
1634    #[tokio::test]
1635    async fn path_param_only_endpoint_produces_a_probe() {
1636        let cfg = SelfTestConfig {
1637            target_url: "http://127.0.0.1:1".into(),
1638            timeout: Duration::from_millis(200),
1639            ..Default::default()
1640        };
1641        let ops = vec![op(
1642            "GET",
1643            "/teams/{team-id}",
1644            None,
1645            vec![],
1646            vec![],
1647            vec![("team-id", "1")],
1648        )];
1649        let report = run_self_test(&ops, &cfg).await.expect("client builds");
1650        let total: usize = report.negative_caught.values().sum::<usize>()
1651            + report.negative_missed.values().sum::<usize>();
1652        assert!(total >= 1, "expected ≥1 path-param probe, got {:?}", report);
1653    }
1654
1655    /// Round 18.5 — when `geo_ip` is set, every default forwarded-
1656    /// IP header gets the IP appended (X-Forwarded-For,
1657    /// True-Client-IP, CF-Connecting-IP).
1658    #[test]
1659    fn effective_op_headers_appends_geo_ip_to_default_headers() {
1660        let ip: IpAddr = "203.0.113.42".parse().unwrap();
1661        let headers = effective_op_headers(
1662            &[("Accept".into(), "application/json".into())],
1663            Some(ip),
1664            &default_geo_source_headers(),
1665        );
1666        let names: Vec<&str> = headers.iter().map(|(k, _)| k.as_str()).collect();
1667        assert!(names.contains(&"Accept"));
1668        assert!(names.contains(&"X-Forwarded-For"));
1669        assert!(names.contains(&"True-Client-IP"));
1670        assert!(names.contains(&"CF-Connecting-IP"));
1671        // Every geo header carries the same IP value.
1672        let geo_values: Vec<&str> =
1673            headers.iter().filter(|(k, _)| k != "Accept").map(|(_, v)| v.as_str()).collect();
1674        for v in geo_values {
1675            assert_eq!(v, "203.0.113.42");
1676        }
1677    }
1678
1679    /// Round 18.5 — operations that already declare a forwarded-IP
1680    /// header (rare but legal — some specs hard-code one) keep their
1681    /// declared value; we don't clobber the spec.
1682    #[test]
1683    fn effective_op_headers_respects_spec_declared_header() {
1684        let ip: IpAddr = "203.0.113.99".parse().unwrap();
1685        let headers = effective_op_headers(
1686            &[("x-forwarded-for".into(), "10.0.0.1".into())],
1687            Some(ip),
1688            &["X-Forwarded-For".to_string()],
1689        );
1690        // The spec's lower-case value wins; we shouldn't add a
1691        // second X-Forwarded-For row that overrides it.
1692        let xff: Vec<&str> = headers
1693            .iter()
1694            .filter(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-for"))
1695            .map(|(_, v)| v.as_str())
1696            .collect();
1697        assert_eq!(xff, vec!["10.0.0.1"]);
1698    }
1699
1700    /// Round 18.5 — None geo_ip and/or empty header list is a no-op.
1701    #[test]
1702    fn effective_op_headers_is_a_noop_without_geo_ip() {
1703        let base = vec![("Accept".into(), "json".into())];
1704        let h1 = effective_op_headers(&base, None, &default_geo_source_headers());
1705        assert_eq!(h1, base);
1706        let ip: IpAddr = "10.0.0.1".parse().unwrap();
1707        let h2 = effective_op_headers(&base, Some(ip), &[]);
1708        assert_eq!(h2, base);
1709    }
1710
1711    /// Round 18.5 — empty `source_ips` builds a single default
1712    /// client; a non-empty list builds N clients each attempting to
1713    /// bind. We can't reliably test the actual bind on CI (no
1714    /// loopback aliases), but a loopback IP is always bind-able.
1715    #[test]
1716    fn build_client_pool_one_per_source_ip() {
1717        let mut cfg = SelfTestConfig {
1718            target_url: "http://127.0.0.1:1".into(),
1719            timeout: Duration::from_millis(200),
1720            ..Default::default()
1721        };
1722        // Empty → one default client.
1723        assert_eq!(build_client_pool(&cfg).expect("default builds").len(), 1);
1724        // Non-empty → one per IP. Loopback bind is portable.
1725        cfg.source_ips = vec!["127.0.0.1".parse().unwrap()];
1726        assert_eq!(build_client_pool(&cfg).expect("bind loopback").len(), 1);
1727    }
1728
1729    /// Round 18.5 — geo IPs round-robin across operations. Hits an
1730    /// unreachable target so we can inspect the case outcomes; the
1731    /// point is to confirm `op_headers` carried the geo IP through
1732    /// (CaseOutcome doesn't surface headers directly, so we just
1733    /// verify the run completes without panicking and the result
1734    /// shape is correct when source_ips is non-empty too).
1735    #[tokio::test]
1736    async fn run_self_test_with_geo_source_completes() {
1737        let cfg = SelfTestConfig {
1738            target_url: "http://127.0.0.1:1".into(),
1739            timeout: Duration::from_millis(200),
1740            geo_source_ips: vec![
1741                "203.0.113.1".parse().unwrap(),
1742                "203.0.113.2".parse().unwrap(),
1743            ],
1744            ..Default::default()
1745        };
1746        let ops = vec![
1747            op("GET", "/a", None, vec![], vec![], vec![]),
1748            op("GET", "/b", None, vec![], vec![], vec![]),
1749            op("GET", "/c", None, vec![], vec![], vec![]),
1750        ];
1751        let report = run_self_test(&ops, &cfg).await.expect("client builds");
1752        assert_eq!(report.operations.len(), 3);
1753    }
1754
1755    /// Round 24 (f) — Srikanth saw the geo header on positive probes
1756    /// only; the four negative-probe call sites were passing
1757    /// `op.header_params` directly instead of `op_headers`, so the
1758    /// geo IP got dropped. This test runs a self-test that includes
1759    /// negative probes (uri-too-long, missing-query, etc.) under
1760    /// `--conformance-self-test-capture`, then asserts that EVERY
1761    /// captured probe (positive AND negative) carries one of the
1762    /// configured forwarded-IP headers.
1763    #[tokio::test]
1764    async fn geo_headers_present_on_every_probe_with_capture() {
1765        let sink: Arc<Mutex<Vec<CaseCapture>>> = Arc::new(Mutex::new(Vec::new()));
1766        let cfg = SelfTestConfig {
1767            target_url: "http://127.0.0.1:1".into(),
1768            timeout: Duration::from_millis(50),
1769            geo_source_ips: vec!["203.0.113.5".parse().unwrap()],
1770            capture: Some(sink.clone()),
1771            ..Default::default()
1772        };
1773        // An operation rich enough to trip several negative-probe
1774        // branches: header param (→ missing-header), query param
1775        // (→ missing-query), and a sample body (→ schema mutations
1776        // wouldn't fire without a schema, but uri-too-long always
1777        // does).
1778        let ops = vec![op(
1779            "GET",
1780            "/items",
1781            Some("{}"),
1782            vec![("id", "1")],
1783            vec![("X-Trace", "x")],
1784            vec![],
1785        )];
1786        let _ = run_self_test(&ops, &cfg).await.expect("client builds");
1787        let captures = sink.lock().unwrap();
1788        assert!(!captures.is_empty(), "self-test should record probes");
1789        // For every captured probe, at least one of the default geo
1790        // headers must be present and equal to the configured IP.
1791        let geo_headers: std::collections::HashSet<&str> =
1792            ["X-Forwarded-For", "True-Client-IP", "CF-Connecting-IP"].into_iter().collect();
1793        for c in captures.iter() {
1794            let has_geo = c
1795                .request_headers
1796                .iter()
1797                .any(|(k, v)| geo_headers.contains(k.as_str()) && v == "203.0.113.5");
1798            assert!(
1799                has_geo,
1800                "probe `{}` is missing the geo IP header; got headers: {:?}",
1801                c.label, c.request_headers
1802            );
1803        }
1804    }
1805
1806    /// Round 25 (k) — operations with a JSON request body now get four
1807    /// content-type-swap probes (xml / yaml / multipart / urlencoded).
1808    /// Verify they:
1809    ///   1. fire only when the operation declares a JSON body
1810    ///   2. carry the wrong Content-Type the probe is testing for
1811    ///   3. don't fire on body-less operations
1812    #[tokio::test]
1813    async fn content_type_swap_probes_fire_for_json_bodies() {
1814        let sink: Arc<Mutex<Vec<CaseCapture>>> = Arc::new(Mutex::new(Vec::new()));
1815        let cfg = SelfTestConfig {
1816            target_url: "http://127.0.0.1:1".into(),
1817            timeout: Duration::from_millis(50),
1818            capture: Some(sink.clone()),
1819            ..Default::default()
1820        };
1821        let ops = vec![
1822            op("POST", "/users", Some("{\"name\":\"a\"}"), vec![], vec![], vec![]),
1823            op("GET", "/ping", None, vec![], vec![], vec![]),
1824        ];
1825        let _ = run_self_test(&ops, &cfg).await.expect("client builds");
1826        let captures = sink.lock().unwrap();
1827
1828        let swap_labels: Vec<&str> = captures
1829            .iter()
1830            .filter(|c| c.label.starts_with("request-body:content-type-mismatch:"))
1831            .map(|c| c.label.as_str())
1832            .collect();
1833        assert_eq!(
1834            swap_labels.len(),
1835            4,
1836            "expected 4 content-type-swap probes (one per variant), got: {swap_labels:?}"
1837        );
1838        let expected_labels = [
1839            "request-body:content-type-mismatch:xml",
1840            "request-body:content-type-mismatch:yaml",
1841            "request-body:content-type-mismatch:multipart",
1842            "request-body:content-type-mismatch:urlencoded",
1843        ];
1844        for want in expected_labels {
1845            assert!(swap_labels.contains(&want), "missing swap probe `{want}`");
1846        }
1847
1848        // Each swap probe must carry the wrong Content-Type it's
1849        // testing for — that's the whole point.
1850        for c in captures.iter() {
1851            let Some(suffix) = c.label.strip_prefix("request-body:content-type-mismatch:") else {
1852                continue;
1853            };
1854            let want_ct = match suffix {
1855                "xml" => "application/xml",
1856                "yaml" => "application/yaml",
1857                "multipart" => "multipart/form-data",
1858                "urlencoded" => "application/x-www-form-urlencoded",
1859                _ => continue,
1860            };
1861            let got_ct = c
1862                .request_headers
1863                .iter()
1864                .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1865                .map(|(_, v)| v.as_str())
1866                .unwrap_or("");
1867            assert_eq!(got_ct, want_ct, "swap probe `{}` sent wrong CT", c.label);
1868        }
1869
1870        // The body-less operation must NOT produce content-type-swap
1871        // probes (no body → no content type to lie about).
1872        let body_less_swaps = captures
1873            .iter()
1874            .filter(|c| {
1875                c.label.starts_with("request-body:content-type-mismatch:")
1876                    && c.url.ends_with("/ping")
1877            })
1878            .count();
1879        assert_eq!(
1880            body_less_swaps, 0,
1881            "GET /ping has no request body; should not produce content-type-swap probes"
1882        );
1883    }
1884
1885    /// Round 26 — Srikanth saw `at /: Type { kind: Single` in his
1886    /// 0.3.169 capture for the vCenter `infraprofile/configs` 202
1887    /// response (spec promised `type: string`, server returned a
1888    /// JSON object). The output was a broken-syntax debug string.
1889    /// This test reproduces his exact spec+body and asserts the
1890    /// message is readable.
1891    #[test]
1892    fn response_schema_error_message_is_readable() {
1893        let schema = serde_json::json!({"type": "string"});
1894        let body = r#"{"data":{},"id":"generated_id","status":"created"}"#;
1895        let err = validate_body_against_schema(body, &schema).expect("type-mismatch fires");
1896        // The message must NOT contain Rust debug syntax leftovers
1897        // ("Type { kind:", trailing "{" or "(" tokens). It SHOULD say
1898        // what type was expected and at which location.
1899        assert!(!err.contains("Type { kind"), "stale debug output: {err}");
1900        assert!(!err.contains("{ kind:"), "stale debug output: {err}");
1901        assert!(err.contains("string"), "should name expected type: {err}");
1902        assert!(err.contains("at /"), "should include instance path: {err}");
1903    }
1904
1905    #[test]
1906    fn response_schema_error_required_field_is_readable() {
1907        let schema = serde_json::json!({
1908            "type": "object",
1909            "required": ["id"],
1910            "properties": {"id": {"type": "integer"}}
1911        });
1912        let body = r#"{"other": 1}"#;
1913        let err = validate_body_against_schema(body, &schema).expect("required-missing fires");
1914        assert!(err.contains("required field missing"), "{err}");
1915        assert!(err.contains("id"), "{err}");
1916    }
1917
1918    #[test]
1919    fn response_schema_error_none_on_match() {
1920        let schema = serde_json::json!({"type": "string"});
1921        assert_eq!(validate_body_against_schema("\"hello\"", &schema), None);
1922    }
1923
1924    #[test]
1925    fn json_serialises_report() {
1926        let r = SelfTestReport {
1927            positive_pass: 1,
1928            positive_fail: 0,
1929            negative_caught: BTreeMap::new(),
1930            negative_missed: BTreeMap::new(),
1931            operations: vec![OperationResult {
1932                method: "GET".into(),
1933                path: "/x".into(),
1934                positive: Some(CaseOutcome {
1935                    label: "positive".into(),
1936                    expected_4xx: false,
1937                    actual_status: 200,
1938                    passed: true,
1939                }),
1940                negatives: Vec::new(),
1941            }],
1942        };
1943        let json = serde_json::to_value(&r).expect("serialises");
1944        assert_eq!(json["positive_pass"], serde_json::json!(1));
1945        assert_eq!(json["operations"][0]["positive"]["actual_status"], serde_json::json!(200));
1946    }
1947}