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