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