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