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