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                            op.header_params.clone(),
451                        )
452                        .await,
453                    );
454                }
455            }
456        }
457    }
458
459    // Round 17.2 — URI-length probe. Spec-agnostic but schema-aware in
460    // spirit: most servers cap URIs at 8 KB or so. Append a 9 KB query
461    // string to the URL and expect 414 URI Too Long (or 400). Skipped
462    // for operations that already have a heavy positive query.
463    {
464        let pad = "p=".to_string() + &"x".repeat(9_000);
465        let bad_url = if url.contains('?') {
466            format!("{url}&{pad}")
467        } else {
468            format!("{url}?{pad}")
469        };
470        negatives.push(
471            send_case(
472                client,
473                config,
474                method.clone(),
475                &bad_url,
476                "parameters:uri-too-long",
477                true,
478                op.sample_body.as_deref(),
479                op.query_params.clone(),
480                op.header_params.clone(),
481            )
482            .await,
483        );
484    }
485
486    // (e) Round 16 — path-param type probe. Send the first path
487    // parameter as a literal `"self-test-invalid-id"`: a string that
488    // contains hyphens, won't parse as an integer, won't parse as a
489    // UUID, and won't match any typical regex pattern. Operations
490    // whose spec types the param as `integer` or `string` with a
491    // `format`/`pattern` will catch this (caught: server returned
492    // 4xx); operations whose spec lets path params be free-form
493    // strings will let it through (missed: server returned 2xx).
494    // Either outcome is informative: a category that's all "missed"
495    // tells the user their spec is loose on path-param types, which
496    // is itself worth knowing. Addresses Srikanth's "always all
497    // passing" report — operations with a path param now produce at
498    // least one probe instead of zero.
499    if !op.path_params.is_empty() {
500        let mut url_with_placeholder = op.path.clone();
501        if let Some((first_name, _)) = op.path_params.first() {
502            // Substitute every other path-param with its sample so the
503            // route shape stays intact and only the first param is bad.
504            for (name, value) in op.path_params.iter().skip(1) {
505                if !value.is_empty() {
506                    url_with_placeholder =
507                        url_with_placeholder.replace(&format!("{{{name}}}"), value);
508                }
509            }
510            // Substitute the first param with a guaranteed-invalid
511            // sentinel that's unlikely to match any reasonable schema:
512            // contains characters disallowed in numeric IDs *and* UUIDs.
513            url_with_placeholder =
514                url_with_placeholder.replace(&format!("{{{first_name}}}"), "self-test-invalid-id");
515            // Round 18.1 — honour `base_path` here too, otherwise the
516            // probe URL differs from the positive case and the
517            // resulting 404 is misattributed to "bad path param".
518            let bad_url = build_url_with_base(
519                &config.target_url,
520                config.base_path.as_deref(),
521                &url_with_placeholder,
522                &[],
523            );
524            negatives.push(
525                send_case(
526                    client,
527                    config,
528                    method.clone(),
529                    &bad_url,
530                    "parameters:bad-path-param",
531                    true,
532                    op.sample_body.as_deref(),
533                    op.query_params.clone(),
534                    op_headers.clone(),
535                )
536                .await,
537            );
538        }
539    }
540
541    // (c) drop the first required query param
542    if !op.query_params.is_empty() {
543        let mut q = op.query_params.clone();
544        q.remove(0);
545        negatives.push(
546            send_case(
547                client,
548                config,
549                method.clone(),
550                &url,
551                "parameters:missing-query",
552                true,
553                op.sample_body.as_deref(),
554                q,
555                op_headers.clone(),
556            )
557            .await,
558        );
559    }
560
561    // (s) Round 17.3 — security probes.
562    //
563    // Operations whose spec declares a security requirement get a
564    // dedicated set of negatives. The point isn't to test whether the
565    // server's *real* auth works (the positive case already does that
566    // via `extra_headers`) — it's to check whether deliberately-bad
567    // credentials are still rejected, which is exactly the failure
568    // mode that lets an attacker through a half-wired validator.
569    //
570    // Each probe replaces or omits the relevant auth credential and
571    // expects 401 / 403. A 2xx here is a hard finding: "spec says
572    // this endpoint is protected, server let unauthenticated /
573    // wrong-credential traffic through".
574    //
575    // Bounded: at most one probe per declared scheme kind, so an
576    // operation with 3 security requirements doesn't 4× the request
577    // volume. Skips entirely when `op.security_schemes` is empty.
578    for probe in build_security_probes(&op.security_schemes) {
579        // Strip any pre-existing Authorization or known API-key
580        // header from extra_headers + header_params so the probe
581        // value is the *only* credential the server sees.
582        let stripped_extra = strip_auth(&config.extra_headers, &op.security_schemes);
583        let stripped_headers = strip_auth(&op.header_params, &op.security_schemes);
584        let stripped_query = strip_auth_query(&op.query_params, &op.security_schemes);
585        let mut req_headers = stripped_headers;
586        for (k, v) in &probe.headers {
587            req_headers.push((k.clone(), v.clone()));
588        }
589        let mut req_query = stripped_query;
590        for (k, v) in &probe.query {
591            req_query.push((k.clone(), v.clone()));
592        }
593        negatives.push(
594            send_case_with_extra(
595                client,
596                config,
597                method.clone(),
598                &url,
599                &probe.label,
600                true,
601                op.sample_body.as_deref(),
602                req_query,
603                req_headers,
604                stripped_extra,
605            )
606            .await,
607        );
608    }
609
610    // (d) drop the first required header
611    if !op.header_params.is_empty() {
612        let mut h = op.header_params.clone();
613        h.remove(0);
614        negatives.push(
615            send_case(
616                client,
617                config,
618                method.clone(),
619                &url,
620                "parameters:missing-header",
621                true,
622                op.sample_body.as_deref(),
623                op.query_params.clone(),
624                h,
625            )
626            .await,
627        );
628    }
629
630    // (w) Round 17.5 — OWASP/WAF unification.
631    //
632    // Pull one canonical payload per OWASP category from the existing
633    // `SecurityPayloads` library and emit an injection probe per
634    // category. Targets in priority order: (1) substitute the first
635    // query param's value, (2) substitute the first string field of
636    // the positive JSON body, (3) skip if neither is available.
637    //
638    // Label format `owasp:<category>`, so the existing
639    // `negative_caught` / `negative_missed` rollup groups all OWASP
640    // findings under one `owasp` bucket. Expected 4xx (server should
641    // reject malicious input). A 5xx is a hard finding (server
642    // crashed on the payload); a 2xx is a soft finding (input passed
643    // through unfiltered — may or may not be a real vuln).
644    //
645    // Bounded: at most one probe per category (7 categories total).
646    // Skips the operation entirely if no injection target is
647    // available — open GET endpoints with no params get zero OWASP
648    // probes, no false signal.
649    for probe in build_owasp_probes(op) {
650        negatives.push(
651            send_case(
652                client,
653                config,
654                method.clone(),
655                &url,
656                &probe.label,
657                true,
658                probe.body.as_deref(),
659                probe.query,
660                op.header_params.clone(),
661            )
662            .await,
663        );
664    }
665
666    OperationResult {
667        method: op.method.clone(),
668        path: op.path.clone(),
669        positive: Some(positive),
670        negatives,
671    }
672}
673
674/// Round 17.5 — one OWASP injection probe to send.
675#[derive(Debug, Clone)]
676struct OwaspProbe {
677    label: String,
678    body: Option<String>,
679    query: Vec<(String, String)>,
680}
681
682/// Build one OWASP probe per `SecurityCategory` for `op`. Targets the
683/// first query param if any, else the first string field of the
684/// positive JSON body. Returns empty if neither target is available.
685fn build_owasp_probes(op: &AnnotatedOperation) -> Vec<OwaspProbe> {
686    use crate::security_payloads::{SecurityCategory, SecurityPayloads};
687
688    let categories = [
689        SecurityCategory::SqlInjection,
690        SecurityCategory::Xss,
691        SecurityCategory::CommandInjection,
692        SecurityCategory::PathTraversal,
693        SecurityCategory::Ssti,
694        SecurityCategory::LdapInjection,
695        SecurityCategory::Xxe,
696    ];
697
698    // Pick an injection target ONCE per operation; reuse it across
699    // categories. (A single op gets up to 7 probes — one per category
700    // — all attacking the same field.)
701    let injection_target = pick_injection_target(op);
702    let Some(target) = injection_target else {
703        return Vec::new();
704    };
705
706    let mut probes = Vec::new();
707    for cat in categories {
708        // Take the *first* payload from each category. The
709        // collection's first entry is the canonical low-risk
710        // representative; later entries include time-based / blind
711        // probes that aren't useful as a one-shot rejection test.
712        let Some(payload) = SecurityPayloads::get_by_category(cat).into_iter().next() else {
713            continue;
714        };
715        let mut query = op.query_params.clone();
716        let mut body = op.sample_body.clone();
717        match &target {
718            InjectionTarget::Query(idx) => {
719                if let Some(slot) = query.get_mut(*idx) {
720                    slot.1 = payload.payload.clone();
721                }
722            }
723            InjectionTarget::BodyStringField(field) => {
724                body = inject_into_body_field(body.as_deref(), field, &payload.payload);
725            }
726        }
727        probes.push(OwaspProbe {
728            label: format!("owasp:{}", cat),
729            body,
730            query,
731        });
732    }
733    probes
734}
735
736#[derive(Debug, Clone)]
737enum InjectionTarget {
738    Query(usize),
739    BodyStringField(String),
740}
741
742fn pick_injection_target(op: &AnnotatedOperation) -> Option<InjectionTarget> {
743    if !op.query_params.is_empty() {
744        return Some(InjectionTarget::Query(0));
745    }
746    let sample = op.sample_body.as_deref()?;
747    let parsed: serde_json::Value = serde_json::from_str(sample).ok()?;
748    let obj = parsed.as_object()?;
749    for (k, v) in obj {
750        if v.is_string() {
751            return Some(InjectionTarget::BodyStringField(k.clone()));
752        }
753    }
754    None
755}
756
757/// Replace the value of `field` in a JSON-object body with `payload`.
758/// Returns the mutated body as a JSON string. Returns `None` if the
759/// body doesn't parse as a JSON object.
760fn inject_into_body_field(body: Option<&str>, field: &str, payload: &str) -> Option<String> {
761    let raw = body?;
762    let mut parsed: serde_json::Value = serde_json::from_str(raw).ok()?;
763    let obj = parsed.as_object_mut()?;
764    obj.insert(field.to_string(), serde_json::json!(payload));
765    serde_json::to_string(&parsed).ok()
766}
767
768#[allow(clippy::too_many_arguments)]
769/// Round 17.3 — one synthesised bad credential to send.
770#[derive(Debug, Clone)]
771struct SecurityProbe {
772    /// Self-test label, e.g. `security:bad-bearer`.
773    label: String,
774    /// Headers to attach to the probe request.
775    headers: Vec<(String, String)>,
776    /// Query parameters to attach (API key in query case).
777    query: Vec<(String, String)>,
778}
779
780/// For each declared security scheme, produce one bad-credential
781/// probe plus a single "no auth at all" probe that exercises the
782/// missing-credential code path. Deduplicates by scheme kind so an
783/// operation declaring `[bearer, bearer]` only yields one Bearer
784/// probe.
785fn build_security_probes(schemes: &[SecuritySchemeInfo]) -> Vec<SecurityProbe> {
786    if schemes.is_empty() {
787        return Vec::new();
788    }
789    let mut probes: Vec<SecurityProbe> = Vec::new();
790    let mut seen_bearer = false;
791    let mut seen_basic = false;
792    // `(loc_tag, name)` — ApiKeyLocation doesn't implement Ord, so
793    // we tag it with a short discriminant string for dedup.
794    let mut seen_apikey: std::collections::BTreeSet<(&'static str, String)> = Default::default();
795    for s in schemes {
796        match s {
797            SecuritySchemeInfo::Bearer if !seen_bearer => {
798                seen_bearer = true;
799                probes.push(SecurityProbe {
800                    label: "security:bad-bearer".into(),
801                    headers: vec![(
802                        "Authorization".into(),
803                        "Bearer self-test-invalid-token".into(),
804                    )],
805                    query: Vec::new(),
806                });
807            }
808            SecuritySchemeInfo::Basic if !seen_basic => {
809                seen_basic = true;
810                // base64("self-test:invalid") — valid base64, wrong creds.
811                probes.push(SecurityProbe {
812                    label: "security:bad-basic".into(),
813                    headers: vec![(
814                        "Authorization".into(),
815                        "Basic c2VsZi10ZXN0OmludmFsaWQ=".into(),
816                    )],
817                    query: Vec::new(),
818                });
819            }
820            SecuritySchemeInfo::ApiKey { location, name } => {
821                let loc_tag = match location {
822                    ApiKeyLocation::Header => "header",
823                    ApiKeyLocation::Query => "query",
824                    ApiKeyLocation::Cookie => "cookie",
825                };
826                if seen_apikey.contains(&(loc_tag, name.clone())) {
827                    continue;
828                }
829                seen_apikey.insert((loc_tag, name.clone()));
830                let label = format!("security:bad-apikey:{}", name);
831                let bad = "self-test-invalid-key".to_string();
832                match location {
833                    ApiKeyLocation::Header => probes.push(SecurityProbe {
834                        label,
835                        headers: vec![(name.clone(), bad)],
836                        query: Vec::new(),
837                    }),
838                    ApiKeyLocation::Query => probes.push(SecurityProbe {
839                        label,
840                        headers: Vec::new(),
841                        query: vec![(name.clone(), bad)],
842                    }),
843                    ApiKeyLocation::Cookie => probes.push(SecurityProbe {
844                        label,
845                        headers: vec![("Cookie".into(), format!("{}={}", name, bad))],
846                        query: Vec::new(),
847                    }),
848                }
849            }
850            _ => {}
851        }
852    }
853    // Always add a "no auth at all" probe when *any* security scheme
854    // is declared — useful even if all schemes failed to resolve to a
855    // testable kind, because it surfaces validators that aren't
856    // checking auth presence at all.
857    probes.push(SecurityProbe {
858        label: "security:no-auth".into(),
859        headers: Vec::new(),
860        query: Vec::new(),
861    });
862    probes
863}
864
865/// Remove Authorization and any API-key headers declared by the
866/// operation's security schemes from `headers`, so a security probe
867/// can supply its own credential (or none) cleanly.
868fn strip_auth(
869    headers: &[(String, String)],
870    schemes: &[SecuritySchemeInfo],
871) -> Vec<(String, String)> {
872    let mut apikey_headers: std::collections::BTreeSet<String> = Default::default();
873    for s in schemes {
874        if let SecuritySchemeInfo::ApiKey {
875            location: ApiKeyLocation::Header,
876            name,
877        } = s
878        {
879            apikey_headers.insert(name.to_lowercase());
880        }
881        if let SecuritySchemeInfo::ApiKey {
882            location: ApiKeyLocation::Cookie,
883            ..
884        } = s
885        {
886            apikey_headers.insert("cookie".into());
887        }
888    }
889    headers
890        .iter()
891        .filter(|(k, _)| {
892            let lk = k.to_lowercase();
893            lk != "authorization" && !apikey_headers.contains(&lk)
894        })
895        .cloned()
896        .collect()
897}
898
899/// Remove API-key query parameters declared by the operation's
900/// security schemes from `query`, so a probe can supply its own.
901fn strip_auth_query(
902    query: &[(String, String)],
903    schemes: &[SecuritySchemeInfo],
904) -> Vec<(String, String)> {
905    let mut apikey_query: std::collections::BTreeSet<String> = Default::default();
906    for s in schemes {
907        if let SecuritySchemeInfo::ApiKey {
908            location: ApiKeyLocation::Query,
909            name,
910        } = s
911        {
912            apikey_query.insert(name.clone());
913        }
914    }
915    query.iter().filter(|(k, _)| !apikey_query.contains(k)).cloned().collect()
916}
917
918/// Variant of `send_case` that takes an explicit `extra_headers`
919/// (rather than reading them from `config`). Used by security probes
920/// to substitute or strip the configured Authorization header.
921#[allow(clippy::too_many_arguments)]
922async fn send_case_with_extra(
923    client: &Client,
924    config: &SelfTestConfig,
925    method: Method,
926    url: &str,
927    label: &str,
928    expected_4xx: bool,
929    body: Option<&str>,
930    query: Vec<(String, String)>,
931    headers: Vec<(String, String)>,
932    extra_headers: Vec<(String, String)>,
933) -> CaseOutcome {
934    let mut req = client.request(method.clone(), url);
935    let mut capture_headers: BTreeMap<String, String> = BTreeMap::new();
936    for (k, v) in &query {
937        req = req.query(&[(k.as_str(), v.as_str())]);
938    }
939    for (k, v) in &headers {
940        req = req.header(k, v);
941        capture_headers.insert(k.clone(), v.clone());
942    }
943    for (k, v) in &extra_headers {
944        req = req.header(k, v);
945        capture_headers.insert(k.clone(), v.clone());
946    }
947    if let Some(b) = body {
948        req = req
949            .header(reqwest::header::CONTENT_TYPE, "application/json")
950            .body(b.to_string());
951        capture_headers.insert("Content-Type".to_string(), "application/json".to_string());
952    }
953    let (actual_status, response_capture) = match req.send().await {
954        Ok(resp) => {
955            let status = resp.status().as_u16();
956            if let Some(sink) = &config.capture {
957                let resp_headers: BTreeMap<String, String> = resp
958                    .headers()
959                    .iter()
960                    .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
961                    .collect();
962                let text = resp.text().await.unwrap_or_default();
963                let (rb, truncated) = truncate_body_for_capture(&text);
964                (status, Some((Some((rb, truncated)), resp_headers, None, sink.clone())))
965            } else {
966                (status, None)
967            }
968        }
969        Err(e) => {
970            let err_str = e.to_string();
971            if let Some(sink) = &config.capture {
972                (0, Some((None, BTreeMap::new(), Some(err_str), sink.clone())))
973            } else {
974                (0, None)
975            }
976        }
977    };
978    let passed = if expected_4xx {
979        (400..500).contains(&actual_status)
980    } else {
981        (200..400).contains(&actual_status)
982    };
983    if let Some((resp_body, resp_headers, error, sink)) = response_capture {
984        let (request_body, request_body_truncated) = match body {
985            Some(b) => {
986                let (rb, t) = truncate_body_for_capture(b);
987                (Some(rb), t)
988            }
989            None => (None, false),
990        };
991        let (response_body, response_body_truncated) = match resp_body {
992            Some((rb, t)) => (Some(rb), t),
993            None => (None, false),
994        };
995        let entry = CaseCapture {
996            label: label.to_string(),
997            method: method.to_string(),
998            url: build_query_url(url, &query),
999            request_headers: capture_headers,
1000            request_body,
1001            request_body_truncated,
1002            response_status: actual_status,
1003            response_headers: resp_headers,
1004            response_body,
1005            response_body_truncated,
1006            error,
1007        };
1008        if let Ok(mut guard) = sink.lock() {
1009            guard.push(entry);
1010        }
1011    }
1012    CaseOutcome {
1013        label: label.to_string(),
1014        expected_4xx,
1015        actual_status,
1016        passed,
1017    }
1018}
1019
1020// HTTP request shape needs all of these: client, config (for capture
1021// sink + extra headers), method, url, label (probe id), expected_4xx
1022// (pass/fail decision), body, query, headers. A struct wrapper would
1023// just move the arity from positional to field access without making
1024// the call sites clearer.
1025#[allow(clippy::too_many_arguments)]
1026async fn send_case(
1027    client: &Client,
1028    config: &SelfTestConfig,
1029    method: Method,
1030    url: &str,
1031    label: &str,
1032    expected_4xx: bool,
1033    body: Option<&str>,
1034    query: Vec<(String, String)>,
1035    headers: Vec<(String, String)>,
1036) -> CaseOutcome {
1037    // Forwarding to `send_case_with_extra` keeps the capture logic in
1038    // one place so request/response tracing can't drift between the
1039    // two entrypoints.
1040    send_case_with_extra(
1041        client,
1042        config,
1043        method,
1044        url,
1045        label,
1046        expected_4xx,
1047        body,
1048        query,
1049        headers,
1050        config.extra_headers.clone(),
1051    )
1052    .await
1053}
1054
1055/// Round 23 (c-iii) — rebuild the query-stringified URL for capture so
1056/// the JSONL trace shows the URL that actually went over the wire
1057/// (reqwest applies `.query(..)` after the request URL string is
1058/// rendered, so capturing the raw `url` argument alone loses the
1059/// query params).
1060fn build_query_url(base: &str, query: &[(String, String)]) -> String {
1061    if query.is_empty() {
1062        return base.to_string();
1063    }
1064    let qs: String = query
1065        .iter()
1066        .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
1067        .collect::<Vec<_>>()
1068        .join("&");
1069    if base.contains('?') {
1070        format!("{base}&{qs}")
1071    } else {
1072        format!("{base}?{qs}")
1073    }
1074}
1075
1076/// Substitute `{param}` placeholders in the spec path with their
1077/// sample values from `path_params`, then prepend `target_url`. Empty
1078/// values are kept as `{param}` so an upstream router still matches
1079/// the template — useful when `path_params` is empty and we want to
1080/// hit the same route the spec defines.
1081///
1082/// All current call sites went through `build_url_with_base` after
1083/// round 18.1, so this no-base-path helper is unused; keep it as the
1084/// documented shim for future external callers (one-arg simplification).
1085#[allow(dead_code)]
1086fn build_url(target: &str, path_template: &str, path_params: &[(String, String)]) -> String {
1087    build_url_with_base(target, None, path_template, path_params)
1088}
1089
1090/// Round 18.1 — variant of `build_url` that takes a `base_path`
1091/// (e.g. `Some("/api")`). When set, prepends it to the spec path so a
1092/// spec declaring `/users` against a target served behind `/api`
1093/// resolves to `<target>/api/users`. `base_path` is normalised: leading
1094/// `/` is auto-added, trailing `/` is stripped.
1095fn build_url_with_base(
1096    target: &str,
1097    base_path: Option<&str>,
1098    path_template: &str,
1099    path_params: &[(String, String)],
1100) -> String {
1101    let mut url = path_template.to_string();
1102    for (name, value) in path_params {
1103        let placeholder = format!("{{{}}}", name);
1104        if !value.is_empty() {
1105            url = url.replace(&placeholder, value);
1106        }
1107    }
1108    let target = target.trim_end_matches('/');
1109    let prefix = match base_path {
1110        Some(bp) if !bp.is_empty() => {
1111            let trimmed = bp.trim_end_matches('/');
1112            if trimmed.starts_with('/') {
1113                trimmed.to_string()
1114            } else {
1115                format!("/{}", trimmed)
1116            }
1117        }
1118        _ => String::new(),
1119    };
1120    let path = if url.starts_with('/') {
1121        url
1122    } else {
1123        format!("/{url}")
1124    };
1125    format!("{target}{prefix}{path}")
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130    use super::*;
1131
1132    fn op(
1133        method: &str,
1134        path: &str,
1135        body: Option<&str>,
1136        query: Vec<(&str, &str)>,
1137        headers: Vec<(&str, &str)>,
1138        path_params: Vec<(&str, &str)>,
1139    ) -> AnnotatedOperation {
1140        AnnotatedOperation {
1141            method: method.into(),
1142            path: path.into(),
1143            features: Vec::new(),
1144            request_body_content_type: body.map(|_| "application/json".into()),
1145            sample_body: body.map(|s| s.to_string()),
1146            query_params: query.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1147            header_params: headers.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1148            path_params: path_params.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1149            response_schema: None,
1150            request_body_schema: None,
1151            security_schemes: Vec::new(),
1152        }
1153    }
1154
1155    #[test]
1156    fn build_url_substitutes_path_params() {
1157        let url = build_url(
1158            "https://api.test/",
1159            "/users/{id}/posts/{pid}",
1160            &[("id".into(), "42".into()), ("pid".into(), "7".into())],
1161        );
1162        assert_eq!(url, "https://api.test/users/42/posts/7");
1163    }
1164
1165    /// Round 18.1 — a run where every positive 404s should be flagged
1166    /// as a likely target misconfiguration, not silently treated as a
1167    /// successful conformance run.
1168    #[test]
1169    fn detect_target_misconfiguration_when_all_positives_share_status() {
1170        let mut report = SelfTestReport {
1171            positive_pass: 0,
1172            positive_fail: 50,
1173            ..Default::default()
1174        };
1175        for i in 0..50 {
1176            report.operations.push(OperationResult {
1177                method: "GET".into(),
1178                path: format!("/r/{i}"),
1179                positive: Some(CaseOutcome {
1180                    label: "positive".into(),
1181                    expected_4xx: false,
1182                    actual_status: 404,
1183                    passed: false,
1184                }),
1185                negatives: Vec::new(),
1186            });
1187        }
1188        assert_eq!(report.detect_target_misconfiguration(), Some(404));
1189    }
1190
1191    #[test]
1192    fn detect_target_misconfiguration_returns_none_when_some_pass() {
1193        let mut report = SelfTestReport {
1194            positive_pass: 5,
1195            positive_fail: 50,
1196            ..Default::default()
1197        };
1198        for i in 0..55 {
1199            report.operations.push(OperationResult {
1200                method: "GET".into(),
1201                path: format!("/r/{i}"),
1202                positive: Some(CaseOutcome {
1203                    label: "positive".into(),
1204                    expected_4xx: false,
1205                    actual_status: if i < 5 { 200 } else { 404 },
1206                    passed: i < 5,
1207                }),
1208                negatives: Vec::new(),
1209            });
1210        }
1211        assert_eq!(report.detect_target_misconfiguration(), None);
1212    }
1213
1214    /// Round 18.1 — `--base-path /api` should prepend `/api` to
1215    /// every spec path. Pre-fix, the self-test ignored base_path and
1216    /// 404'd every positive when the deployed API was behind a path
1217    /// prefix.
1218    #[test]
1219    fn build_url_applies_base_path_when_present() {
1220        let url = build_url_with_base(
1221            "https://api.example.com",
1222            Some("/api"),
1223            "/users/{id}",
1224            &[("id".into(), "42".into())],
1225        );
1226        assert_eq!(url, "https://api.example.com/api/users/42");
1227    }
1228
1229    /// Round 18.1 — base_path is normalised: missing leading slash
1230    /// gets one added, trailing slash is stripped, empty string is
1231    /// the same as None.
1232    #[test]
1233    fn build_url_normalises_base_path() {
1234        let no_slash = build_url_with_base("https://t", Some("api"), "/x", &[]);
1235        assert_eq!(no_slash, "https://t/api/x");
1236        let trailing = build_url_with_base("https://t", Some("/api/"), "/x", &[]);
1237        assert_eq!(trailing, "https://t/api/x");
1238        let empty = build_url_with_base("https://t", Some(""), "/x", &[]);
1239        assert_eq!(empty, "https://t/x");
1240        let none = build_url_with_base("https://t", None, "/x", &[]);
1241        assert_eq!(none, "https://t/x");
1242    }
1243
1244    #[test]
1245    fn build_url_keeps_placeholders_when_no_sample() {
1246        let url = build_url("https://api.test", "/users/{id}", &[]);
1247        assert_eq!(url, "https://api.test/users/{id}");
1248    }
1249
1250    #[test]
1251    fn report_summary_calls_out_misses() {
1252        let r = SelfTestReport {
1253            positive_pass: 3,
1254            positive_fail: 0,
1255            negative_caught: BTreeMap::from([("request-body".into(), 2)]),
1256            negative_missed: BTreeMap::from([("request-body".into(), 1)]),
1257            operations: Vec::new(),
1258        };
1259        let summary = r.render_summary();
1260        assert!(summary.contains("Positives: 3 pass / 0 fail"));
1261        assert!(summary.contains("Negatives [request-body]: 2 caught / 1 missed"));
1262        assert!(summary.contains("⚠"));
1263        assert!(!r.all_passed());
1264    }
1265
1266    #[test]
1267    fn report_all_passed_when_no_miss() {
1268        let r = SelfTestReport {
1269            positive_pass: 5,
1270            positive_fail: 0,
1271            negative_caught: BTreeMap::from([("parameters".into(), 3)]),
1272            negative_missed: BTreeMap::new(),
1273            operations: Vec::new(),
1274        };
1275        assert!(r.all_passed());
1276        assert!(r.render_summary().contains("✓"));
1277    }
1278
1279    #[tokio::test]
1280    async fn run_self_test_against_unreachable_target_marks_all_failed() {
1281        // Use an obviously-dead port so we exercise the timeout/error
1282        // path without needing a live server in tests.
1283        let cfg = SelfTestConfig {
1284            target_url: "http://127.0.0.1:1".into(),
1285            timeout: Duration::from_millis(200),
1286            ..Default::default()
1287        };
1288        let ops = vec![op(
1289            "POST",
1290            "/users",
1291            Some("{\"name\":\"a\"}"),
1292            vec![],
1293            vec![],
1294            vec![],
1295        )];
1296        let report = run_self_test(&ops, &cfg).await.expect("client builds");
1297        // All cases hit the connect-error path → actual_status=0.
1298        // Positive expects 2xx-3xx → 0 is fail. Negatives expect 4xx
1299        // → 0 is also fail (we missed catching).
1300        assert_eq!(report.positive_fail, 1);
1301        assert!(report.negative_missed.values().sum::<usize>() >= 1);
1302        assert!(!report.all_passed());
1303    }
1304
1305    /// Round 17.2 — operations with both a positive sample AND a
1306    /// resolved request-body schema produce schema-driven negatives
1307    /// in addition to the spec-agnostic empty/wrong-type ones. The
1308    /// labels carry the field path so a per-category report can tell
1309    /// you exactly which field caught.
1310    #[tokio::test]
1311    async fn schema_driven_negatives_fire_when_schema_present() {
1312        use openapiv3::{ObjectType, ReferenceOr, Schema, SchemaData, SchemaKind, Type};
1313        let cfg = SelfTestConfig {
1314            target_url: "http://127.0.0.1:1".into(),
1315            timeout: Duration::from_millis(200),
1316            ..Default::default()
1317        };
1318        // Build an operation whose schema has a required `name` string
1319        // and an `age` integer. The mutator should produce, at
1320        // minimum: required-removed:name, required-removed:age,
1321        // type-mismatch:name, type-mismatch:age, integer-as-float:age,
1322        // plus the root-level type-mismatch.
1323        let mut obj = ObjectType::default();
1324        obj.properties.insert(
1325            "name".to_string(),
1326            ReferenceOr::Item(Box::new(Schema {
1327                schema_data: SchemaData::default(),
1328                schema_kind: SchemaKind::Type(Type::String(Default::default())),
1329            })),
1330        );
1331        obj.properties.insert(
1332            "age".to_string(),
1333            ReferenceOr::Item(Box::new(Schema {
1334                schema_data: SchemaData::default(),
1335                schema_kind: SchemaKind::Type(Type::Integer(Default::default())),
1336            })),
1337        );
1338        obj.required = vec!["name".into(), "age".into()];
1339        let schema = Schema {
1340            schema_data: SchemaData::default(),
1341            schema_kind: SchemaKind::Type(Type::Object(obj)),
1342        };
1343
1344        let mut o =
1345            op("POST", "/users", Some(r#"{"name":"Ada","age":30}"#), vec![], vec![], vec![]);
1346        o.request_body_schema = Some(schema);
1347        let report = run_self_test(&[o], &cfg).await.expect("client builds");
1348        // Bucket labels from the operation result.
1349        let labels: std::collections::BTreeSet<String> = report
1350            .operations
1351            .iter()
1352            .flat_map(|op| op.negatives.iter().map(|n| n.label.clone()))
1353            .collect();
1354        assert!(
1355            labels.iter().any(|l| l.starts_with("request-body:type-mismatch:")),
1356            "missing type-mismatch negative; got {labels:?}"
1357        );
1358        assert!(
1359            labels.iter().any(|l| l.starts_with("request-body:required-removed:")),
1360            "missing required-removed negative; got {labels:?}"
1361        );
1362        assert!(
1363            labels.iter().any(|l| l == "parameters:uri-too-long"),
1364            "missing URI-length negative; got {labels:?}"
1365        );
1366    }
1367
1368    /// Round 16 — operations with a body OR a path-param now produce
1369    /// negatives even without a sample body. Previously a POST whose
1370    /// body annotator failed produced *zero* negatives, so the self-test
1371    /// always reported "all passing" for that endpoint.
1372    #[tokio::test]
1373    async fn no_sample_body_still_produces_request_body_negatives() {
1374        let cfg = SelfTestConfig {
1375            target_url: "http://127.0.0.1:1".into(),
1376            timeout: Duration::from_millis(200),
1377            ..Default::default()
1378        };
1379        // POST with a body content type but no sample (annotator gap).
1380        let ops = vec![op("POST", "/x", None, vec![], vec![], vec![])];
1381        // No sample_body but request_body_content_type set:
1382        let mut ops_fixed = ops;
1383        ops_fixed[0].request_body_content_type = Some("application/json".into());
1384        let report = run_self_test(&ops_fixed, &cfg).await.expect("client builds");
1385        // Both request-body negatives (empty + wrong-type) should fire,
1386        // landing in `negative_missed` because the unreachable target
1387        // returns no 4xx. The point: count > 0.
1388        assert!(
1389            report.negative_missed.values().sum::<usize>() >= 2,
1390            "expected ≥2 request-body negatives, got {:?}",
1391            report.negative_missed
1392        );
1393    }
1394
1395    /// Round 16 — operations with a path-param now get a probe even
1396    /// when there's no body / required query / required header.
1397    /// Previously `/teams/{team-id}` with no other required fields
1398    /// produced zero negatives → always "all passing".
1399    #[tokio::test]
1400    async fn path_param_only_endpoint_produces_a_probe() {
1401        let cfg = SelfTestConfig {
1402            target_url: "http://127.0.0.1:1".into(),
1403            timeout: Duration::from_millis(200),
1404            ..Default::default()
1405        };
1406        let ops = vec![op(
1407            "GET",
1408            "/teams/{team-id}",
1409            None,
1410            vec![],
1411            vec![],
1412            vec![("team-id", "1")],
1413        )];
1414        let report = run_self_test(&ops, &cfg).await.expect("client builds");
1415        let total: usize = report.negative_caught.values().sum::<usize>()
1416            + report.negative_missed.values().sum::<usize>();
1417        assert!(total >= 1, "expected ≥1 path-param probe, got {:?}", report);
1418    }
1419
1420    /// Round 18.5 — when `geo_ip` is set, every default forwarded-
1421    /// IP header gets the IP appended (X-Forwarded-For,
1422    /// True-Client-IP, CF-Connecting-IP).
1423    #[test]
1424    fn effective_op_headers_appends_geo_ip_to_default_headers() {
1425        let ip: IpAddr = "203.0.113.42".parse().unwrap();
1426        let headers = effective_op_headers(
1427            &[("Accept".into(), "application/json".into())],
1428            Some(ip),
1429            &default_geo_source_headers(),
1430        );
1431        let names: Vec<&str> = headers.iter().map(|(k, _)| k.as_str()).collect();
1432        assert!(names.contains(&"Accept"));
1433        assert!(names.contains(&"X-Forwarded-For"));
1434        assert!(names.contains(&"True-Client-IP"));
1435        assert!(names.contains(&"CF-Connecting-IP"));
1436        // Every geo header carries the same IP value.
1437        let geo_values: Vec<&str> =
1438            headers.iter().filter(|(k, _)| k != "Accept").map(|(_, v)| v.as_str()).collect();
1439        for v in geo_values {
1440            assert_eq!(v, "203.0.113.42");
1441        }
1442    }
1443
1444    /// Round 18.5 — operations that already declare a forwarded-IP
1445    /// header (rare but legal — some specs hard-code one) keep their
1446    /// declared value; we don't clobber the spec.
1447    #[test]
1448    fn effective_op_headers_respects_spec_declared_header() {
1449        let ip: IpAddr = "203.0.113.99".parse().unwrap();
1450        let headers = effective_op_headers(
1451            &[("x-forwarded-for".into(), "10.0.0.1".into())],
1452            Some(ip),
1453            &["X-Forwarded-For".to_string()],
1454        );
1455        // The spec's lower-case value wins; we shouldn't add a
1456        // second X-Forwarded-For row that overrides it.
1457        let xff: Vec<&str> = headers
1458            .iter()
1459            .filter(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-for"))
1460            .map(|(_, v)| v.as_str())
1461            .collect();
1462        assert_eq!(xff, vec!["10.0.0.1"]);
1463    }
1464
1465    /// Round 18.5 — None geo_ip and/or empty header list is a no-op.
1466    #[test]
1467    fn effective_op_headers_is_a_noop_without_geo_ip() {
1468        let base = vec![("Accept".into(), "json".into())];
1469        let h1 = effective_op_headers(&base, None, &default_geo_source_headers());
1470        assert_eq!(h1, base);
1471        let ip: IpAddr = "10.0.0.1".parse().unwrap();
1472        let h2 = effective_op_headers(&base, Some(ip), &[]);
1473        assert_eq!(h2, base);
1474    }
1475
1476    /// Round 18.5 — empty `source_ips` builds a single default
1477    /// client; a non-empty list builds N clients each attempting to
1478    /// bind. We can't reliably test the actual bind on CI (no
1479    /// loopback aliases), but a loopback IP is always bind-able.
1480    #[test]
1481    fn build_client_pool_one_per_source_ip() {
1482        let mut cfg = SelfTestConfig {
1483            target_url: "http://127.0.0.1:1".into(),
1484            timeout: Duration::from_millis(200),
1485            ..Default::default()
1486        };
1487        // Empty → one default client.
1488        assert_eq!(build_client_pool(&cfg).expect("default builds").len(), 1);
1489        // Non-empty → one per IP. Loopback bind is portable.
1490        cfg.source_ips = vec!["127.0.0.1".parse().unwrap()];
1491        assert_eq!(build_client_pool(&cfg).expect("bind loopback").len(), 1);
1492    }
1493
1494    /// Round 18.5 — geo IPs round-robin across operations. Hits an
1495    /// unreachable target so we can inspect the case outcomes; the
1496    /// point is to confirm `op_headers` carried the geo IP through
1497    /// (CaseOutcome doesn't surface headers directly, so we just
1498    /// verify the run completes without panicking and the result
1499    /// shape is correct when source_ips is non-empty too).
1500    #[tokio::test]
1501    async fn run_self_test_with_geo_source_completes() {
1502        let cfg = SelfTestConfig {
1503            target_url: "http://127.0.0.1:1".into(),
1504            timeout: Duration::from_millis(200),
1505            geo_source_ips: vec![
1506                "203.0.113.1".parse().unwrap(),
1507                "203.0.113.2".parse().unwrap(),
1508            ],
1509            ..Default::default()
1510        };
1511        let ops = vec![
1512            op("GET", "/a", None, vec![], vec![], vec![]),
1513            op("GET", "/b", None, vec![], vec![], vec![]),
1514            op("GET", "/c", None, vec![], vec![], vec![]),
1515        ];
1516        let report = run_self_test(&ops, &cfg).await.expect("client builds");
1517        assert_eq!(report.operations.len(), 3);
1518    }
1519
1520    #[test]
1521    fn json_serialises_report() {
1522        let r = SelfTestReport {
1523            positive_pass: 1,
1524            positive_fail: 0,
1525            negative_caught: BTreeMap::new(),
1526            negative_missed: BTreeMap::new(),
1527            operations: vec![OperationResult {
1528                method: "GET".into(),
1529                path: "/x".into(),
1530                positive: Some(CaseOutcome {
1531                    label: "positive".into(),
1532                    expected_4xx: false,
1533                    actual_status: 200,
1534                    passed: true,
1535                }),
1536                negatives: Vec::new(),
1537            }],
1538        };
1539        let json = serde_json::to_value(&r).expect("serialises");
1540        assert_eq!(json["positive_pass"], serde_json::json!(1));
1541        assert_eq!(json["operations"][0]["positive"]["actual_status"], serde_json::json!(200));
1542    }
1543}