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