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