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