Skip to main content

mockforge_bench/conformance/
request_validator.rs

1//! Request validation against OpenAPI spec.
2//!
3//! Validates that conformance test requests (especially from HAR custom checks)
4//! conform to the OpenAPI specification: correct paths, required parameters,
5//! valid request body schemas, and matching content types.
6
7use crate::error::Result;
8use crate::spec_parser::SpecParser;
9use openapiv3::{OpenAPI, ReferenceOr};
10use serde::Serialize;
11use std::collections::HashMap;
12use std::path::Path;
13
14use super::custom::CustomConformanceConfig;
15
16/// A single request validation violation
17#[derive(Debug, Serialize)]
18pub struct RequestViolation {
19    /// Check name from the custom YAML
20    pub check_name: String,
21    /// Request method
22    pub method: String,
23    /// Request path
24    pub path: String,
25    /// Type of violation
26    pub violation_type: String,
27    /// Human-readable description
28    pub message: String,
29}
30
31/// Validate custom conformance checks against an OpenAPI spec.
32///
33/// Returns a list of violations (empty if all checks are valid).
34pub fn validate_custom_checks(
35    spec: &OpenAPI,
36    custom_checks_file: &Path,
37    base_path: Option<&str>,
38) -> Result<Vec<RequestViolation>> {
39    let config = CustomConformanceConfig::from_file(custom_checks_file)?;
40    let mut violations = Vec::new();
41
42    // Build a map of spec paths -> operations for matching
43    let spec_ops = build_spec_operation_map(spec);
44
45    for check in &config.custom_checks {
46        // Strip query string from path for matching
47        let check_path = check.path.split('?').next().unwrap_or(&check.path);
48
49        // Try to match the check's path to a spec operation
50        let spec_path = match find_matching_spec_path(check_path, &spec_ops, base_path) {
51            Some(p) => p,
52            None => {
53                violations.push(RequestViolation {
54                    check_name: check.name.clone(),
55                    method: check.method.clone(),
56                    path: check.path.clone(),
57                    violation_type: "unknown_path".to_string(),
58                    message: format!(
59                        "Path '{}' not found in OpenAPI spec (checked with base_path={:?})",
60                        check_path, base_path
61                    ),
62                });
63                continue;
64            }
65        };
66
67        // Check if the method is defined for this path
68        let path_item = match spec.paths.paths.get(&spec_path) {
69            Some(ReferenceOr::Item(item)) => item,
70            _ => continue,
71        };
72
73        let method_lower = check.method.to_lowercase();
74        let operation = match method_lower.as_str() {
75            "get" => path_item.get.as_ref(),
76            "post" => path_item.post.as_ref(),
77            "put" => path_item.put.as_ref(),
78            "delete" => path_item.delete.as_ref(),
79            "patch" => path_item.patch.as_ref(),
80            "head" => path_item.head.as_ref(),
81            "options" => path_item.options.as_ref(),
82            _ => None,
83        };
84
85        let operation = match operation {
86            Some(op) => op,
87            None => {
88                violations.push(RequestViolation {
89                    check_name: check.name.clone(),
90                    method: check.method.clone(),
91                    path: check.path.clone(),
92                    violation_type: "method_not_allowed".to_string(),
93                    message: format!(
94                        "Method '{}' not defined for path '{}' in the spec",
95                        check.method, spec_path
96                    ),
97                });
98                continue;
99            }
100        };
101
102        // Validate request body for POST/PUT/PATCH
103        if matches!(method_lower.as_str(), "post" | "put" | "patch") {
104            validate_request_body(
105                &check.name,
106                &check.method,
107                &check.path,
108                check.body.as_deref(),
109                operation,
110                spec,
111                &mut violations,
112            );
113        }
114
115        // Check required parameters
116        validate_parameters(
117            &check.name,
118            &check.method,
119            &check.path,
120            check_path,
121            &check.headers,
122            operation,
123            path_item,
124            spec,
125            &mut violations,
126        );
127    }
128
129    Ok(violations)
130}
131
132/// Collected spec operations indexed by path
133type SpecOperationMap = HashMap<String, Vec<String>>; // path -> [methods]
134
135fn build_spec_operation_map(spec: &OpenAPI) -> SpecOperationMap {
136    let mut map = HashMap::new();
137    for (path, item_ref) in &spec.paths.paths {
138        if let ReferenceOr::Item(item) = item_ref {
139            let mut methods = Vec::new();
140            if item.get.is_some() {
141                methods.push("GET".to_string());
142            }
143            if item.post.is_some() {
144                methods.push("POST".to_string());
145            }
146            if item.put.is_some() {
147                methods.push("PUT".to_string());
148            }
149            if item.delete.is_some() {
150                methods.push("DELETE".to_string());
151            }
152            if item.patch.is_some() {
153                methods.push("PATCH".to_string());
154            }
155            if item.head.is_some() {
156                methods.push("HEAD".to_string());
157            }
158            if item.options.is_some() {
159                methods.push("OPTIONS".to_string());
160            }
161            map.insert(path.clone(), methods);
162        }
163    }
164    map
165}
166
167/// Try to match a concrete path (e.g., "/users/123") to a spec path template
168/// (e.g., "/users/{id}"). Handles base_path stripping.
169fn find_matching_spec_path(
170    check_path: &str,
171    spec_ops: &SpecOperationMap,
172    base_path: Option<&str>,
173) -> Option<String> {
174    // Try exact match first
175    if spec_ops.contains_key(check_path) {
176        return Some(check_path.to_string());
177    }
178
179    // Try with base_path prepended
180    if let Some(bp) = base_path {
181        let with_base = format!("{}{}", bp.trim_end_matches('/'), check_path);
182        if spec_ops.contains_key(&with_base) {
183            return Some(with_base);
184        }
185    }
186
187    // Try template matching (e.g., /users/123 matches /users/{id})
188    for spec_path in spec_ops.keys() {
189        if path_matches_template(check_path, spec_path)
190            || base_path
191                .map(|bp| {
192                    let with_base = format!("{}{}", bp.trim_end_matches('/'), check_path);
193                    path_matches_template(&with_base, spec_path)
194                })
195                .unwrap_or(false)
196        {
197            return Some(spec_path.clone());
198        }
199    }
200
201    None
202}
203
204/// Match a single templated path segment against a concrete one and, on
205/// success, return the bound `(param_name, value)` when the segment carries a
206/// `{param}` placeholder.
207///
208/// Handles a plain `{name}`, a literal segment (`v1`), AND — Round 54 (#79) —
209/// a single `{name}` wrapped in literals, i.e. Google's custom-verb form
210/// `{instance}:reportStatus` (also `prefix-{name}`). Multi-placeholder
211/// segments (`{a}-{b}`) fall back to plain equality. Returns:
212///   - `Some(None)` when the segment matches with no bound param (literal),
213///   - `Some(Some((name, value)))` when it matches and binds a param,
214///   - `None` when it does not match.
215fn match_path_segment<'a>(
216    template_seg: &'a str,
217    concrete_seg: &str,
218) -> Option<Option<(&'a str, String)>> {
219    let open = template_seg.find('{');
220    let close = template_seg.find('}');
221    match (open, close) {
222        (Some(o), Some(c)) if c > o && !template_seg[c + 1..].contains('{') => {
223            let prefix = &template_seg[..o];
224            let name = &template_seg[o + 1..c];
225            let suffix = &template_seg[c + 1..];
226            if concrete_seg.starts_with(prefix)
227                && concrete_seg.ends_with(suffix)
228                && concrete_seg.len() >= prefix.len() + suffix.len()
229            {
230                let value = &concrete_seg[prefix.len()..concrete_seg.len() - suffix.len()];
231                Some(Some((name, value.to_string())))
232            } else {
233                None
234            }
235        }
236        // No single clean placeholder — require literal equality.
237        _ => {
238            if template_seg == concrete_seg {
239                Some(None)
240            } else {
241                None
242            }
243        }
244    }
245}
246
247/// Round 56 (#79) — human-readable note for a `parameters:*` negative probe
248/// that produced no hard spec breach, so the log explains WHY it isn't a
249/// schema violation while still recording that the probe was fired.
250fn describe_parameter_probe(check: &str) -> String {
251    if check.starts_with("parameters:missing-query") {
252        "negative probe omitted a query parameter; not a contract breach when the \
253         parameter is optional (a required one is reported as query_missing_required). \
254         The target should still validate it."
255            .to_string()
256    } else if check.starts_with("parameters:uri-too-long") {
257        "negative probe added an oversized / undeclared query parameter; OpenAPI does \
258         not constrain URI length or forbid extra query params, so this is not a schema \
259         breach. Exercises the target's URI-length / unknown-param handling."
260            .to_string()
261    } else if check.starts_with("parameters:bad-path-param") {
262        "negative probe injected an invalid-looking path parameter value; it satisfied \
263         the declared schema (no enum/pattern/length constraint to violate), so it is not \
264         a schema breach (a constrained one is reported as path_value_mismatch)."
265            .to_string()
266    } else {
267        "parameter negative probe emitted; no client-side schema breach detected (the \
268         mutation is permitted by the contract, so rejection is the target's responsibility)."
269            .to_string()
270    }
271}
272
273/// Collapse runs of `/` into a single `/` (preserving a leading slash).
274/// `//v1//x` -> `/v1/x`. Round 55 (#79) — guards path matching against the
275/// `--base-path /` double-slash regression.
276fn collapse_slashes(path: &str) -> String {
277    let mut out = String::with_capacity(path.len());
278    let mut prev_slash = false;
279    for ch in path.chars() {
280        if ch == '/' {
281            if !prev_slash {
282                out.push(ch);
283            }
284            prev_slash = true;
285        } else {
286            out.push(ch);
287            prev_slash = false;
288        }
289    }
290    out
291}
292
293/// Check if a concrete path matches a path template with {param} segments
294fn path_matches_template(concrete: &str, template: &str) -> bool {
295    let concrete_parts: Vec<&str> = concrete.split('/').collect();
296    let template_parts: Vec<&str> = template.split('/').collect();
297
298    if concrete_parts.len() != template_parts.len() {
299        return false;
300    }
301
302    concrete_parts
303        .iter()
304        .zip(template_parts.iter())
305        .all(|(c, t)| match_path_segment(t, c).is_some())
306}
307
308/// Validate request body against the spec's requestBody schema
309#[allow(clippy::too_many_arguments)]
310fn validate_request_body(
311    check_name: &str,
312    method: &str,
313    path: &str,
314    body: Option<&str>,
315    operation: &openapiv3::Operation,
316    spec: &OpenAPI,
317    violations: &mut Vec<RequestViolation>,
318) {
319    let request_body_ref = match &operation.request_body {
320        Some(rb) => rb,
321        None => {
322            // Spec doesn't define a requestBody — body is optional
323            return;
324        }
325    };
326
327    // Resolve $ref if needed
328    let request_body = match request_body_ref {
329        ReferenceOr::Item(rb) => rb,
330        ReferenceOr::Reference { reference } => {
331            let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
332            match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
333                Some(ReferenceOr::Item(rb)) => rb,
334                _ => return,
335            }
336        }
337    };
338
339    // Check if body is required but missing
340    if request_body.required && body.is_none() {
341        violations.push(RequestViolation {
342            check_name: check_name.to_string(),
343            method: method.to_string(),
344            path: path.to_string(),
345            violation_type: "missing_required_body".to_string(),
346            message: "Spec requires a request body but none is provided in the check".to_string(),
347        });
348        return;
349    }
350
351    // If body is provided, validate against schema
352    if let Some(body_str) = body {
353        // Find JSON content type
354        let json_media = request_body.content.get("application/json").or_else(|| {
355            request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v)
356        });
357
358        if let Some(media) = json_media {
359            if let Some(schema_ref) = &media.schema {
360                // Resolve the immediate $ref (one level) to get the
361                // root schema, then hand both schema + spec to the
362                // ref-resolver helper so nested `$ref` strings (e.g.
363                // `#/components/schemas/Vcenter.VM.DiskCloneSpec`)
364                // resolve against the full document context.
365                //
366                // Round 18.3 — pre-fix this called
367                // `jsonschema::validator_for(&schema_json)` directly,
368                // which used the inner schema as the validator's
369                // document. Nested $refs to `#/components/schemas/X`
370                // then failed with "Pointer '...' does not exist"
371                // because the validator's document had no
372                // `components` key (Srikanth's vCenter run: 157
373                // violations).
374                let root_schema = match schema_ref {
375                    ReferenceOr::Item(s) => s.clone(),
376                    ReferenceOr::Reference { reference } => {
377                        let name =
378                            reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
379                        match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
380                            Some(ReferenceOr::Item(s)) => s.clone(),
381                            _ => return,
382                        }
383                    }
384                };
385
386                // Parse body as JSON and validate against schema
387                match serde_json::from_str::<serde_json::Value>(body_str) {
388                    Ok(body_value) => {
389                        match mockforge_openapi::schema_ref_resolver::build_validator(
390                            &root_schema,
391                            spec,
392                        ) {
393                            Ok(validator) => {
394                                let errors: Vec<_> = validator.iter_errors(&body_value).collect();
395                                for err in errors.iter().take(5) {
396                                    violations.push(RequestViolation {
397                                        check_name: check_name.to_string(),
398                                        method: method.to_string(),
399                                        path: path.to_string(),
400                                        violation_type: "body_schema_violation".to_string(),
401                                        message: format!(
402                                            "Request body schema violation at {}: {}",
403                                            err.instance_path, err
404                                        ),
405                                    });
406                                }
407                            }
408                            Err(_) => {
409                                // Schema itself is invalid — skip validation
410                            }
411                        }
412                    }
413                    Err(e) => {
414                        violations.push(RequestViolation {
415                            check_name: check_name.to_string(),
416                            method: method.to_string(),
417                            path: path.to_string(),
418                            violation_type: "body_not_json".to_string(),
419                            message: format!("Request body is not valid JSON: {}", e),
420                        });
421                    }
422                }
423            }
424        }
425    }
426}
427
428/// Validate required parameters from the spec
429#[allow(clippy::too_many_arguments)]
430fn validate_parameters(
431    check_name: &str,
432    method: &str,
433    path: &str,
434    check_path_no_query: &str,
435    check_headers: &HashMap<String, String>,
436    operation: &openapiv3::Operation,
437    path_item: &openapiv3::PathItem,
438    spec: &OpenAPI,
439    violations: &mut Vec<RequestViolation>,
440) {
441    // Collect all parameters (path-level + operation-level)
442    let mut all_params = Vec::new();
443    for p in &path_item.parameters {
444        if let Some(param) = resolve_parameter(p, spec) {
445            all_params.push(param);
446        }
447    }
448    for p in &operation.parameters {
449        if let Some(param) = resolve_parameter(p, spec) {
450            all_params.push(param);
451        }
452    }
453
454    for param in &all_params {
455        let param_data = match param {
456            openapiv3::Parameter::Query { parameter_data, .. } => {
457                if !parameter_data.required {
458                    continue;
459                }
460                // Check if query param is in the path's query string
461                let has_param = check_path_no_query != path
462                    && path.contains(&format!("{}=", parameter_data.name));
463                if !has_param {
464                    violations.push(RequestViolation {
465                        check_name: check_name.to_string(),
466                        method: method.to_string(),
467                        path: path.to_string(),
468                        violation_type: "missing_required_query_param".to_string(),
469                        message: format!(
470                            "Required query parameter '{}' is missing",
471                            parameter_data.name
472                        ),
473                    });
474                }
475                continue;
476            }
477            openapiv3::Parameter::Header { parameter_data, .. } => parameter_data,
478            openapiv3::Parameter::Path { parameter_data, .. } => {
479                // Path params are always required — but they're embedded in the URL
480                // so we can't easily validate them here (they're already resolved)
481                let _ = parameter_data;
482                continue;
483            }
484            openapiv3::Parameter::Cookie { .. } => continue,
485        };
486
487        if param_data.required {
488            let has_header = check_headers.keys().any(|k| k.eq_ignore_ascii_case(&param_data.name));
489            if !has_header {
490                violations.push(RequestViolation {
491                    check_name: check_name.to_string(),
492                    method: method.to_string(),
493                    path: path.to_string(),
494                    violation_type: "missing_required_header".to_string(),
495                    message: format!("Required header parameter '{}' is missing", param_data.name),
496                });
497            }
498        }
499    }
500}
501
502/// Resolve a parameter reference
503fn resolve_parameter<'a>(
504    param_ref: &'a ReferenceOr<openapiv3::Parameter>,
505    spec: &'a OpenAPI,
506) -> Option<&'a openapiv3::Parameter> {
507    match param_ref {
508        ReferenceOr::Item(p) => Some(p),
509        ReferenceOr::Reference { reference } => {
510            let name = reference.strip_prefix("#/components/parameters/")?;
511            match spec.components.as_ref()?.parameters.get(name)? {
512                ReferenceOr::Item(p) => Some(p),
513                _ => None,
514            }
515        }
516    }
517}
518
519/// Round 53 (#79) — percent-decode a query key/value or a path segment.
520///
521/// The spec declares parameters by their decoded name (`$.xgafv`), but the
522/// wire carries them encoded (`%24.xgafv`). Matching the raw wire key against
523/// the spec name silently skipped every parameter whose name needs escaping,
524/// which is why all of Srikanth's `owasp:*` probes (they all inject into
525/// `$.xgafv`) produced zero violations. Decoding the value as well keeps the
526/// reported message readable.
527///
528/// Falls back to the input unchanged when it isn't valid percent-encoded
529/// UTF-8, so a malformed probe can never panic or drop the parameter.
530fn pct_decode(s: &str) -> String {
531    urlencoding::decode(s).map(|c| c.into_owned()).unwrap_or_else(|_| s.to_string())
532}
533
534/// Round 53 (#79) — resolve a parameter's schema, following a single
535/// `#/components/schemas/...` reference. Mirrors the request-body resolution
536/// added in r52; without it a `$ref`'d parameter schema is silently skipped.
537fn resolve_param_schema<'a>(
538    schema_ref: &'a ReferenceOr<openapiv3::Schema>,
539    spec: &'a OpenAPI,
540) -> Option<&'a openapiv3::Schema> {
541    match schema_ref {
542        ReferenceOr::Item(s) => Some(s),
543        ReferenceOr::Reference { reference } => {
544            let name = reference.strip_prefix("#/components/schemas/")?;
545            match spec.components.as_ref()?.schemas.get(name)? {
546                ReferenceOr::Item(s) => Some(s),
547                _ => None,
548            }
549        }
550    }
551}
552
553/// Resolve a schema reference to a serde_json::Value for validation.
554/// Reserved for round 21.3 (response-body shape validation against the
555/// spec's response schema). Not yet wired into a call site.
556#[allow(dead_code)]
557fn resolve_schema_to_json(
558    schema_ref: &ReferenceOr<openapiv3::Schema>,
559    spec: &OpenAPI,
560) -> Option<serde_json::Value> {
561    let schema = match schema_ref {
562        ReferenceOr::Item(s) => s,
563        ReferenceOr::Reference { reference } => {
564            let name = reference.strip_prefix("#/components/schemas/")?;
565            match spec.components.as_ref()?.schemas.get(name)? {
566                ReferenceOr::Item(s) => s,
567                _ => return None,
568            }
569        }
570    };
571    serde_json::to_value(schema).ok()
572}
573
574/// Run request validation and write results to a file.
575/// Called from the conformance execution path.
576pub async fn run_request_validation(
577    spec_files: &[std::path::PathBuf],
578    custom_checks_file: Option<&Path>,
579    base_path: Option<&str>,
580    output_dir: &Path,
581) -> Result<usize> {
582    let custom_file = match custom_checks_file {
583        Some(f) => f,
584        None => return Ok(0),
585    };
586
587    if spec_files.is_empty() {
588        return Ok(0);
589    }
590
591    let parser = SpecParser::from_file(&spec_files[0]).await?;
592    let spec = parser.spec();
593
594    let violations = validate_custom_checks(spec, custom_file, base_path)?;
595
596    if !violations.is_empty() {
597        let path = output_dir.join("conformance-request-violations.json");
598        if let Ok(json) = serde_json::to_string_pretty(&violations) {
599            let _ = std::fs::write(&path, json);
600            tracing::info!(
601                "Found {} request validation violation(s), saved to {}",
602                violations.len(),
603                path.display()
604            );
605        }
606    }
607
608    Ok(violations.len())
609}
610
611/// Round 44 (#79) — validate each emitted request retrospectively
612/// against the OpenAPI spec, after the bench run completes. Reads
613/// `conformance-requests.json` (which `--export-requests` writes) and
614/// emits one [`RequestViolation`] entry per actual wire-level
615/// rule break (enum, type, required field, etc.), so a user can see
616/// the client's own view of what it sent that violated the contract
617/// without having to query the server's `/__mockforge/api/conformance/violations`.
618///
619/// Srikanth on 0.3.188: "Any reason why validate-requests in mockforge
620/// client are not catching all this query param or body params or path
621/// params violation issues and record in conformance-request-failure
622/// logs?" The existing `validate_custom_checks` only looks at the YAML
623/// shape at config time (missing required params, unknown path);
624/// auto-generated self-test probes ARE intentionally invalid but were
625/// never recorded client-side because they don't come from the YAML.
626/// This function complements the YAML-shape pass by checking each
627/// emitted request against the spec's actual rule set.
628///
629/// Appends to (not overwrites) `conformance-request-violations.json`
630/// when YAML-shape violations were already written above, so a single
631/// file holds both views.
632pub async fn validate_emitted_requests(
633    spec_files: &[std::path::PathBuf],
634    output_dir: &Path,
635) -> Result<usize> {
636    validate_emitted_requests_with_base_path(spec_files, output_dir, None).await
637}
638
639/// Round 45 (#79) — same as `validate_emitted_requests` but accepts an
640/// explicit `base_path` (e.g. Srikanth's `--base-path /api` for the
641/// Apigee spec where every operation lives under `/api/v1/...` on the
642/// wire but `/v1/...` in the spec). Without it the emitted URL doesn't
643/// match the spec path and every request silently skips validation.
644///
645/// Also broadened in r45 to:
646/// - extract path params from the URL and validate their values
647///   against the spec's path-parameter schemas (enum / type)
648/// - parse the request body when content-type is JSON and walk it
649///   against the requestBody schema's `required: [...]` and enum
650///   constraints on top-level properties
651///
652/// Body and path-param coverage is INTENTIONALLY shallow (top-level
653/// `required` + `enum`/`type` on direct properties only) — the
654/// authoritative validator is the OpenAPI server's; this is the
655/// client-side cross-check that mirrors the server's view on the
656/// wire-level requests the bench actually sent.
657pub async fn validate_emitted_requests_with_base_path(
658    spec_files: &[std::path::PathBuf],
659    output_dir: &Path,
660    base_path: Option<&str>,
661) -> Result<usize> {
662    use serde_json::Value;
663
664    if spec_files.is_empty() {
665        return Ok(0);
666    }
667    let requests_path = output_dir.join("conformance-requests.json");
668    let self_test_jsonl_path = output_dir.join("conformance-self-test-requests.jsonl");
669
670    // Round 49 (#79) — Srikanth on 0.3.193: self-test + --targets-file
671    // produced no violation logs because validate_emitted_requests
672    // only reads `conformance-requests.json` (the bench export
673    // shape), and self-test writes `conformance-self-test-
674    // requests.jsonl` (the CaseCapture shape). Now read whichever
675    // exists, converting the JSONL shape into the same `{check,
676    // method, url, request.body}` structure the validator below
677    // expects. If both exist, the bench export wins (a deliberate
678    // bench run shouldn't be overridden by stale self-test output).
679    let entries: Vec<Value> = if requests_path.exists() {
680        let bytes = match std::fs::read(&requests_path) {
681            Ok(b) => b,
682            Err(_) => return Ok(0),
683        };
684        match serde_json::from_slice(&bytes) {
685            Ok(v) => v,
686            Err(_) => return Ok(0),
687        }
688    } else if self_test_jsonl_path.exists() {
689        let bytes = match std::fs::read(&self_test_jsonl_path) {
690            Ok(b) => b,
691            Err(_) => return Ok(0),
692        };
693        let text = String::from_utf8_lossy(&bytes);
694        text.lines()
695            .filter(|l| !l.is_empty())
696            .filter_map(|l| serde_json::from_str::<Value>(l).ok())
697            .map(|case| {
698                let label = case.get("label").and_then(|v| v.as_str()).unwrap_or("").to_string();
699                let method = case.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
700                let url = case.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
701                let body = case.get("request_body").cloned().unwrap_or(Value::Null);
702                let mut req = serde_json::Map::new();
703                req.insert("method".into(), Value::String(method));
704                req.insert("url".into(), Value::String(url));
705                req.insert(
706                    "body".into(),
707                    match body {
708                        Value::String(s) => Value::String(s),
709                        Value::Null => Value::String(String::new()),
710                        other => other,
711                    },
712                );
713                let mut out = serde_json::Map::new();
714                out.insert("check".into(), Value::String(label));
715                out.insert("request".into(), Value::Object(req));
716                Value::Object(out)
717            })
718            .collect()
719    } else {
720        return Ok(0);
721    };
722    if entries.is_empty() {
723        return Ok(0);
724    }
725
726    let parser = SpecParser::from_file(&spec_files[0]).await?;
727    let spec = parser.spec();
728    let spec_ops = build_spec_operation_map(spec);
729
730    let mut emitted_violations: Vec<RequestViolation> = Vec::new();
731
732    for entry in &entries {
733        let check = entry.get("check").and_then(|v| v.as_str()).unwrap_or("").to_string();
734        let req = match entry.get("request") {
735            Some(r) => r,
736            None => continue,
737        };
738        let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("").to_uppercase();
739        let url = req.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
740        if method.is_empty() || url.is_empty() {
741            continue;
742        }
743        // Round 56 (#79) — remember how many violations existed before this
744        // probe so a parameter negative that produced no hard breach can still
745        // be recorded (see the block at the end of the loop).
746        let viol_before = emitted_violations.len();
747        let (path_only, query_string) = match url.find('?') {
748            Some(i) => (url[..i].to_string(), url[i + 1..].to_string()),
749            None => (url.clone(), String::new()),
750        };
751        // Trim scheme + host from path so we match spec paths cleanly.
752        // "http://host:port/api/x" → "/api/x".
753        let path_only = if let Some(stripped) = path_only.split_once("://") {
754            match stripped.1.find('/') {
755                Some(i) => stripped.1[i..].to_string(),
756                None => "/".to_string(),
757            }
758        } else {
759            path_only
760        };
761
762        // Round 55 (#79) — collapse accidental double slashes so a request
763        // that reached the wire as `//v1/organizations` (e.g. an older
764        // `--base-path /` that prefixed a `/` onto an already-rooted path)
765        // still matches the spec's `/v1/organizations`. Belt-and-suspenders:
766        // the generator no longer produces the `//`, but stale captures /
767        // hand-built URLs might.
768        let path_only = collapse_slashes(&path_only);
769
770        // Round 45 — strip base_path BEFORE matching so an Apigee-style
771        // `/api/v1/organizations` on the wire matches `/v1/organizations`
772        // in the spec when `--base-path /api` was passed.
773        let lookup_path = if let Some(bp) = base_path {
774            let bp = bp.trim_end_matches('/');
775            if !bp.is_empty() && path_only.starts_with(bp) {
776                let stripped = &path_only[bp.len()..];
777                if stripped.is_empty() {
778                    "/".to_string()
779                } else {
780                    stripped.to_string()
781                }
782            } else {
783                path_only.clone()
784            }
785        } else {
786            path_only.clone()
787        };
788
789        let spec_path = match find_matching_spec_path(&lookup_path, &spec_ops, None) {
790            Some(p) => p,
791            None => continue,
792        };
793        let path_item = match spec.paths.paths.get(&spec_path) {
794            Some(ReferenceOr::Item(item)) => item,
795            _ => continue,
796        };
797        let operation = match method.as_str() {
798            "GET" => path_item.get.as_ref(),
799            "POST" => path_item.post.as_ref(),
800            "PUT" => path_item.put.as_ref(),
801            "DELETE" => path_item.delete.as_ref(),
802            "PATCH" => path_item.patch.as_ref(),
803            "HEAD" => path_item.head.as_ref(),
804            "OPTIONS" => path_item.options.as_ref(),
805            _ => None,
806        };
807        let Some(operation) = operation else { continue };
808
809        // Inspect query parameters declared on this operation; for each
810        // sent query field, check it against the parameter's schema enum
811        // and type. This is what catches Srikanth's `?$.xgafv=test-value`
812        // case where the value isn't `"1"` or `"2"`.
813        //
814        // Round 53 (#79) — percent-decode BOTH the key and the value. The
815        // spec declares the parameter as `$.xgafv`, but it reaches the wire
816        // as `%24.xgafv`, so matching the raw key against the spec name
817        // missed every time and silently skipped the parameter. That hid all
818        // 7602 of Srikanth's `owasp:*` probes, which all inject into
819        // `$.xgafv`. Decoding the value too keeps the violation message
820        // readable (`' OR '1'='1` rather than `%27%20OR%20%271%27%3D%271`).
821        let sent_query: HashMap<String, String> = query_string
822            .split('&')
823            .filter_map(|kv| {
824                let mut it = kv.splitn(2, '=');
825                let k = pct_decode(it.next()?);
826                let v = pct_decode(it.next().unwrap_or(""));
827                if k.is_empty() {
828                    None
829                } else {
830                    Some((k, v))
831                }
832            })
833            .collect();
834
835        // Round 45 — bind path parameters by zipping the concrete URL
836        // path against the spec's template path. `/v1/{name}` ←
837        // `/v1/projects/abc` produces `{ "name": "projects/abc" }`.
838        // Used below to value-check each path-param against its
839        // declared schema (enum / type).
840        let path_params: HashMap<String, String> = {
841            let mut out = HashMap::new();
842            let concrete_parts: Vec<&str> = lookup_path.split('/').collect();
843            let template_parts: Vec<&str> = spec_path.split('/').collect();
844            if concrete_parts.len() == template_parts.len() {
845                for (c, t) in concrete_parts.iter().zip(template_parts.iter()) {
846                    // Round 54 — reuse the segment matcher so custom-verb path
847                    // params (`{instance}:reportStatus`) bind too, not just bare
848                    // `{name}` segments. Round 53 — path values arrive encoded.
849                    if let Some(Some((name, value))) = match_path_segment(t, c) {
850                        out.insert(name.to_string(), pct_decode(&value));
851                    }
852                }
853            }
854            out
855        };
856
857        let mut all_params: Vec<&openapiv3::Parameter> = Vec::new();
858        for p in &path_item.parameters {
859            if let Some(param) = resolve_parameter(p, spec) {
860                all_params.push(param);
861            }
862        }
863        for p in &operation.parameters {
864            if let Some(param) = resolve_parameter(p, spec) {
865                all_params.push(param);
866            }
867        }
868
869        for param in &all_params {
870            let (loc_str, name, schema_ref) = match param {
871                openapiv3::Parameter::Query { parameter_data, .. } => {
872                    let openapiv3::ParameterSchemaOrContent::Schema(sref) = &parameter_data.format
873                    else {
874                        continue;
875                    };
876                    let Some(v) = sent_query.get(&parameter_data.name) else {
877                        // Round 53 (#79) — a REQUIRED query param that never
878                        // reached the wire is itself a spec violation. The
879                        // loop previously only inspected params that were
880                        // sent, so `parameters:missing-query` probes (which
881                        // drop a required param on purpose) produced nothing.
882                        if parameter_data.required {
883                            emitted_violations.push(RequestViolation {
884                                check_name: check.clone(),
885                                method: method.clone(),
886                                path: url.clone(),
887                                violation_type: "query_missing_required".to_string(),
888                                message: format!(
889                                    "query.{}: required parameter missing",
890                                    parameter_data.name
891                                ),
892                            });
893                        }
894                        continue;
895                    };
896                    ("query", &parameter_data.name, (sref, v.clone()))
897                }
898                openapiv3::Parameter::Path { parameter_data, .. } => {
899                    let openapiv3::ParameterSchemaOrContent::Schema(sref) = &parameter_data.format
900                    else {
901                        continue;
902                    };
903                    let Some(v) = path_params.get(&parameter_data.name) else {
904                        continue;
905                    };
906                    ("path", &parameter_data.name, (sref, v.clone()))
907                }
908                _ => continue,
909            };
910            let (schema_ref, value) = schema_ref;
911            // Round 53 — resolve `$ref` parameter schemas too; the same
912            // `as_item()` blind spot that hid `$ref` request bodies in r52
913            // applies to parameters whose schema is a component reference.
914            let Some(schema) = resolve_param_schema(schema_ref, spec) else {
915                continue;
916            };
917            if let Some(msg) = check_value_against_schema(&value, schema) {
918                emitted_violations.push(RequestViolation {
919                    check_name: check.clone(),
920                    method: method.clone(),
921                    path: url.clone(),
922                    violation_type: format!("{}_value_mismatch", loc_str),
923                    message: format!("{}.{}: {}", loc_str, name, msg),
924                });
925            }
926        }
927
928        // Round 45 — request-body cross-check. Only kicks in when the
929        // sent body parses as JSON and the operation declares a JSON
930        // requestBody schema.
931        //
932        // Round 52 (#79) — Srikanth on 0.3.198: a self-test +
933        // `--targets-file` run reported "2700 request-body caught" but
934        // the by-request / by-probe violation files came out EMPTY. The
935        // old shallow check here only fired when the requestBody media
936        // schema was an inline `ReferenceOr::Item`; the Apigee spec (and
937        // most real specs) declares
938        // `schema.$ref = #/components/schemas/GoogleCloudApigeeV1Organization`,
939        // so `schema_ref.as_item()` returned `None` and every body probe
940        // was silently skipped. It also only type-checked STRING values,
941        // so a `{"analyticsRegion":12345}` (number-where-string) probe
942        // never surfaced even when the schema resolved. We now resolve
943        // the requestBody + schema `$ref`s and reuse the same full JSON
944        // Schema validator `validate_custom_checks` uses (round 18.3's
945        // `build_validator`), so nested `$ref`s, root-type mismatches,
946        // and non-string type mismatches are all caught.
947        let body_str = req.get("body").and_then(|v| v.as_str()).unwrap_or("");
948        if !body_str.is_empty() {
949            if let Ok(body_json) = serde_json::from_str::<serde_json::Value>(body_str) {
950                validate_emitted_body(
951                    &check,
952                    &method,
953                    &url,
954                    &body_json,
955                    operation,
956                    spec,
957                    &mut emitted_violations,
958                );
959            }
960        }
961
962        // Round 56 (#79) — Srikanth on 0.3.203: the console reported thousands
963        // of missed `parameters` negatives but the logs showed only owasp and
964        // request-body rows. For his Apigee spec the parameter probes don't
965        // breach the contract at all (dropping the OPTIONAL `$.xgafv`, adding an
966        // undeclared oversized query param, or an unconstrained `{instance}`
967        // path value are all spec-VALID), so the validator had nothing to flag.
968        // Record each parameter negative probe explicitly so every probe the
969        // self-test fired is visible, clearly typed so it is not mistaken for a
970        // hard schema breach. `query_missing_required` / `path_value_mismatch`
971        // (added in r53/r54) still fire first for genuinely-constrained params;
972        // this only covers the ones the contract permits.
973        if check.starts_with("parameters:") && emitted_violations.len() == viol_before {
974            emitted_violations.push(RequestViolation {
975                check_name: check.clone(),
976                method: method.clone(),
977                path: url.clone(),
978                violation_type: "parameter_negative_probe".to_string(),
979                message: describe_parameter_probe(&check),
980            });
981        }
982    }
983
984    // Merge with any pre-existing custom-YAML violations on disk.
985    let dst = output_dir.join("conformance-request-violations.json");
986    let mut all: Vec<Value> = if dst.exists() {
987        match std::fs::read(&dst) {
988            Ok(b) => serde_json::from_slice(&b).unwrap_or_default(),
989            Err(_) => Vec::new(),
990        }
991    } else {
992        Vec::new()
993    };
994    for v in &emitted_violations {
995        if let Ok(val) = serde_json::to_value(v) {
996            all.push(val);
997        }
998    }
999    // Round 50 (#79) — dedup byte-identical violations. A multi-iteration
1000    // self-test captures one probe per iteration, so a 22x duration run
1001    // produced 22 copies of every violation in the flat file (and, before
1002    // the grouping fixes below, 22 copies inside each grouped row). Keep
1003    // the first occurrence of each (check_name, method, path,
1004    // violation_type, message) tuple; re-runs that merged the on-disk file
1005    // are collapsed too. Preserves first-seen order.
1006    {
1007        let mut seen: std::collections::HashSet<(String, String, String, String, String)> =
1008            std::collections::HashSet::new();
1009        all.retain(|v| {
1010            let f = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
1011            seen.insert((
1012                f("check_name"),
1013                f("method"),
1014                f("path"),
1015                f("violation_type"),
1016                f("message"),
1017            ))
1018        });
1019    }
1020    if !all.is_empty() {
1021        if let Ok(json) = serde_json::to_string_pretty(&all) {
1022            let _ = std::fs::write(&dst, json);
1023            tracing::info!(
1024                "validate-requests: wrote {} entries to {} ({} from emitted requests)",
1025                all.len(),
1026                dst.display(),
1027                emitted_violations.len()
1028            );
1029        }
1030    }
1031
1032    // Round 46 (#79) — Srikanth on 0.3.190: "I see three different
1033    // messages, is this message for 3 different requests or for 1
1034    // request. if it is 1 request can we have 1 line item mentioning
1035    // violation 1 = message1, violation2 = message2 etc". Emit a
1036    // sibling file grouped by (check_name, method, path) so each
1037    // wire-level request shows up as a single row carrying every
1038    // violation it raised. The per-violation file stays as-is for
1039    // tooling that wants the flat shape.
1040    let grouped_dst = output_dir.join("conformance-request-violations-by-request.json");
1041    let grouped_value = group_violations_by_request(&all);
1042    if let Ok(json) = serde_json::to_string_pretty(&grouped_value) {
1043        let _ = std::fs::write(&grouped_dst, json);
1044    }
1045
1046    // Round 48 (#79) — Srikanth on 0.3.192: "Can I assume all this
1047    // checks has some violation either in the incoming request or
1048    // outgoing response if yes then how can I see all this violation
1049    // individually? Do we have any other Logs pointing each of those
1050    // so that I can fix in one go?" New per-probe drill-down file
1051    // emits one row per (check_name, method, path) carrying its full
1052    // flat violation list. Lets the user see EXACTLY what each probe
1053    // pattern (body:json, schema:string, constraint:enum, etc.)
1054    // surfaced rather than just the deduped union the
1055    // by-request file shows.
1056    let drill_dst = output_dir.join("conformance-request-violations-by-probe.json");
1057    let drill_value = group_violations_by_probe(&all);
1058    if let Ok(json) = serde_json::to_string_pretty(&drill_value) {
1059        let _ = std::fs::write(&drill_dst, json);
1060    }
1061    Ok(emitted_violations.len())
1062}
1063
1064/// Round 48 (#79) — emit one entry per (check_name, method, path)
1065/// with its full violation list. Unlike `group_violations_by_request`,
1066/// this preserves the per-probe view so the user can see WHICH spec-
1067/// probing pattern (body:json / schema:string / constraint:enum /
1068/// method:POST / etc.) surfaced WHICH violation. Sorted by check_name
1069/// within the same (method, path) so probes group together visually.
1070fn group_violations_by_probe(flat: &[serde_json::Value]) -> serde_json::Value {
1071    use serde_json::{Map, Value};
1072
1073    let mut by_probe_order: Vec<(String, String, String)> = Vec::new();
1074    let mut by_probe: std::collections::HashMap<(String, String, String), Vec<(String, String)>> =
1075        std::collections::HashMap::new();
1076
1077    // Round 50 (#79) — Srikanth on 0.3.194: "I see same violation is
1078    // getting printed in logs for 22 times" on a multi-iteration run.
1079    // The self-test capture holds one probe per iteration, so a 22x
1080    // duration run feeds 22 byte-identical violations per probe into
1081    // this flat list and we used to append all 22. Dedup identical
1082    // (violation_type, message) pairs WITHIN a probe so each unique
1083    // violation shows exactly once regardless of iteration count.
1084    let mut seen_in_probe: std::collections::HashSet<(String, String, String, String)> =
1085        std::collections::HashSet::new();
1086    for v in flat {
1087        let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
1088        let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
1089        let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
1090        let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
1091        let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
1092        let key = (check.clone(), method.clone(), path.clone());
1093        if !by_probe.contains_key(&key) {
1094            by_probe_order.push(key.clone());
1095        }
1096        if seen_in_probe.insert((check, method, path, format!("{vt}\u{0}{msg}"))) {
1097            by_probe.entry(key).or_default().push((vt, msg));
1098        }
1099    }
1100
1101    // Sort within same (method, path) by check_name for visual grouping.
1102    by_probe_order.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)).then(a.0.cmp(&b.0)));
1103
1104    let mut rows: Vec<Value> = Vec::with_capacity(by_probe_order.len());
1105    for key in &by_probe_order {
1106        let (check, method, path) = key;
1107        let entries = by_probe.get(key).cloned().unwrap_or_default();
1108        let mut row = Map::new();
1109        row.insert("check_name".into(), Value::String(check.clone()));
1110        row.insert("method".into(), Value::String(method.clone()));
1111        row.insert("path".into(), Value::String(path.clone()));
1112        row.insert(
1113            "violation_count".into(),
1114            Value::Number(serde_json::Number::from(entries.len())),
1115        );
1116        for (i, (vt, msg)) in entries.iter().enumerate() {
1117            let mut entry = Map::new();
1118            entry.insert("violation_type".into(), Value::String(vt.clone()));
1119            entry.insert("message".into(), Value::String(msg.clone()));
1120            row.insert(format!("violation_{}", i + 1), Value::Object(entry));
1121        }
1122        rows.push(Value::Object(row));
1123    }
1124    Value::Array(rows)
1125}
1126
1127/// Round 46–50 (#79) — collapse the flat list of
1128/// [`RequestViolation`]-shaped JSON values into exactly ONE entry per
1129/// `(method, path)`.
1130///
1131/// History: Round 46 keyed on `(check_name, method, path)` (too many
1132/// duplicate rows). Round 47 collapsed by `(method, path)` AND the
1133/// violation set, listing contributing checks in a `checks: [...]`
1134/// array. But that re-split a single URL whenever two probe families
1135/// produced DIFFERENT violation sets for it — Srikanth on 0.3.194:
1136/// `owasp:ldap-injection` (query violations) landed in a different
1137/// by-request row than the `request-body:*` checks for the very same
1138/// URL, so his triage flow ("find the URL with the most violations
1139/// here, then drill into by-probe") missed half the picture.
1140///
1141/// Round 50 makes this file the authoritative per-URL overview: one row
1142/// per `(method, path)` carrying the DEDUPED UNION of every violation
1143/// and every contributing `check_name`. The per-probe attribution
1144/// ("which check surfaced which violation") lives in the sibling
1145/// `conformance-request-violations-by-probe.json`. First-seen order is
1146/// preserved for both checks and violations so the output is stable.
1147fn group_violations_by_request(flat: &[serde_json::Value]) -> serde_json::Value {
1148    use serde_json::{Map, Value};
1149
1150    let mut order: Vec<(String, String)> = Vec::new();
1151    let mut checks_by_key: std::collections::HashMap<(String, String), Vec<String>> =
1152        std::collections::HashMap::new();
1153    let mut viols_by_key: std::collections::HashMap<(String, String), Vec<(String, String)>> =
1154        std::collections::HashMap::new();
1155    // Per-(method,path) dedup sets so a check fired across 22 iterations,
1156    // or the same (vt,msg) surfaced by several checks, is counted once.
1157    let mut seen_check: std::collections::HashSet<(String, String, String)> =
1158        std::collections::HashSet::new();
1159    let mut seen_viol: std::collections::HashSet<(String, String, String)> =
1160        std::collections::HashSet::new();
1161
1162    for v in flat {
1163        let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
1164        let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
1165        let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
1166        let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
1167        let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
1168        let key = (method.clone(), path.clone());
1169        if !checks_by_key.contains_key(&key) && !viols_by_key.contains_key(&key) {
1170            order.push(key.clone());
1171        }
1172        if !check.is_empty() && seen_check.insert((method.clone(), path.clone(), check.clone())) {
1173            checks_by_key.entry(key.clone()).or_default().push(check);
1174        }
1175        if seen_viol.insert((method.clone(), path.clone(), format!("{vt}\u{0}{msg}"))) {
1176            viols_by_key.entry(key).or_default().push((vt, msg));
1177        }
1178    }
1179
1180    let mut rows: Vec<Value> = Vec::with_capacity(order.len());
1181    for key in &order {
1182        let (method, path) = key;
1183        let checks = checks_by_key.get(key).cloned().unwrap_or_default();
1184        let viols = viols_by_key.get(key).cloned().unwrap_or_default();
1185        let mut row = Map::new();
1186        row.insert(
1187            "checks".into(),
1188            Value::Array(checks.iter().map(|s| Value::String(s.clone())).collect()),
1189        );
1190        // Round 48 (#79) — keep a single representative `check_name`
1191        // pointing at the check whose family matches the FIRST violation,
1192        // so the headline check isn't misleading. The full set is in
1193        // `checks[]`; per-violation attribution is in the by-probe file.
1194        let dominant_prefix: &str = viols
1195            .first()
1196            .map(|(vt, _)| {
1197                if vt.starts_with("query_") {
1198                    "param:query"
1199                } else if vt.starts_with("body_") {
1200                    "body:"
1201                } else if vt.starts_with("path_") {
1202                    "param:path"
1203                } else if vt.starts_with("header_") {
1204                    "param:header"
1205                } else {
1206                    ""
1207                }
1208            })
1209            .unwrap_or("");
1210        let best_check = if !dominant_prefix.is_empty() {
1211            checks
1212                .iter()
1213                .find(|c| c.starts_with(dominant_prefix))
1214                .cloned()
1215                .or_else(|| checks.first().cloned())
1216                .unwrap_or_default()
1217        } else {
1218            checks.first().cloned().unwrap_or_default()
1219        };
1220        row.insert("check_name".into(), Value::String(best_check));
1221        row.insert("method".into(), Value::String(method.clone()));
1222        row.insert("path".into(), Value::String(path.clone()));
1223        row.insert("violation_count".into(), Value::Number(serde_json::Number::from(viols.len())));
1224        for (i, (vt, msg)) in viols.iter().enumerate() {
1225            let mut entry = Map::new();
1226            entry.insert("violation_type".into(), Value::String(vt.clone()));
1227            entry.insert("message".into(), Value::String(msg.clone()));
1228            row.insert(format!("violation_{}", i + 1), Value::Object(entry));
1229        }
1230        rows.push(Value::Object(row));
1231    }
1232    Value::Array(rows)
1233}
1234
1235/// Round 52 (#79) — validate an emitted request body against the
1236/// operation's requestBody schema, resolving `$ref` at both the
1237/// requestBody and schema level and delegating to the same full JSON
1238/// Schema validator (`build_validator`) the custom-checks path uses.
1239///
1240/// This replaced a shallow, `$ref`-unaware check that only fired for
1241/// inline schemas and only type-checked string values — which is why a
1242/// self-test run against the Apigee spec (whose request bodies are all
1243/// `$ref`s to component schemas) produced empty violation logs even
1244/// though the summary reported thousands of caught request-body
1245/// negatives. We cap at 5 errors per body so a deeply-broken probe
1246/// can't flood the log; the by-probe file still gets one row per probe.
1247fn validate_emitted_body(
1248    check: &str,
1249    method: &str,
1250    url: &str,
1251    body: &serde_json::Value,
1252    operation: &openapiv3::Operation,
1253    spec: &OpenAPI,
1254    violations: &mut Vec<RequestViolation>,
1255) {
1256    // Resolve the requestBody (may itself be a $ref into components).
1257    let Some(request_body_ref) = &operation.request_body else {
1258        return;
1259    };
1260    let request_body = match request_body_ref {
1261        ReferenceOr::Item(rb) => rb,
1262        ReferenceOr::Reference { reference } => {
1263            let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
1264            match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
1265                Some(ReferenceOr::Item(rb)) => rb,
1266                _ => return,
1267            }
1268        }
1269    };
1270
1271    // Only cross-check JSON bodies — other media types (multipart,
1272    // urlencoded) are handled elsewhere / by the server validator.
1273    let json_media = request_body
1274        .content
1275        .get("application/json")
1276        .or_else(|| request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v));
1277    let Some(media) = json_media else {
1278        return;
1279    };
1280    let Some(schema_ref) = &media.schema else {
1281        return;
1282    };
1283
1284    // Resolve the immediate schema $ref (one level) to the root schema,
1285    // then hand it to the resolver so nested $refs resolve against the
1286    // full document (round 18.3's fix for vCenter's nested components).
1287    let root_schema = match schema_ref {
1288        ReferenceOr::Item(s) => s.clone(),
1289        ReferenceOr::Reference { reference } => {
1290            let name = reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
1291            match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
1292                Some(ReferenceOr::Item(s)) => s.clone(),
1293                _ => return,
1294            }
1295        }
1296    };
1297
1298    let Ok(validator) = mockforge_openapi::schema_ref_resolver::build_validator(&root_schema, spec)
1299    else {
1300        // Schema itself is unbuildable — skip rather than false-positive.
1301        return;
1302    };
1303    for err in validator.iter_errors(body).take(5) {
1304        let loc = err.instance_path.to_string();
1305        let loc = if loc.is_empty() { "$".to_string() } else { loc };
1306        violations.push(RequestViolation {
1307            check_name: check.to_string(),
1308            method: method.to_string(),
1309            path: url.to_string(),
1310            violation_type: "body_schema_violation".to_string(),
1311            message: format!("body{}: {}", loc, err),
1312        });
1313    }
1314}
1315
1316/// Round 44 (#79) — minimal value-vs-schema check for the retroactive
1317/// emitted-request validator. Returns a human-readable error message
1318/// when the value doesn't satisfy the schema, or `None` when it does.
1319/// Only handles the rules Srikanth's Apigee spec uses (enum, type:
1320/// integer, type: boolean); falls through silently for any other
1321/// rule rather than producing a false positive.
1322fn check_value_against_schema(value: &str, schema: &openapiv3::Schema) -> Option<String> {
1323    use openapiv3::{SchemaKind, Type};
1324
1325    let SchemaKind::Type(t) = &schema.schema_kind else {
1326        return None;
1327    };
1328    match t {
1329        Type::String(s) => {
1330            if !s.enumeration.is_empty() {
1331                let allowed: Vec<String> = s.enumeration.iter().filter_map(|e| e.clone()).collect();
1332                if !allowed.iter().any(|a| a == value) {
1333                    let quoted: Vec<String> =
1334                        allowed.iter().map(|a| format!("\"{}\"", a)).collect();
1335                    return Some(format!(
1336                        "value \"{}\" is not one of {}",
1337                        value,
1338                        quoted.join(" or ")
1339                    ));
1340                }
1341            }
1342            // Round 54 (#79) — string length + pattern constraints. Without
1343            // these a `bad-path-param` / bad-query probe against a param that
1344            // declares `pattern` / `minLength` / `maxLength` produced no
1345            // violation even though the value clearly breaks the contract.
1346            let len = value.chars().count();
1347            if let Some(min) = s.min_length {
1348                if len < min {
1349                    return Some(format!("value \"{value}\" is shorter than minLength {min}"));
1350                }
1351            }
1352            if let Some(max) = s.max_length {
1353                if len > max {
1354                    return Some(format!("value \"{value}\" is longer than maxLength {max}"));
1355                }
1356            }
1357            if let Some(pat) = &s.pattern {
1358                // Best-effort: an uncompilable pattern is skipped rather than
1359                // reported as a false positive.
1360                if let Ok(re) = regex::Regex::new(pat) {
1361                    if !re.is_match(value) {
1362                        return Some(format!("value \"{value}\" does not match pattern /{pat}/"));
1363                    }
1364                }
1365            }
1366            None
1367        }
1368        Type::Integer(_) => {
1369            if value.parse::<i64>().is_err() {
1370                Some(format!("value \"{}\" is not of type \"integer\"", value))
1371            } else {
1372                None
1373            }
1374        }
1375        Type::Number(_) => {
1376            if value.parse::<f64>().is_err() {
1377                Some(format!("value \"{}\" is not of type \"number\"", value))
1378            } else {
1379                None
1380            }
1381        }
1382        Type::Boolean(_) => match value {
1383            "true" | "false" => None,
1384            _ => Some(format!("value \"{}\" is not of type \"boolean\"", value)),
1385        },
1386        _ => None,
1387    }
1388}
1389
1390#[cfg(test)]
1391mod grouping_tests {
1392    use super::{group_violations_by_probe, group_violations_by_request};
1393    use serde_json::json;
1394
1395    /// Build a flat violation value the way `validate_emitted_requests` does.
1396    fn viol(check: &str, method: &str, path: &str, vt: &str, msg: &str) -> serde_json::Value {
1397        json!({
1398            "check_name": check,
1399            "method": method,
1400            "path": path,
1401            "violation_type": vt,
1402            "message": msg,
1403        })
1404    }
1405
1406    /// Round 50 (#79) — reproduces Srikanth's 0.3.194 report: a single URL
1407    /// whose query violations come from `owasp:ldap-injection` while its
1408    /// body violations come from `request-body:*` checks must collapse into
1409    /// ONE by-request row that lists BOTH check families and the UNION of
1410    /// every violation. Previously these split into two separate rows, so
1411    /// the owasp check was invisible from the body row he was reading.
1412    #[test]
1413    fn by_request_unions_all_checks_for_a_url() {
1414        let path = "https://host/v1/organizations?alt=test-value&prettyPrint=test-value";
1415        let flat = vec![
1416            viol(
1417                "request-body:type-mismatch:billingType",
1418                "POST",
1419                path,
1420                "body_type_mismatch",
1421                "body.billingType: expected string",
1422            ),
1423            viol(
1424                "owasp:ldap-injection",
1425                "POST",
1426                path,
1427                "query_value_mismatch",
1428                "query.alt: value \"test-value\" is not one of \"json\" or \"media\"",
1429            ),
1430            viol(
1431                "owasp:ldap-injection",
1432                "POST",
1433                path,
1434                "query_value_mismatch",
1435                "query.prettyPrint: value \"test-value\" is not of type \"boolean\"",
1436            ),
1437        ];
1438
1439        let out = group_violations_by_request(&flat);
1440        let rows = out.as_array().expect("array");
1441        // Exactly one row for the URL — no fragmentation.
1442        assert_eq!(rows.len(), 1, "expected a single by-request row per URL");
1443        let row = &rows[0];
1444        assert_eq!(row["violation_count"], 3);
1445        let checks: Vec<&str> =
1446            row["checks"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect();
1447        assert!(checks.contains(&"owasp:ldap-injection"), "owasp check must appear: {checks:?}");
1448        assert!(
1449            checks.iter().any(|c| c.starts_with("request-body:")),
1450            "body check must appear: {checks:?}"
1451        );
1452    }
1453
1454    /// Round 50 (#79) — "I see same violation is getting printed in logs for
1455    /// 22 times." A multi-iteration run feeds N identical violations per
1456    /// probe; the by-probe drill-down must show each unique violation once.
1457    #[test]
1458    fn by_probe_dedups_repeated_iterations() {
1459        let path = "https://host/v1/organizations?alt=test-value";
1460        let mut flat = Vec::new();
1461        for _ in 0..22 {
1462            flat.push(viol(
1463                "owasp:ldap-injection",
1464                "POST",
1465                path,
1466                "query_value_mismatch",
1467                "query.alt: value \"test-value\" is not one of \"json\" or \"media\"",
1468            ));
1469        }
1470
1471        let out = group_violations_by_probe(&flat);
1472        let rows = out.as_array().expect("array");
1473        assert_eq!(rows.len(), 1, "one probe row");
1474        assert_eq!(rows[0]["violation_count"], 1, "22 identical iterations collapse to 1");
1475        assert!(rows[0].get("violation_1").is_some());
1476        assert!(rows[0].get("violation_2").is_none(), "no duplicate violation_2");
1477    }
1478
1479    /// The by-request union must also collapse the 22x duplicates, not just
1480    /// dedup across checks.
1481    #[test]
1482    fn by_request_dedups_repeated_iterations() {
1483        let path = "https://host/v1/widgets";
1484        let mut flat = Vec::new();
1485        for _ in 0..22 {
1486            flat.push(viol(
1487                "request-body:type-mismatch:name",
1488                "POST",
1489                path,
1490                "body_type_mismatch",
1491                "body.name: expected string",
1492            ));
1493        }
1494        let out = group_violations_by_request(&flat);
1495        let rows = out.as_array().unwrap();
1496        assert_eq!(rows.len(), 1);
1497        assert_eq!(rows[0]["violation_count"], 1, "duplicate iterations collapse");
1498        let checks = rows[0]["checks"].as_array().unwrap();
1499        assert_eq!(checks.len(), 1, "the same check listed once");
1500    }
1501
1502    /// Distinct URLs stay distinct.
1503    #[test]
1504    fn by_request_keeps_distinct_urls_separate() {
1505        let flat = vec![
1506            viol("c1", "POST", "https://host/a", "body_type_mismatch", "a"),
1507            viol("c2", "GET", "https://host/b", "query_value_mismatch", "b"),
1508        ];
1509        let out = group_violations_by_request(&flat);
1510        assert_eq!(out.as_array().unwrap().len(), 2);
1511    }
1512}
1513
1514#[cfg(test)]
1515mod emitted_body_tests {
1516    use super::validate_emitted_requests_with_base_path;
1517    use std::io::Write;
1518
1519    /// Round 52 (#79) — Srikanth on 0.3.198: a `--conformance-self-test
1520    /// --targets-file` run reported "2700 request-body caught" in the
1521    /// summary but wrote EMPTY `conformance-request-violations-by-request.json`
1522    /// and `-by-probe.json`. Root cause: the emitted-request validator's
1523    /// body check (`check_body_against_schema`) only fired when the
1524    /// requestBody media schema was an inline `ReferenceOr::Item`. The
1525    /// Apigee spec (like most real specs) declares
1526    /// `requestBody.content.application/json.schema.$ref =
1527    /// #/components/schemas/GoogleCloudApigeeV1Organization`, so
1528    /// `schema_ref.as_item()` returned `None` and every body probe was
1529    /// skipped. It also only type-checked STRING property values, so a
1530    /// `{"analyticsRegion":12345}` (number where string expected) probe
1531    /// produced no violation even when the schema resolved.
1532    ///
1533    /// This reproduces the multi-target self-test shape: a JSONL of
1534    /// captured probes, a spec whose requestBody is a `$ref`, and the
1535    /// exact negative labels the self-test generator emits.
1536    #[tokio::test]
1537    async fn emitted_requests_validate_ref_bodied_negatives() {
1538        let dir = tempfile::tempdir().expect("tempdir");
1539
1540        // Spec: /v1/organizations POST, requestBody is a $ref to a
1541        // component schema (the real-world shape). No `required` fields
1542        // so the positive `{}` probe stays clean (no false positive).
1543        let spec_json = serde_json::json!({
1544            "openapi": "3.0.0",
1545            "info": { "title": "apigee-min", "version": "1.0.0" },
1546            "paths": {
1547                "/v1/organizations": {
1548                    "post": {
1549                        "requestBody": {
1550                            "content": {
1551                                "application/json": {
1552                                    "schema": { "$ref": "#/components/schemas/Organization" }
1553                                }
1554                            }
1555                        },
1556                        "responses": { "200": { "description": "ok" } }
1557                    }
1558                }
1559            },
1560            "components": {
1561                "schemas": {
1562                    "Organization": {
1563                        "type": "object",
1564                        "properties": {
1565                            "analyticsRegion": { "type": "string" },
1566                            "displayName": { "type": "string" }
1567                        }
1568                    }
1569                }
1570            }
1571        });
1572        let spec_path = dir.path().join("apigee-min.json");
1573        std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1574
1575        // JSONL of captured probes, mirroring the self-test capture shape
1576        // (label / method / url / request_body). One positive, two
1577        // negatives (a type-mismatch on a $ref'd property, and a
1578        // wrong-root-type body).
1579        let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1580        let mut f = std::fs::File::create(&jsonl_path).unwrap();
1581        let base = "https://172.22.232.2:443/v1/organizations?alt=json";
1582        for line in [
1583            serde_json::json!({
1584                "label": "positive", "method": "POST", "url": base, "request_body": "{}"
1585            }),
1586            serde_json::json!({
1587                "label": "request-body:type-mismatch:analyticsRegion",
1588                "method": "POST", "url": base,
1589                "request_body": "{\"analyticsRegion\":12345}"
1590            }),
1591            serde_json::json!({
1592                "label": "request-body:wrong-type",
1593                "method": "POST", "url": base, "request_body": "[]"
1594            }),
1595        ] {
1596            writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1597        }
1598        drop(f);
1599
1600        let n = validate_emitted_requests_with_base_path(
1601            std::slice::from_ref(&spec_path),
1602            dir.path(),
1603            None,
1604        )
1605        .await
1606        .expect("validation runs");
1607
1608        assert!(n >= 2, "expected the two request-body negatives to be flagged, got {n}");
1609
1610        // The grouped files the user actually reads must be non-empty.
1611        let by_request = std::fs::read_to_string(
1612            dir.path().join("conformance-request-violations-by-request.json"),
1613        )
1614        .unwrap();
1615        let by_request: serde_json::Value = serde_json::from_str(&by_request).unwrap();
1616        assert!(
1617            !by_request.as_array().unwrap().is_empty(),
1618            "by-request file must not be empty for a spec with $ref request bodies"
1619        );
1620
1621        let by_probe = std::fs::read_to_string(
1622            dir.path().join("conformance-request-violations-by-probe.json"),
1623        )
1624        .unwrap();
1625        let by_probe: serde_json::Value = serde_json::from_str(&by_probe).unwrap();
1626        assert!(!by_probe.as_array().unwrap().is_empty(), "by-probe file must not be empty");
1627
1628        // The type-mismatch probe must surface as a violation naming the field.
1629        let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1630            .unwrap();
1631        assert!(
1632            flat.contains("analyticsRegion"),
1633            "the number-where-string probe must be reported: {flat}"
1634        );
1635    }
1636
1637    /// Round 53 (#79) — Srikanth on 0.3.199: body violations now populate,
1638    /// but the logs contain ONLY `request-body:*` rows. His console reported
1639    /// 7602 missed `owasp` and 3248 missed `parameters` negatives, yet not a
1640    /// single `owasp:*` row or query/path violation appeared.
1641    ///
1642    /// Two distinct causes, both reproduced here against the real wire shape:
1643    ///
1644    /// 1. Every owasp probe injects into `$.xgafv`, which reaches the wire
1645    ///    percent-encoded as `%24.xgafv`. The validator built `sent_query`
1646    ///    from the RAW query string, then looked the parameter up by the
1647    ///    spec's DECODED name (`$.xgafv`), so the lookup missed and all 7602
1648    ///    probes were silently skipped. (Round 45's test used a plain `alt`
1649    ///    param, which needs no encoding, so this stayed latent.)
1650    ///
1651    /// 2. `parameters:missing-query` DROPS a required query param. The loop
1652    ///    only inspected params that were actually sent, so a missing
1653    ///    required param could never be reported.
1654    #[tokio::test]
1655    async fn emitted_requests_flag_encoded_query_and_missing_required() {
1656        let dir = tempfile::tempdir().expect("tempdir");
1657
1658        // Spec mirrors the Apigee shape: a param whose name needs percent
1659        // encoding (`$.xgafv`, enum 1/2), a plain enum param, and a REQUIRED
1660        // param that the missing-query probe drops.
1661        let spec_json = serde_json::json!({
1662            "openapi": "3.0.0",
1663            "info": { "title": "apigee-min", "version": "1.0.0" },
1664            "paths": {
1665                "/v1/organizations": {
1666                    "post": {
1667                        "parameters": [
1668                            { "name": "$.xgafv", "in": "query",
1669                              "schema": { "type": "string", "enum": ["1", "2"] } },
1670                            { "name": "alt", "in": "query",
1671                              "schema": { "type": "string", "enum": ["json", "media"] } },
1672                            { "name": "parent", "in": "query", "required": true,
1673                              "schema": { "type": "string" } }
1674                        ],
1675                        "responses": { "200": { "description": "ok" } }
1676                    }
1677                }
1678            }
1679        });
1680        let spec_path = dir.path().join("apigee-min.json");
1681        std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1682
1683        let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1684        let mut f = std::fs::File::create(&jsonl_path).unwrap();
1685        let base = "https://172.22.232.2:443/v1/organizations";
1686        for line in [
1687            // Valid baseline: nothing should be reported.
1688            serde_json::json!({
1689                "label": "positive", "method": "POST",
1690                "url": format!("{base}?%24.xgafv=1&alt=json&parent=test-value"),
1691                "request_body": ""
1692            }),
1693            // owasp:sqli injects `' OR '1'='1` into the ENCODED `%24.xgafv`.
1694            serde_json::json!({
1695                "label": "owasp:sqli", "method": "POST",
1696                "url": format!("{base}?%24.xgafv=%27%20OR%20%271%27%3D%271&alt=json&parent=test-value"),
1697                "request_body": ""
1698            }),
1699            // parameters:missing-query drops the required `parent`.
1700            serde_json::json!({
1701                "label": "parameters:missing-query", "method": "POST",
1702                "url": format!("{base}?%24.xgafv=1&alt=json"),
1703                "request_body": ""
1704            }),
1705        ] {
1706            writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1707        }
1708        drop(f);
1709
1710        let n = validate_emitted_requests_with_base_path(
1711            std::slice::from_ref(&spec_path),
1712            dir.path(),
1713            None,
1714        )
1715        .await
1716        .expect("validation runs");
1717        assert!(n >= 2, "expected owasp + missing-required to be flagged, got {n}");
1718
1719        let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1720            .unwrap();
1721        let flat: serde_json::Value = serde_json::from_str(&flat).unwrap();
1722        let rows = flat.as_array().unwrap();
1723
1724        // The owasp probe must surface as a query violation on the DECODED
1725        // param name, with the DECODED value in the message (not `%27%20OR...`).
1726        let owasp = rows
1727            .iter()
1728            .find(|r| r["check_name"] == "owasp:sqli")
1729            .expect("owasp:sqli must produce a violation");
1730        assert_eq!(owasp["violation_type"], "query_value_mismatch");
1731        let msg = owasp["message"].as_str().unwrap();
1732        assert!(msg.contains("$.xgafv"), "decoded param name expected: {msg}");
1733        assert!(msg.contains("' OR '1'='1"), "decoded value expected: {msg}");
1734
1735        // The missing required param must be reported.
1736        let missing = rows
1737            .iter()
1738            .find(|r| r["check_name"] == "parameters:missing-query")
1739            .expect("missing-query must produce a violation");
1740        assert_eq!(missing["violation_type"], "query_missing_required");
1741        assert!(missing["message"].as_str().unwrap().contains("parent"));
1742
1743        // The positive probe must stay clean (no false positives).
1744        assert!(
1745            !rows.iter().any(|r| r["check_name"] == "positive"),
1746            "positive probe must not be flagged: {rows:?}"
1747        );
1748    }
1749
1750    /// Round 55 (#79) — double slashes collapse so a `//v1/x` request (from a
1751    /// stray `--base-path /`) still matches the spec's `/v1/x`.
1752    #[test]
1753    fn collapse_slashes_normalises_double_slashes() {
1754        use super::collapse_slashes;
1755        assert_eq!(collapse_slashes("//v1/organizations"), "/v1/organizations");
1756        assert_eq!(collapse_slashes("/v1//x///y"), "/v1/x/y");
1757        assert_eq!(collapse_slashes("/v1/organizations"), "/v1/organizations");
1758        assert_eq!(collapse_slashes("/"), "/");
1759    }
1760
1761    /// Round 55 (#79) — Srikanth on 0.3.202: `--base-path /` produced
1762    /// `//v1/organizations` emitted URLs, which never matched the spec's
1763    /// `/v1/organizations`, so EVERY owasp/param/body violation vanished.
1764    /// The validator now collapses the double slash and still flags the
1765    /// body probe.
1766    #[tokio::test]
1767    async fn double_slashed_url_still_validates() {
1768        let dir = tempfile::tempdir().expect("tempdir");
1769        let spec_json = serde_json::json!({
1770            "openapi": "3.0.0",
1771            "info": { "title": "apigee-min", "version": "1.0.0" },
1772            "paths": {
1773                "/v1/organizations": {
1774                    "post": {
1775                        "requestBody": {
1776                            "content": {
1777                                "application/json": {
1778                                    "schema": {
1779                                        "type": "object",
1780                                        "properties": { "analyticsRegion": { "type": "string" } }
1781                                    }
1782                                }
1783                            }
1784                        },
1785                        "responses": { "200": { "description": "ok" } }
1786                    }
1787                }
1788            }
1789        });
1790        let spec_path = dir.path().join("apigee-min.json");
1791        std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1792
1793        let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1794        std::fs::write(
1795            &jsonl_path,
1796            serde_json::to_string(&serde_json::json!({
1797                "label": "request-body:type-mismatch:analyticsRegion",
1798                "method": "POST",
1799                // Note the DOUBLE slash after the host — the `--base-path /` bug.
1800                "url": "https://172.22.232.2:443//v1/organizations",
1801                "request_body": "{\"analyticsRegion\":12345}"
1802            }))
1803            .unwrap()
1804                + "\n",
1805        )
1806        .unwrap();
1807
1808        let n = validate_emitted_requests_with_base_path(
1809            std::slice::from_ref(&spec_path),
1810            dir.path(),
1811            // The exact combination Srikanth used: base_path = "/".
1812            Some("/"),
1813        )
1814        .await
1815        .expect("validation runs");
1816        assert!(n >= 1, "double-slashed URL must still match and flag the body, got {n}");
1817        let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1818            .unwrap();
1819        assert!(flat.contains("analyticsRegion"), "body probe must be reported: {flat}");
1820    }
1821
1822    /// Round 54 (#79) — the segment matcher must bind custom-verb path params
1823    /// (`{instance}:reportStatus`), not just bare `{name}` segments.
1824    #[test]
1825    fn segment_matcher_handles_custom_verbs() {
1826        use super::match_path_segment;
1827        // Bare placeholder.
1828        assert_eq!(match_path_segment("{name}", "abc"), Some(Some(("name", "abc".to_string()))));
1829        // Custom verb suffix — Google style.
1830        assert_eq!(
1831            match_path_segment("{instance}:reportStatus", "self-test-invalid-id:reportStatus"),
1832            Some(Some(("instance", "self-test-invalid-id".to_string())))
1833        );
1834        // Wrong verb -> no match.
1835        assert_eq!(match_path_segment("{instance}:reportStatus", "x:other"), None);
1836        // Literal segment matches only itself.
1837        assert_eq!(match_path_segment("v1", "v1"), Some(None));
1838        assert_eq!(match_path_segment("v1", "v2"), None);
1839    }
1840
1841    /// Round 54 (#79) — Srikanth on 0.3.200: OWASP violations now appear but
1842    /// parameter probes didn't. A `parameters:bad-path-param` probe hits a
1843    /// custom-verb path (`/v1/{instance}:reportStatus`); the path never matched
1844    /// the template (so validation was skipped entirely), and even when it did
1845    /// only enum/type were checked, not `pattern`/`maxLength`. This reproduces
1846    /// the probe against a constrained path param and asserts the violation.
1847    #[tokio::test]
1848    async fn emitted_requests_flag_bad_custom_verb_path_param() {
1849        let dir = tempfile::tempdir().expect("tempdir");
1850        let spec_json = serde_json::json!({
1851            "openapi": "3.0.0",
1852            "info": { "title": "apigee-min", "version": "1.0.0" },
1853            "paths": {
1854                "/v1/{instance}:reportStatus": {
1855                    "post": {
1856                        "parameters": [
1857                            { "name": "instance", "in": "path", "required": true,
1858                              "schema": { "type": "string", "maxLength": 8 } }
1859                        ],
1860                        "responses": { "200": { "description": "ok" } }
1861                    }
1862                }
1863            }
1864        });
1865        let spec_path = dir.path().join("apigee-min.json");
1866        std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1867
1868        let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1869        std::fs::write(
1870            &jsonl_path,
1871            serde_json::to_string(&serde_json::json!({
1872                "label": "parameters:bad-path-param",
1873                "method": "POST",
1874                // 20-char instance value > maxLength 8, on the custom-verb path.
1875                "url": "https://172.22.232.2:443/v1/self-test-invalid-id:reportStatus",
1876                "request_body": ""
1877            }))
1878            .unwrap()
1879                + "\n",
1880        )
1881        .unwrap();
1882
1883        let n = validate_emitted_requests_with_base_path(
1884            std::slice::from_ref(&spec_path),
1885            dir.path(),
1886            None,
1887        )
1888        .await
1889        .expect("validation runs");
1890        assert!(n >= 1, "the bad custom-verb path param must be flagged, got {n}");
1891
1892        let flat: serde_json::Value = serde_json::from_str(
1893            &std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1894                .unwrap(),
1895        )
1896        .unwrap();
1897        let row = flat
1898            .as_array()
1899            .unwrap()
1900            .iter()
1901            .find(|r| r["check_name"] == "parameters:bad-path-param")
1902            .expect("bad-path-param violation present");
1903        assert_eq!(row["violation_type"], "path_value_mismatch");
1904        let msg = row["message"].as_str().unwrap();
1905        assert!(msg.contains("instance") && msg.contains("maxLength"), "unexpected: {msg}");
1906    }
1907
1908    /// Round 56 (#79) — Srikanth on 0.3.203: parameter negatives never appeared
1909    /// in the logs because for his Apigee spec they don't breach the contract.
1910    /// The three probe types (missing OPTIONAL query, oversized extra query,
1911    /// unconstrained path value) are now recorded as `parameter_negative_probe`
1912    /// so every probe is visible, while a probe that DOES breach the contract
1913    /// still produces its hard violation (not the probe record).
1914    #[tokio::test]
1915    async fn parameter_negatives_are_recorded_even_when_spec_valid() {
1916        let dir = tempfile::tempdir().expect("tempdir");
1917        let spec_json = serde_json::json!({
1918            "openapi": "3.0.0",
1919            "info": { "title": "apigee-min", "version": "1.0.0" },
1920            "paths": {
1921                "/v1/organizations": {
1922                    "post": {
1923                        "parameters": [
1924                            { "name": "$.xgafv", "in": "query",
1925                              "schema": { "type": "string", "enum": ["1", "2"] } }
1926                        ],
1927                        "responses": { "200": { "description": "ok" } }
1928                    }
1929                }
1930            }
1931        });
1932        let spec_path = dir.path().join("apigee-min.json");
1933        std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1934
1935        let base = "https://172.22.232.2:443/v1/organizations";
1936        let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1937        let mut f = std::fs::File::create(&jsonl_path).unwrap();
1938        use std::io::Write as _;
1939        for line in [
1940            // Drops the OPTIONAL $.xgafv — spec-valid, but should be recorded.
1941            serde_json::json!({ "label": "parameters:missing-query", "method": "POST",
1942                "url": base, "request_body": "" }),
1943            // Adds an oversized extra param — spec-valid, but recorded.
1944            serde_json::json!({ "label": "parameters:uri-too-long", "method": "POST",
1945                "url": format!("{base}?p=xxxxxxxxxxxxxxxxxxxx"), "request_body": "" }),
1946            // owasp injects a BAD $.xgafv value — a real query breach, NOT a probe record.
1947            serde_json::json!({ "label": "owasp:sqli", "method": "POST",
1948                "url": format!("{base}?%24.xgafv=%27%20OR%201%3D1"), "request_body": "" }),
1949        ] {
1950            writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1951        }
1952        drop(f);
1953
1954        validate_emitted_requests_with_base_path(
1955            std::slice::from_ref(&spec_path),
1956            dir.path(),
1957            None,
1958        )
1959        .await
1960        .expect("runs");
1961        let flat: serde_json::Value = serde_json::from_str(
1962            &std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1963                .unwrap(),
1964        )
1965        .unwrap();
1966        let rows = flat.as_array().unwrap();
1967
1968        // Both parameter negatives are recorded as probe records.
1969        let param_probes: Vec<&serde_json::Value> = rows
1970            .iter()
1971            .filter(|r| r["violation_type"] == "parameter_negative_probe")
1972            .collect();
1973        assert!(
1974            param_probes.iter().any(|r| r["check_name"] == "parameters:missing-query"),
1975            "missing-query probe must be recorded: {rows:?}"
1976        );
1977        assert!(
1978            param_probes.iter().any(|r| r["check_name"] == "parameters:uri-too-long"),
1979            "uri-too-long probe must be recorded"
1980        );
1981        // The owasp probe is a REAL query breach, not a probe record.
1982        let owasp = rows.iter().find(|r| r["check_name"] == "owasp:sqli").expect("owasp present");
1983        assert_eq!(owasp["violation_type"], "query_value_mismatch");
1984    }
1985}