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/// Check if a concrete path matches a path template with {param} segments
205fn path_matches_template(concrete: &str, template: &str) -> bool {
206    let concrete_parts: Vec<&str> = concrete.split('/').collect();
207    let template_parts: Vec<&str> = template.split('/').collect();
208
209    if concrete_parts.len() != template_parts.len() {
210        return false;
211    }
212
213    concrete_parts
214        .iter()
215        .zip(template_parts.iter())
216        .all(|(c, t)| t.starts_with('{') && t.ends_with('}') || c == t)
217}
218
219/// Validate request body against the spec's requestBody schema
220#[allow(clippy::too_many_arguments)]
221fn validate_request_body(
222    check_name: &str,
223    method: &str,
224    path: &str,
225    body: Option<&str>,
226    operation: &openapiv3::Operation,
227    spec: &OpenAPI,
228    violations: &mut Vec<RequestViolation>,
229) {
230    let request_body_ref = match &operation.request_body {
231        Some(rb) => rb,
232        None => {
233            // Spec doesn't define a requestBody — body is optional
234            return;
235        }
236    };
237
238    // Resolve $ref if needed
239    let request_body = match request_body_ref {
240        ReferenceOr::Item(rb) => rb,
241        ReferenceOr::Reference { reference } => {
242            let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
243            match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
244                Some(ReferenceOr::Item(rb)) => rb,
245                _ => return,
246            }
247        }
248    };
249
250    // Check if body is required but missing
251    if request_body.required && body.is_none() {
252        violations.push(RequestViolation {
253            check_name: check_name.to_string(),
254            method: method.to_string(),
255            path: path.to_string(),
256            violation_type: "missing_required_body".to_string(),
257            message: "Spec requires a request body but none is provided in the check".to_string(),
258        });
259        return;
260    }
261
262    // If body is provided, validate against schema
263    if let Some(body_str) = body {
264        // Find JSON content type
265        let json_media = request_body.content.get("application/json").or_else(|| {
266            request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v)
267        });
268
269        if let Some(media) = json_media {
270            if let Some(schema_ref) = &media.schema {
271                // Resolve the immediate $ref (one level) to get the
272                // root schema, then hand both schema + spec to the
273                // ref-resolver helper so nested `$ref` strings (e.g.
274                // `#/components/schemas/Vcenter.VM.DiskCloneSpec`)
275                // resolve against the full document context.
276                //
277                // Round 18.3 — pre-fix this called
278                // `jsonschema::validator_for(&schema_json)` directly,
279                // which used the inner schema as the validator's
280                // document. Nested $refs to `#/components/schemas/X`
281                // then failed with "Pointer '...' does not exist"
282                // because the validator's document had no
283                // `components` key (Srikanth's vCenter run: 157
284                // violations).
285                let root_schema = match schema_ref {
286                    ReferenceOr::Item(s) => s.clone(),
287                    ReferenceOr::Reference { reference } => {
288                        let name =
289                            reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
290                        match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
291                            Some(ReferenceOr::Item(s)) => s.clone(),
292                            _ => return,
293                        }
294                    }
295                };
296
297                // Parse body as JSON and validate against schema
298                match serde_json::from_str::<serde_json::Value>(body_str) {
299                    Ok(body_value) => {
300                        match mockforge_openapi::schema_ref_resolver::build_validator(
301                            &root_schema,
302                            spec,
303                        ) {
304                            Ok(validator) => {
305                                let errors: Vec<_> = validator.iter_errors(&body_value).collect();
306                                for err in errors.iter().take(5) {
307                                    violations.push(RequestViolation {
308                                        check_name: check_name.to_string(),
309                                        method: method.to_string(),
310                                        path: path.to_string(),
311                                        violation_type: "body_schema_violation".to_string(),
312                                        message: format!(
313                                            "Request body schema violation at {}: {}",
314                                            err.instance_path, err
315                                        ),
316                                    });
317                                }
318                            }
319                            Err(_) => {
320                                // Schema itself is invalid — skip validation
321                            }
322                        }
323                    }
324                    Err(e) => {
325                        violations.push(RequestViolation {
326                            check_name: check_name.to_string(),
327                            method: method.to_string(),
328                            path: path.to_string(),
329                            violation_type: "body_not_json".to_string(),
330                            message: format!("Request body is not valid JSON: {}", e),
331                        });
332                    }
333                }
334            }
335        }
336    }
337}
338
339/// Validate required parameters from the spec
340#[allow(clippy::too_many_arguments)]
341fn validate_parameters(
342    check_name: &str,
343    method: &str,
344    path: &str,
345    check_path_no_query: &str,
346    check_headers: &HashMap<String, String>,
347    operation: &openapiv3::Operation,
348    path_item: &openapiv3::PathItem,
349    spec: &OpenAPI,
350    violations: &mut Vec<RequestViolation>,
351) {
352    // Collect all parameters (path-level + operation-level)
353    let mut all_params = Vec::new();
354    for p in &path_item.parameters {
355        if let Some(param) = resolve_parameter(p, spec) {
356            all_params.push(param);
357        }
358    }
359    for p in &operation.parameters {
360        if let Some(param) = resolve_parameter(p, spec) {
361            all_params.push(param);
362        }
363    }
364
365    for param in &all_params {
366        let param_data = match param {
367            openapiv3::Parameter::Query { parameter_data, .. } => {
368                if !parameter_data.required {
369                    continue;
370                }
371                // Check if query param is in the path's query string
372                let has_param = check_path_no_query != path
373                    && path.contains(&format!("{}=", parameter_data.name));
374                if !has_param {
375                    violations.push(RequestViolation {
376                        check_name: check_name.to_string(),
377                        method: method.to_string(),
378                        path: path.to_string(),
379                        violation_type: "missing_required_query_param".to_string(),
380                        message: format!(
381                            "Required query parameter '{}' is missing",
382                            parameter_data.name
383                        ),
384                    });
385                }
386                continue;
387            }
388            openapiv3::Parameter::Header { parameter_data, .. } => parameter_data,
389            openapiv3::Parameter::Path { parameter_data, .. } => {
390                // Path params are always required — but they're embedded in the URL
391                // so we can't easily validate them here (they're already resolved)
392                let _ = parameter_data;
393                continue;
394            }
395            openapiv3::Parameter::Cookie { .. } => continue,
396        };
397
398        if param_data.required {
399            let has_header = check_headers.keys().any(|k| k.eq_ignore_ascii_case(&param_data.name));
400            if !has_header {
401                violations.push(RequestViolation {
402                    check_name: check_name.to_string(),
403                    method: method.to_string(),
404                    path: path.to_string(),
405                    violation_type: "missing_required_header".to_string(),
406                    message: format!("Required header parameter '{}' is missing", param_data.name),
407                });
408            }
409        }
410    }
411}
412
413/// Resolve a parameter reference
414fn resolve_parameter<'a>(
415    param_ref: &'a ReferenceOr<openapiv3::Parameter>,
416    spec: &'a OpenAPI,
417) -> Option<&'a openapiv3::Parameter> {
418    match param_ref {
419        ReferenceOr::Item(p) => Some(p),
420        ReferenceOr::Reference { reference } => {
421            let name = reference.strip_prefix("#/components/parameters/")?;
422            match spec.components.as_ref()?.parameters.get(name)? {
423                ReferenceOr::Item(p) => Some(p),
424                _ => None,
425            }
426        }
427    }
428}
429
430/// Round 53 (#79) — percent-decode a query key/value or a path segment.
431///
432/// The spec declares parameters by their decoded name (`$.xgafv`), but the
433/// wire carries them encoded (`%24.xgafv`). Matching the raw wire key against
434/// the spec name silently skipped every parameter whose name needs escaping,
435/// which is why all of Srikanth's `owasp:*` probes (they all inject into
436/// `$.xgafv`) produced zero violations. Decoding the value as well keeps the
437/// reported message readable.
438///
439/// Falls back to the input unchanged when it isn't valid percent-encoded
440/// UTF-8, so a malformed probe can never panic or drop the parameter.
441fn pct_decode(s: &str) -> String {
442    urlencoding::decode(s).map(|c| c.into_owned()).unwrap_or_else(|_| s.to_string())
443}
444
445/// Round 53 (#79) — resolve a parameter's schema, following a single
446/// `#/components/schemas/...` reference. Mirrors the request-body resolution
447/// added in r52; without it a `$ref`'d parameter schema is silently skipped.
448fn resolve_param_schema<'a>(
449    schema_ref: &'a ReferenceOr<openapiv3::Schema>,
450    spec: &'a OpenAPI,
451) -> Option<&'a openapiv3::Schema> {
452    match schema_ref {
453        ReferenceOr::Item(s) => Some(s),
454        ReferenceOr::Reference { reference } => {
455            let name = reference.strip_prefix("#/components/schemas/")?;
456            match spec.components.as_ref()?.schemas.get(name)? {
457                ReferenceOr::Item(s) => Some(s),
458                _ => None,
459            }
460        }
461    }
462}
463
464/// Resolve a schema reference to a serde_json::Value for validation.
465/// Reserved for round 21.3 (response-body shape validation against the
466/// spec's response schema). Not yet wired into a call site.
467#[allow(dead_code)]
468fn resolve_schema_to_json(
469    schema_ref: &ReferenceOr<openapiv3::Schema>,
470    spec: &OpenAPI,
471) -> Option<serde_json::Value> {
472    let schema = match schema_ref {
473        ReferenceOr::Item(s) => s,
474        ReferenceOr::Reference { reference } => {
475            let name = reference.strip_prefix("#/components/schemas/")?;
476            match spec.components.as_ref()?.schemas.get(name)? {
477                ReferenceOr::Item(s) => s,
478                _ => return None,
479            }
480        }
481    };
482    serde_json::to_value(schema).ok()
483}
484
485/// Run request validation and write results to a file.
486/// Called from the conformance execution path.
487pub async fn run_request_validation(
488    spec_files: &[std::path::PathBuf],
489    custom_checks_file: Option<&Path>,
490    base_path: Option<&str>,
491    output_dir: &Path,
492) -> Result<usize> {
493    let custom_file = match custom_checks_file {
494        Some(f) => f,
495        None => return Ok(0),
496    };
497
498    if spec_files.is_empty() {
499        return Ok(0);
500    }
501
502    let parser = SpecParser::from_file(&spec_files[0]).await?;
503    let spec = parser.spec();
504
505    let violations = validate_custom_checks(spec, custom_file, base_path)?;
506
507    if !violations.is_empty() {
508        let path = output_dir.join("conformance-request-violations.json");
509        if let Ok(json) = serde_json::to_string_pretty(&violations) {
510            let _ = std::fs::write(&path, json);
511            tracing::info!(
512                "Found {} request validation violation(s), saved to {}",
513                violations.len(),
514                path.display()
515            );
516        }
517    }
518
519    Ok(violations.len())
520}
521
522/// Round 44 (#79) — validate each emitted request retrospectively
523/// against the OpenAPI spec, after the bench run completes. Reads
524/// `conformance-requests.json` (which `--export-requests` writes) and
525/// emits one [`RequestViolation`] entry per actual wire-level
526/// rule break (enum, type, required field, etc.), so a user can see
527/// the client's own view of what it sent that violated the contract
528/// without having to query the server's `/__mockforge/api/conformance/violations`.
529///
530/// Srikanth on 0.3.188: "Any reason why validate-requests in mockforge
531/// client are not catching all this query param or body params or path
532/// params violation issues and record in conformance-request-failure
533/// logs?" The existing `validate_custom_checks` only looks at the YAML
534/// shape at config time (missing required params, unknown path);
535/// auto-generated self-test probes ARE intentionally invalid but were
536/// never recorded client-side because they don't come from the YAML.
537/// This function complements the YAML-shape pass by checking each
538/// emitted request against the spec's actual rule set.
539///
540/// Appends to (not overwrites) `conformance-request-violations.json`
541/// when YAML-shape violations were already written above, so a single
542/// file holds both views.
543pub async fn validate_emitted_requests(
544    spec_files: &[std::path::PathBuf],
545    output_dir: &Path,
546) -> Result<usize> {
547    validate_emitted_requests_with_base_path(spec_files, output_dir, None).await
548}
549
550/// Round 45 (#79) — same as `validate_emitted_requests` but accepts an
551/// explicit `base_path` (e.g. Srikanth's `--base-path /api` for the
552/// Apigee spec where every operation lives under `/api/v1/...` on the
553/// wire but `/v1/...` in the spec). Without it the emitted URL doesn't
554/// match the spec path and every request silently skips validation.
555///
556/// Also broadened in r45 to:
557/// - extract path params from the URL and validate their values
558///   against the spec's path-parameter schemas (enum / type)
559/// - parse the request body when content-type is JSON and walk it
560///   against the requestBody schema's `required: [...]` and enum
561///   constraints on top-level properties
562///
563/// Body and path-param coverage is INTENTIONALLY shallow (top-level
564/// `required` + `enum`/`type` on direct properties only) — the
565/// authoritative validator is the OpenAPI server's; this is the
566/// client-side cross-check that mirrors the server's view on the
567/// wire-level requests the bench actually sent.
568pub async fn validate_emitted_requests_with_base_path(
569    spec_files: &[std::path::PathBuf],
570    output_dir: &Path,
571    base_path: Option<&str>,
572) -> Result<usize> {
573    use serde_json::Value;
574
575    if spec_files.is_empty() {
576        return Ok(0);
577    }
578    let requests_path = output_dir.join("conformance-requests.json");
579    let self_test_jsonl_path = output_dir.join("conformance-self-test-requests.jsonl");
580
581    // Round 49 (#79) — Srikanth on 0.3.193: self-test + --targets-file
582    // produced no violation logs because validate_emitted_requests
583    // only reads `conformance-requests.json` (the bench export
584    // shape), and self-test writes `conformance-self-test-
585    // requests.jsonl` (the CaseCapture shape). Now read whichever
586    // exists, converting the JSONL shape into the same `{check,
587    // method, url, request.body}` structure the validator below
588    // expects. If both exist, the bench export wins (a deliberate
589    // bench run shouldn't be overridden by stale self-test output).
590    let entries: Vec<Value> = if requests_path.exists() {
591        let bytes = match std::fs::read(&requests_path) {
592            Ok(b) => b,
593            Err(_) => return Ok(0),
594        };
595        match serde_json::from_slice(&bytes) {
596            Ok(v) => v,
597            Err(_) => return Ok(0),
598        }
599    } else if self_test_jsonl_path.exists() {
600        let bytes = match std::fs::read(&self_test_jsonl_path) {
601            Ok(b) => b,
602            Err(_) => return Ok(0),
603        };
604        let text = String::from_utf8_lossy(&bytes);
605        text.lines()
606            .filter(|l| !l.is_empty())
607            .filter_map(|l| serde_json::from_str::<Value>(l).ok())
608            .map(|case| {
609                let label = case.get("label").and_then(|v| v.as_str()).unwrap_or("").to_string();
610                let method = case.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
611                let url = case.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
612                let body = case.get("request_body").cloned().unwrap_or(Value::Null);
613                let mut req = serde_json::Map::new();
614                req.insert("method".into(), Value::String(method));
615                req.insert("url".into(), Value::String(url));
616                req.insert(
617                    "body".into(),
618                    match body {
619                        Value::String(s) => Value::String(s),
620                        Value::Null => Value::String(String::new()),
621                        other => other,
622                    },
623                );
624                let mut out = serde_json::Map::new();
625                out.insert("check".into(), Value::String(label));
626                out.insert("request".into(), Value::Object(req));
627                Value::Object(out)
628            })
629            .collect()
630    } else {
631        return Ok(0);
632    };
633    if entries.is_empty() {
634        return Ok(0);
635    }
636
637    let parser = SpecParser::from_file(&spec_files[0]).await?;
638    let spec = parser.spec();
639    let spec_ops = build_spec_operation_map(spec);
640
641    let mut emitted_violations: Vec<RequestViolation> = Vec::new();
642
643    for entry in &entries {
644        let check = entry.get("check").and_then(|v| v.as_str()).unwrap_or("").to_string();
645        let req = match entry.get("request") {
646            Some(r) => r,
647            None => continue,
648        };
649        let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("").to_uppercase();
650        let url = req.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
651        if method.is_empty() || url.is_empty() {
652            continue;
653        }
654        let (path_only, query_string) = match url.find('?') {
655            Some(i) => (url[..i].to_string(), url[i + 1..].to_string()),
656            None => (url.clone(), String::new()),
657        };
658        // Trim scheme + host from path so we match spec paths cleanly.
659        // "http://host:port/api/x" → "/api/x".
660        let path_only = if let Some(stripped) = path_only.split_once("://") {
661            match stripped.1.find('/') {
662                Some(i) => stripped.1[i..].to_string(),
663                None => "/".to_string(),
664            }
665        } else {
666            path_only
667        };
668
669        // Round 45 — strip base_path BEFORE matching so an Apigee-style
670        // `/api/v1/organizations` on the wire matches `/v1/organizations`
671        // in the spec when `--base-path /api` was passed.
672        let lookup_path = if let Some(bp) = base_path {
673            let bp = bp.trim_end_matches('/');
674            if !bp.is_empty() && path_only.starts_with(bp) {
675                let stripped = &path_only[bp.len()..];
676                if stripped.is_empty() {
677                    "/".to_string()
678                } else {
679                    stripped.to_string()
680                }
681            } else {
682                path_only.clone()
683            }
684        } else {
685            path_only.clone()
686        };
687
688        let spec_path = match find_matching_spec_path(&lookup_path, &spec_ops, None) {
689            Some(p) => p,
690            None => continue,
691        };
692        let path_item = match spec.paths.paths.get(&spec_path) {
693            Some(ReferenceOr::Item(item)) => item,
694            _ => continue,
695        };
696        let operation = match method.as_str() {
697            "GET" => path_item.get.as_ref(),
698            "POST" => path_item.post.as_ref(),
699            "PUT" => path_item.put.as_ref(),
700            "DELETE" => path_item.delete.as_ref(),
701            "PATCH" => path_item.patch.as_ref(),
702            "HEAD" => path_item.head.as_ref(),
703            "OPTIONS" => path_item.options.as_ref(),
704            _ => None,
705        };
706        let Some(operation) = operation else { continue };
707
708        // Inspect query parameters declared on this operation; for each
709        // sent query field, check it against the parameter's schema enum
710        // and type. This is what catches Srikanth's `?$.xgafv=test-value`
711        // case where the value isn't `"1"` or `"2"`.
712        //
713        // Round 53 (#79) — percent-decode BOTH the key and the value. The
714        // spec declares the parameter as `$.xgafv`, but it reaches the wire
715        // as `%24.xgafv`, so matching the raw key against the spec name
716        // missed every time and silently skipped the parameter. That hid all
717        // 7602 of Srikanth's `owasp:*` probes, which all inject into
718        // `$.xgafv`. Decoding the value too keeps the violation message
719        // readable (`' OR '1'='1` rather than `%27%20OR%20%271%27%3D%271`).
720        let sent_query: HashMap<String, String> = query_string
721            .split('&')
722            .filter_map(|kv| {
723                let mut it = kv.splitn(2, '=');
724                let k = pct_decode(it.next()?);
725                let v = pct_decode(it.next().unwrap_or(""));
726                if k.is_empty() {
727                    None
728                } else {
729                    Some((k, v))
730                }
731            })
732            .collect();
733
734        // Round 45 — bind path parameters by zipping the concrete URL
735        // path against the spec's template path. `/v1/{name}` ←
736        // `/v1/projects/abc` produces `{ "name": "projects/abc" }`.
737        // Used below to value-check each path-param against its
738        // declared schema (enum / type).
739        let path_params: HashMap<String, String> = {
740            let mut out = HashMap::new();
741            let concrete_parts: Vec<&str> = lookup_path.split('/').collect();
742            let template_parts: Vec<&str> = spec_path.split('/').collect();
743            if concrete_parts.len() == template_parts.len() {
744                for (c, t) in concrete_parts.iter().zip(template_parts.iter()) {
745                    if t.starts_with('{') && t.ends_with('}') {
746                        let name = &t[1..t.len() - 1];
747                        // Round 53 — path segments arrive percent-encoded too.
748                        out.insert(name.to_string(), pct_decode(c));
749                    }
750                }
751            }
752            out
753        };
754
755        let mut all_params: Vec<&openapiv3::Parameter> = Vec::new();
756        for p in &path_item.parameters {
757            if let Some(param) = resolve_parameter(p, spec) {
758                all_params.push(param);
759            }
760        }
761        for p in &operation.parameters {
762            if let Some(param) = resolve_parameter(p, spec) {
763                all_params.push(param);
764            }
765        }
766
767        for param in &all_params {
768            let (loc_str, name, schema_ref) = match param {
769                openapiv3::Parameter::Query { parameter_data, .. } => {
770                    let openapiv3::ParameterSchemaOrContent::Schema(sref) = &parameter_data.format
771                    else {
772                        continue;
773                    };
774                    let Some(v) = sent_query.get(&parameter_data.name) else {
775                        // Round 53 (#79) — a REQUIRED query param that never
776                        // reached the wire is itself a spec violation. The
777                        // loop previously only inspected params that were
778                        // sent, so `parameters:missing-query` probes (which
779                        // drop a required param on purpose) produced nothing.
780                        if parameter_data.required {
781                            emitted_violations.push(RequestViolation {
782                                check_name: check.clone(),
783                                method: method.clone(),
784                                path: url.clone(),
785                                violation_type: "query_missing_required".to_string(),
786                                message: format!(
787                                    "query.{}: required parameter missing",
788                                    parameter_data.name
789                                ),
790                            });
791                        }
792                        continue;
793                    };
794                    ("query", &parameter_data.name, (sref, v.clone()))
795                }
796                openapiv3::Parameter::Path { parameter_data, .. } => {
797                    let openapiv3::ParameterSchemaOrContent::Schema(sref) = &parameter_data.format
798                    else {
799                        continue;
800                    };
801                    let Some(v) = path_params.get(&parameter_data.name) else {
802                        continue;
803                    };
804                    ("path", &parameter_data.name, (sref, v.clone()))
805                }
806                _ => continue,
807            };
808            let (schema_ref, value) = schema_ref;
809            // Round 53 — resolve `$ref` parameter schemas too; the same
810            // `as_item()` blind spot that hid `$ref` request bodies in r52
811            // applies to parameters whose schema is a component reference.
812            let Some(schema) = resolve_param_schema(schema_ref, spec) else {
813                continue;
814            };
815            if let Some(msg) = check_value_against_schema(&value, schema) {
816                emitted_violations.push(RequestViolation {
817                    check_name: check.clone(),
818                    method: method.clone(),
819                    path: url.clone(),
820                    violation_type: format!("{}_value_mismatch", loc_str),
821                    message: format!("{}.{}: {}", loc_str, name, msg),
822                });
823            }
824        }
825
826        // Round 45 — request-body cross-check. Only kicks in when the
827        // sent body parses as JSON and the operation declares a JSON
828        // requestBody schema.
829        //
830        // Round 52 (#79) — Srikanth on 0.3.198: a self-test +
831        // `--targets-file` run reported "2700 request-body caught" but
832        // the by-request / by-probe violation files came out EMPTY. The
833        // old shallow check here only fired when the requestBody media
834        // schema was an inline `ReferenceOr::Item`; the Apigee spec (and
835        // most real specs) declares
836        // `schema.$ref = #/components/schemas/GoogleCloudApigeeV1Organization`,
837        // so `schema_ref.as_item()` returned `None` and every body probe
838        // was silently skipped. It also only type-checked STRING values,
839        // so a `{"analyticsRegion":12345}` (number-where-string) probe
840        // never surfaced even when the schema resolved. We now resolve
841        // the requestBody + schema `$ref`s and reuse the same full JSON
842        // Schema validator `validate_custom_checks` uses (round 18.3's
843        // `build_validator`), so nested `$ref`s, root-type mismatches,
844        // and non-string type mismatches are all caught.
845        let body_str = req.get("body").and_then(|v| v.as_str()).unwrap_or("");
846        if !body_str.is_empty() {
847            if let Ok(body_json) = serde_json::from_str::<serde_json::Value>(body_str) {
848                validate_emitted_body(
849                    &check,
850                    &method,
851                    &url,
852                    &body_json,
853                    operation,
854                    spec,
855                    &mut emitted_violations,
856                );
857            }
858        }
859    }
860
861    // Merge with any pre-existing custom-YAML violations on disk.
862    let dst = output_dir.join("conformance-request-violations.json");
863    let mut all: Vec<Value> = if dst.exists() {
864        match std::fs::read(&dst) {
865            Ok(b) => serde_json::from_slice(&b).unwrap_or_default(),
866            Err(_) => Vec::new(),
867        }
868    } else {
869        Vec::new()
870    };
871    for v in &emitted_violations {
872        if let Ok(val) = serde_json::to_value(v) {
873            all.push(val);
874        }
875    }
876    // Round 50 (#79) — dedup byte-identical violations. A multi-iteration
877    // self-test captures one probe per iteration, so a 22x duration run
878    // produced 22 copies of every violation in the flat file (and, before
879    // the grouping fixes below, 22 copies inside each grouped row). Keep
880    // the first occurrence of each (check_name, method, path,
881    // violation_type, message) tuple; re-runs that merged the on-disk file
882    // are collapsed too. Preserves first-seen order.
883    {
884        let mut seen: std::collections::HashSet<(String, String, String, String, String)> =
885            std::collections::HashSet::new();
886        all.retain(|v| {
887            let f = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
888            seen.insert((
889                f("check_name"),
890                f("method"),
891                f("path"),
892                f("violation_type"),
893                f("message"),
894            ))
895        });
896    }
897    if !all.is_empty() {
898        if let Ok(json) = serde_json::to_string_pretty(&all) {
899            let _ = std::fs::write(&dst, json);
900            tracing::info!(
901                "validate-requests: wrote {} entries to {} ({} from emitted requests)",
902                all.len(),
903                dst.display(),
904                emitted_violations.len()
905            );
906        }
907    }
908
909    // Round 46 (#79) — Srikanth on 0.3.190: "I see three different
910    // messages, is this message for 3 different requests or for 1
911    // request. if it is 1 request can we have 1 line item mentioning
912    // violation 1 = message1, violation2 = message2 etc". Emit a
913    // sibling file grouped by (check_name, method, path) so each
914    // wire-level request shows up as a single row carrying every
915    // violation it raised. The per-violation file stays as-is for
916    // tooling that wants the flat shape.
917    let grouped_dst = output_dir.join("conformance-request-violations-by-request.json");
918    let grouped_value = group_violations_by_request(&all);
919    if let Ok(json) = serde_json::to_string_pretty(&grouped_value) {
920        let _ = std::fs::write(&grouped_dst, json);
921    }
922
923    // Round 48 (#79) — Srikanth on 0.3.192: "Can I assume all this
924    // checks has some violation either in the incoming request or
925    // outgoing response if yes then how can I see all this violation
926    // individually? Do we have any other Logs pointing each of those
927    // so that I can fix in one go?" New per-probe drill-down file
928    // emits one row per (check_name, method, path) carrying its full
929    // flat violation list. Lets the user see EXACTLY what each probe
930    // pattern (body:json, schema:string, constraint:enum, etc.)
931    // surfaced rather than just the deduped union the
932    // by-request file shows.
933    let drill_dst = output_dir.join("conformance-request-violations-by-probe.json");
934    let drill_value = group_violations_by_probe(&all);
935    if let Ok(json) = serde_json::to_string_pretty(&drill_value) {
936        let _ = std::fs::write(&drill_dst, json);
937    }
938    Ok(emitted_violations.len())
939}
940
941/// Round 48 (#79) — emit one entry per (check_name, method, path)
942/// with its full violation list. Unlike `group_violations_by_request`,
943/// this preserves the per-probe view so the user can see WHICH spec-
944/// probing pattern (body:json / schema:string / constraint:enum /
945/// method:POST / etc.) surfaced WHICH violation. Sorted by check_name
946/// within the same (method, path) so probes group together visually.
947fn group_violations_by_probe(flat: &[serde_json::Value]) -> serde_json::Value {
948    use serde_json::{Map, Value};
949
950    let mut by_probe_order: Vec<(String, String, String)> = Vec::new();
951    let mut by_probe: std::collections::HashMap<(String, String, String), Vec<(String, String)>> =
952        std::collections::HashMap::new();
953
954    // Round 50 (#79) — Srikanth on 0.3.194: "I see same violation is
955    // getting printed in logs for 22 times" on a multi-iteration run.
956    // The self-test capture holds one probe per iteration, so a 22x
957    // duration run feeds 22 byte-identical violations per probe into
958    // this flat list and we used to append all 22. Dedup identical
959    // (violation_type, message) pairs WITHIN a probe so each unique
960    // violation shows exactly once regardless of iteration count.
961    let mut seen_in_probe: std::collections::HashSet<(String, String, String, String)> =
962        std::collections::HashSet::new();
963    for v in flat {
964        let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
965        let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
966        let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
967        let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
968        let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
969        let key = (check.clone(), method.clone(), path.clone());
970        if !by_probe.contains_key(&key) {
971            by_probe_order.push(key.clone());
972        }
973        if seen_in_probe.insert((check, method, path, format!("{vt}\u{0}{msg}"))) {
974            by_probe.entry(key).or_default().push((vt, msg));
975        }
976    }
977
978    // Sort within same (method, path) by check_name for visual grouping.
979    by_probe_order.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)).then(a.0.cmp(&b.0)));
980
981    let mut rows: Vec<Value> = Vec::with_capacity(by_probe_order.len());
982    for key in &by_probe_order {
983        let (check, method, path) = key;
984        let entries = by_probe.get(key).cloned().unwrap_or_default();
985        let mut row = Map::new();
986        row.insert("check_name".into(), Value::String(check.clone()));
987        row.insert("method".into(), Value::String(method.clone()));
988        row.insert("path".into(), Value::String(path.clone()));
989        row.insert(
990            "violation_count".into(),
991            Value::Number(serde_json::Number::from(entries.len())),
992        );
993        for (i, (vt, msg)) in entries.iter().enumerate() {
994            let mut entry = Map::new();
995            entry.insert("violation_type".into(), Value::String(vt.clone()));
996            entry.insert("message".into(), Value::String(msg.clone()));
997            row.insert(format!("violation_{}", i + 1), Value::Object(entry));
998        }
999        rows.push(Value::Object(row));
1000    }
1001    Value::Array(rows)
1002}
1003
1004/// Round 46–50 (#79) — collapse the flat list of
1005/// [`RequestViolation`]-shaped JSON values into exactly ONE entry per
1006/// `(method, path)`.
1007///
1008/// History: Round 46 keyed on `(check_name, method, path)` (too many
1009/// duplicate rows). Round 47 collapsed by `(method, path)` AND the
1010/// violation set, listing contributing checks in a `checks: [...]`
1011/// array. But that re-split a single URL whenever two probe families
1012/// produced DIFFERENT violation sets for it — Srikanth on 0.3.194:
1013/// `owasp:ldap-injection` (query violations) landed in a different
1014/// by-request row than the `request-body:*` checks for the very same
1015/// URL, so his triage flow ("find the URL with the most violations
1016/// here, then drill into by-probe") missed half the picture.
1017///
1018/// Round 50 makes this file the authoritative per-URL overview: one row
1019/// per `(method, path)` carrying the DEDUPED UNION of every violation
1020/// and every contributing `check_name`. The per-probe attribution
1021/// ("which check surfaced which violation") lives in the sibling
1022/// `conformance-request-violations-by-probe.json`. First-seen order is
1023/// preserved for both checks and violations so the output is stable.
1024fn group_violations_by_request(flat: &[serde_json::Value]) -> serde_json::Value {
1025    use serde_json::{Map, Value};
1026
1027    let mut order: Vec<(String, String)> = Vec::new();
1028    let mut checks_by_key: std::collections::HashMap<(String, String), Vec<String>> =
1029        std::collections::HashMap::new();
1030    let mut viols_by_key: std::collections::HashMap<(String, String), Vec<(String, String)>> =
1031        std::collections::HashMap::new();
1032    // Per-(method,path) dedup sets so a check fired across 22 iterations,
1033    // or the same (vt,msg) surfaced by several checks, is counted once.
1034    let mut seen_check: std::collections::HashSet<(String, String, String)> =
1035        std::collections::HashSet::new();
1036    let mut seen_viol: std::collections::HashSet<(String, String, String)> =
1037        std::collections::HashSet::new();
1038
1039    for v in flat {
1040        let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
1041        let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
1042        let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
1043        let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
1044        let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
1045        let key = (method.clone(), path.clone());
1046        if !checks_by_key.contains_key(&key) && !viols_by_key.contains_key(&key) {
1047            order.push(key.clone());
1048        }
1049        if !check.is_empty() && seen_check.insert((method.clone(), path.clone(), check.clone())) {
1050            checks_by_key.entry(key.clone()).or_default().push(check);
1051        }
1052        if seen_viol.insert((method.clone(), path.clone(), format!("{vt}\u{0}{msg}"))) {
1053            viols_by_key.entry(key).or_default().push((vt, msg));
1054        }
1055    }
1056
1057    let mut rows: Vec<Value> = Vec::with_capacity(order.len());
1058    for key in &order {
1059        let (method, path) = key;
1060        let checks = checks_by_key.get(key).cloned().unwrap_or_default();
1061        let viols = viols_by_key.get(key).cloned().unwrap_or_default();
1062        let mut row = Map::new();
1063        row.insert(
1064            "checks".into(),
1065            Value::Array(checks.iter().map(|s| Value::String(s.clone())).collect()),
1066        );
1067        // Round 48 (#79) — keep a single representative `check_name`
1068        // pointing at the check whose family matches the FIRST violation,
1069        // so the headline check isn't misleading. The full set is in
1070        // `checks[]`; per-violation attribution is in the by-probe file.
1071        let dominant_prefix: &str = viols
1072            .first()
1073            .map(|(vt, _)| {
1074                if vt.starts_with("query_") {
1075                    "param:query"
1076                } else if vt.starts_with("body_") {
1077                    "body:"
1078                } else if vt.starts_with("path_") {
1079                    "param:path"
1080                } else if vt.starts_with("header_") {
1081                    "param:header"
1082                } else {
1083                    ""
1084                }
1085            })
1086            .unwrap_or("");
1087        let best_check = if !dominant_prefix.is_empty() {
1088            checks
1089                .iter()
1090                .find(|c| c.starts_with(dominant_prefix))
1091                .cloned()
1092                .or_else(|| checks.first().cloned())
1093                .unwrap_or_default()
1094        } else {
1095            checks.first().cloned().unwrap_or_default()
1096        };
1097        row.insert("check_name".into(), Value::String(best_check));
1098        row.insert("method".into(), Value::String(method.clone()));
1099        row.insert("path".into(), Value::String(path.clone()));
1100        row.insert("violation_count".into(), Value::Number(serde_json::Number::from(viols.len())));
1101        for (i, (vt, msg)) in viols.iter().enumerate() {
1102            let mut entry = Map::new();
1103            entry.insert("violation_type".into(), Value::String(vt.clone()));
1104            entry.insert("message".into(), Value::String(msg.clone()));
1105            row.insert(format!("violation_{}", i + 1), Value::Object(entry));
1106        }
1107        rows.push(Value::Object(row));
1108    }
1109    Value::Array(rows)
1110}
1111
1112/// Round 52 (#79) — validate an emitted request body against the
1113/// operation's requestBody schema, resolving `$ref` at both the
1114/// requestBody and schema level and delegating to the same full JSON
1115/// Schema validator (`build_validator`) the custom-checks path uses.
1116///
1117/// This replaced a shallow, `$ref`-unaware check that only fired for
1118/// inline schemas and only type-checked string values — which is why a
1119/// self-test run against the Apigee spec (whose request bodies are all
1120/// `$ref`s to component schemas) produced empty violation logs even
1121/// though the summary reported thousands of caught request-body
1122/// negatives. We cap at 5 errors per body so a deeply-broken probe
1123/// can't flood the log; the by-probe file still gets one row per probe.
1124fn validate_emitted_body(
1125    check: &str,
1126    method: &str,
1127    url: &str,
1128    body: &serde_json::Value,
1129    operation: &openapiv3::Operation,
1130    spec: &OpenAPI,
1131    violations: &mut Vec<RequestViolation>,
1132) {
1133    // Resolve the requestBody (may itself be a $ref into components).
1134    let Some(request_body_ref) = &operation.request_body else {
1135        return;
1136    };
1137    let request_body = match request_body_ref {
1138        ReferenceOr::Item(rb) => rb,
1139        ReferenceOr::Reference { reference } => {
1140            let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
1141            match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
1142                Some(ReferenceOr::Item(rb)) => rb,
1143                _ => return,
1144            }
1145        }
1146    };
1147
1148    // Only cross-check JSON bodies — other media types (multipart,
1149    // urlencoded) are handled elsewhere / by the server validator.
1150    let json_media = request_body
1151        .content
1152        .get("application/json")
1153        .or_else(|| request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v));
1154    let Some(media) = json_media else {
1155        return;
1156    };
1157    let Some(schema_ref) = &media.schema else {
1158        return;
1159    };
1160
1161    // Resolve the immediate schema $ref (one level) to the root schema,
1162    // then hand it to the resolver so nested $refs resolve against the
1163    // full document (round 18.3's fix for vCenter's nested components).
1164    let root_schema = match schema_ref {
1165        ReferenceOr::Item(s) => s.clone(),
1166        ReferenceOr::Reference { reference } => {
1167            let name = reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
1168            match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
1169                Some(ReferenceOr::Item(s)) => s.clone(),
1170                _ => return,
1171            }
1172        }
1173    };
1174
1175    let Ok(validator) = mockforge_openapi::schema_ref_resolver::build_validator(&root_schema, spec)
1176    else {
1177        // Schema itself is unbuildable — skip rather than false-positive.
1178        return;
1179    };
1180    for err in validator.iter_errors(body).take(5) {
1181        let loc = err.instance_path.to_string();
1182        let loc = if loc.is_empty() { "$".to_string() } else { loc };
1183        violations.push(RequestViolation {
1184            check_name: check.to_string(),
1185            method: method.to_string(),
1186            path: url.to_string(),
1187            violation_type: "body_schema_violation".to_string(),
1188            message: format!("body{}: {}", loc, err),
1189        });
1190    }
1191}
1192
1193/// Round 44 (#79) — minimal value-vs-schema check for the retroactive
1194/// emitted-request validator. Returns a human-readable error message
1195/// when the value doesn't satisfy the schema, or `None` when it does.
1196/// Only handles the rules Srikanth's Apigee spec uses (enum, type:
1197/// integer, type: boolean); falls through silently for any other
1198/// rule rather than producing a false positive.
1199fn check_value_against_schema(value: &str, schema: &openapiv3::Schema) -> Option<String> {
1200    use openapiv3::{SchemaKind, Type};
1201
1202    let SchemaKind::Type(t) = &schema.schema_kind else {
1203        return None;
1204    };
1205    match t {
1206        Type::String(s) => {
1207            if !s.enumeration.is_empty() {
1208                let allowed: Vec<String> = s.enumeration.iter().filter_map(|e| e.clone()).collect();
1209                if !allowed.iter().any(|a| a == value) {
1210                    let quoted: Vec<String> =
1211                        allowed.iter().map(|a| format!("\"{}\"", a)).collect();
1212                    return Some(format!(
1213                        "value \"{}\" is not one of {}",
1214                        value,
1215                        quoted.join(" or ")
1216                    ));
1217                }
1218            }
1219            None
1220        }
1221        Type::Integer(_) => {
1222            if value.parse::<i64>().is_err() {
1223                Some(format!("value \"{}\" is not of type \"integer\"", value))
1224            } else {
1225                None
1226            }
1227        }
1228        Type::Number(_) => {
1229            if value.parse::<f64>().is_err() {
1230                Some(format!("value \"{}\" is not of type \"number\"", value))
1231            } else {
1232                None
1233            }
1234        }
1235        Type::Boolean(_) => match value {
1236            "true" | "false" => None,
1237            _ => Some(format!("value \"{}\" is not of type \"boolean\"", value)),
1238        },
1239        _ => None,
1240    }
1241}
1242
1243#[cfg(test)]
1244mod grouping_tests {
1245    use super::{group_violations_by_probe, group_violations_by_request};
1246    use serde_json::json;
1247
1248    /// Build a flat violation value the way `validate_emitted_requests` does.
1249    fn viol(check: &str, method: &str, path: &str, vt: &str, msg: &str) -> serde_json::Value {
1250        json!({
1251            "check_name": check,
1252            "method": method,
1253            "path": path,
1254            "violation_type": vt,
1255            "message": msg,
1256        })
1257    }
1258
1259    /// Round 50 (#79) — reproduces Srikanth's 0.3.194 report: a single URL
1260    /// whose query violations come from `owasp:ldap-injection` while its
1261    /// body violations come from `request-body:*` checks must collapse into
1262    /// ONE by-request row that lists BOTH check families and the UNION of
1263    /// every violation. Previously these split into two separate rows, so
1264    /// the owasp check was invisible from the body row he was reading.
1265    #[test]
1266    fn by_request_unions_all_checks_for_a_url() {
1267        let path = "https://host/v1/organizations?alt=test-value&prettyPrint=test-value";
1268        let flat = vec![
1269            viol(
1270                "request-body:type-mismatch:billingType",
1271                "POST",
1272                path,
1273                "body_type_mismatch",
1274                "body.billingType: expected string",
1275            ),
1276            viol(
1277                "owasp:ldap-injection",
1278                "POST",
1279                path,
1280                "query_value_mismatch",
1281                "query.alt: value \"test-value\" is not one of \"json\" or \"media\"",
1282            ),
1283            viol(
1284                "owasp:ldap-injection",
1285                "POST",
1286                path,
1287                "query_value_mismatch",
1288                "query.prettyPrint: value \"test-value\" is not of type \"boolean\"",
1289            ),
1290        ];
1291
1292        let out = group_violations_by_request(&flat);
1293        let rows = out.as_array().expect("array");
1294        // Exactly one row for the URL — no fragmentation.
1295        assert_eq!(rows.len(), 1, "expected a single by-request row per URL");
1296        let row = &rows[0];
1297        assert_eq!(row["violation_count"], 3);
1298        let checks: Vec<&str> =
1299            row["checks"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect();
1300        assert!(checks.contains(&"owasp:ldap-injection"), "owasp check must appear: {checks:?}");
1301        assert!(
1302            checks.iter().any(|c| c.starts_with("request-body:")),
1303            "body check must appear: {checks:?}"
1304        );
1305    }
1306
1307    /// Round 50 (#79) — "I see same violation is getting printed in logs for
1308    /// 22 times." A multi-iteration run feeds N identical violations per
1309    /// probe; the by-probe drill-down must show each unique violation once.
1310    #[test]
1311    fn by_probe_dedups_repeated_iterations() {
1312        let path = "https://host/v1/organizations?alt=test-value";
1313        let mut flat = Vec::new();
1314        for _ in 0..22 {
1315            flat.push(viol(
1316                "owasp:ldap-injection",
1317                "POST",
1318                path,
1319                "query_value_mismatch",
1320                "query.alt: value \"test-value\" is not one of \"json\" or \"media\"",
1321            ));
1322        }
1323
1324        let out = group_violations_by_probe(&flat);
1325        let rows = out.as_array().expect("array");
1326        assert_eq!(rows.len(), 1, "one probe row");
1327        assert_eq!(rows[0]["violation_count"], 1, "22 identical iterations collapse to 1");
1328        assert!(rows[0].get("violation_1").is_some());
1329        assert!(rows[0].get("violation_2").is_none(), "no duplicate violation_2");
1330    }
1331
1332    /// The by-request union must also collapse the 22x duplicates, not just
1333    /// dedup across checks.
1334    #[test]
1335    fn by_request_dedups_repeated_iterations() {
1336        let path = "https://host/v1/widgets";
1337        let mut flat = Vec::new();
1338        for _ in 0..22 {
1339            flat.push(viol(
1340                "request-body:type-mismatch:name",
1341                "POST",
1342                path,
1343                "body_type_mismatch",
1344                "body.name: expected string",
1345            ));
1346        }
1347        let out = group_violations_by_request(&flat);
1348        let rows = out.as_array().unwrap();
1349        assert_eq!(rows.len(), 1);
1350        assert_eq!(rows[0]["violation_count"], 1, "duplicate iterations collapse");
1351        let checks = rows[0]["checks"].as_array().unwrap();
1352        assert_eq!(checks.len(), 1, "the same check listed once");
1353    }
1354
1355    /// Distinct URLs stay distinct.
1356    #[test]
1357    fn by_request_keeps_distinct_urls_separate() {
1358        let flat = vec![
1359            viol("c1", "POST", "https://host/a", "body_type_mismatch", "a"),
1360            viol("c2", "GET", "https://host/b", "query_value_mismatch", "b"),
1361        ];
1362        let out = group_violations_by_request(&flat);
1363        assert_eq!(out.as_array().unwrap().len(), 2);
1364    }
1365}
1366
1367#[cfg(test)]
1368mod emitted_body_tests {
1369    use super::validate_emitted_requests_with_base_path;
1370    use std::io::Write;
1371
1372    /// Round 52 (#79) — Srikanth on 0.3.198: a `--conformance-self-test
1373    /// --targets-file` run reported "2700 request-body caught" in the
1374    /// summary but wrote EMPTY `conformance-request-violations-by-request.json`
1375    /// and `-by-probe.json`. Root cause: the emitted-request validator's
1376    /// body check (`check_body_against_schema`) only fired when the
1377    /// requestBody media schema was an inline `ReferenceOr::Item`. The
1378    /// Apigee spec (like most real specs) declares
1379    /// `requestBody.content.application/json.schema.$ref =
1380    /// #/components/schemas/GoogleCloudApigeeV1Organization`, so
1381    /// `schema_ref.as_item()` returned `None` and every body probe was
1382    /// skipped. It also only type-checked STRING property values, so a
1383    /// `{"analyticsRegion":12345}` (number where string expected) probe
1384    /// produced no violation even when the schema resolved.
1385    ///
1386    /// This reproduces the multi-target self-test shape: a JSONL of
1387    /// captured probes, a spec whose requestBody is a `$ref`, and the
1388    /// exact negative labels the self-test generator emits.
1389    #[tokio::test]
1390    async fn emitted_requests_validate_ref_bodied_negatives() {
1391        let dir = tempfile::tempdir().expect("tempdir");
1392
1393        // Spec: /v1/organizations POST, requestBody is a $ref to a
1394        // component schema (the real-world shape). No `required` fields
1395        // so the positive `{}` probe stays clean (no false positive).
1396        let spec_json = serde_json::json!({
1397            "openapi": "3.0.0",
1398            "info": { "title": "apigee-min", "version": "1.0.0" },
1399            "paths": {
1400                "/v1/organizations": {
1401                    "post": {
1402                        "requestBody": {
1403                            "content": {
1404                                "application/json": {
1405                                    "schema": { "$ref": "#/components/schemas/Organization" }
1406                                }
1407                            }
1408                        },
1409                        "responses": { "200": { "description": "ok" } }
1410                    }
1411                }
1412            },
1413            "components": {
1414                "schemas": {
1415                    "Organization": {
1416                        "type": "object",
1417                        "properties": {
1418                            "analyticsRegion": { "type": "string" },
1419                            "displayName": { "type": "string" }
1420                        }
1421                    }
1422                }
1423            }
1424        });
1425        let spec_path = dir.path().join("apigee-min.json");
1426        std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1427
1428        // JSONL of captured probes, mirroring the self-test capture shape
1429        // (label / method / url / request_body). One positive, two
1430        // negatives (a type-mismatch on a $ref'd property, and a
1431        // wrong-root-type body).
1432        let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1433        let mut f = std::fs::File::create(&jsonl_path).unwrap();
1434        let base = "https://172.22.232.2:443/v1/organizations?alt=json";
1435        for line in [
1436            serde_json::json!({
1437                "label": "positive", "method": "POST", "url": base, "request_body": "{}"
1438            }),
1439            serde_json::json!({
1440                "label": "request-body:type-mismatch:analyticsRegion",
1441                "method": "POST", "url": base,
1442                "request_body": "{\"analyticsRegion\":12345}"
1443            }),
1444            serde_json::json!({
1445                "label": "request-body:wrong-type",
1446                "method": "POST", "url": base, "request_body": "[]"
1447            }),
1448        ] {
1449            writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1450        }
1451        drop(f);
1452
1453        let n = validate_emitted_requests_with_base_path(
1454            std::slice::from_ref(&spec_path),
1455            dir.path(),
1456            None,
1457        )
1458        .await
1459        .expect("validation runs");
1460
1461        assert!(n >= 2, "expected the two request-body negatives to be flagged, got {n}");
1462
1463        // The grouped files the user actually reads must be non-empty.
1464        let by_request = std::fs::read_to_string(
1465            dir.path().join("conformance-request-violations-by-request.json"),
1466        )
1467        .unwrap();
1468        let by_request: serde_json::Value = serde_json::from_str(&by_request).unwrap();
1469        assert!(
1470            !by_request.as_array().unwrap().is_empty(),
1471            "by-request file must not be empty for a spec with $ref request bodies"
1472        );
1473
1474        let by_probe = std::fs::read_to_string(
1475            dir.path().join("conformance-request-violations-by-probe.json"),
1476        )
1477        .unwrap();
1478        let by_probe: serde_json::Value = serde_json::from_str(&by_probe).unwrap();
1479        assert!(!by_probe.as_array().unwrap().is_empty(), "by-probe file must not be empty");
1480
1481        // The type-mismatch probe must surface as a violation naming the field.
1482        let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1483            .unwrap();
1484        assert!(
1485            flat.contains("analyticsRegion"),
1486            "the number-where-string probe must be reported: {flat}"
1487        );
1488    }
1489
1490    /// Round 53 (#79) — Srikanth on 0.3.199: body violations now populate,
1491    /// but the logs contain ONLY `request-body:*` rows. His console reported
1492    /// 7602 missed `owasp` and 3248 missed `parameters` negatives, yet not a
1493    /// single `owasp:*` row or query/path violation appeared.
1494    ///
1495    /// Two distinct causes, both reproduced here against the real wire shape:
1496    ///
1497    /// 1. Every owasp probe injects into `$.xgafv`, which reaches the wire
1498    ///    percent-encoded as `%24.xgafv`. The validator built `sent_query`
1499    ///    from the RAW query string, then looked the parameter up by the
1500    ///    spec's DECODED name (`$.xgafv`), so the lookup missed and all 7602
1501    ///    probes were silently skipped. (Round 45's test used a plain `alt`
1502    ///    param, which needs no encoding, so this stayed latent.)
1503    ///
1504    /// 2. `parameters:missing-query` DROPS a required query param. The loop
1505    ///    only inspected params that were actually sent, so a missing
1506    ///    required param could never be reported.
1507    #[tokio::test]
1508    async fn emitted_requests_flag_encoded_query_and_missing_required() {
1509        let dir = tempfile::tempdir().expect("tempdir");
1510
1511        // Spec mirrors the Apigee shape: a param whose name needs percent
1512        // encoding (`$.xgafv`, enum 1/2), a plain enum param, and a REQUIRED
1513        // param that the missing-query probe drops.
1514        let spec_json = serde_json::json!({
1515            "openapi": "3.0.0",
1516            "info": { "title": "apigee-min", "version": "1.0.0" },
1517            "paths": {
1518                "/v1/organizations": {
1519                    "post": {
1520                        "parameters": [
1521                            { "name": "$.xgafv", "in": "query",
1522                              "schema": { "type": "string", "enum": ["1", "2"] } },
1523                            { "name": "alt", "in": "query",
1524                              "schema": { "type": "string", "enum": ["json", "media"] } },
1525                            { "name": "parent", "in": "query", "required": true,
1526                              "schema": { "type": "string" } }
1527                        ],
1528                        "responses": { "200": { "description": "ok" } }
1529                    }
1530                }
1531            }
1532        });
1533        let spec_path = dir.path().join("apigee-min.json");
1534        std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1535
1536        let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1537        let mut f = std::fs::File::create(&jsonl_path).unwrap();
1538        let base = "https://172.22.232.2:443/v1/organizations";
1539        for line in [
1540            // Valid baseline: nothing should be reported.
1541            serde_json::json!({
1542                "label": "positive", "method": "POST",
1543                "url": format!("{base}?%24.xgafv=1&alt=json&parent=test-value"),
1544                "request_body": ""
1545            }),
1546            // owasp:sqli injects `' OR '1'='1` into the ENCODED `%24.xgafv`.
1547            serde_json::json!({
1548                "label": "owasp:sqli", "method": "POST",
1549                "url": format!("{base}?%24.xgafv=%27%20OR%20%271%27%3D%271&alt=json&parent=test-value"),
1550                "request_body": ""
1551            }),
1552            // parameters:missing-query drops the required `parent`.
1553            serde_json::json!({
1554                "label": "parameters:missing-query", "method": "POST",
1555                "url": format!("{base}?%24.xgafv=1&alt=json"),
1556                "request_body": ""
1557            }),
1558        ] {
1559            writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1560        }
1561        drop(f);
1562
1563        let n = validate_emitted_requests_with_base_path(
1564            std::slice::from_ref(&spec_path),
1565            dir.path(),
1566            None,
1567        )
1568        .await
1569        .expect("validation runs");
1570        assert!(n >= 2, "expected owasp + missing-required to be flagged, got {n}");
1571
1572        let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1573            .unwrap();
1574        let flat: serde_json::Value = serde_json::from_str(&flat).unwrap();
1575        let rows = flat.as_array().unwrap();
1576
1577        // The owasp probe must surface as a query violation on the DECODED
1578        // param name, with the DECODED value in the message (not `%27%20OR...`).
1579        let owasp = rows
1580            .iter()
1581            .find(|r| r["check_name"] == "owasp:sqli")
1582            .expect("owasp:sqli must produce a violation");
1583        assert_eq!(owasp["violation_type"], "query_value_mismatch");
1584        let msg = owasp["message"].as_str().unwrap();
1585        assert!(msg.contains("$.xgafv"), "decoded param name expected: {msg}");
1586        assert!(msg.contains("' OR '1'='1"), "decoded value expected: {msg}");
1587
1588        // The missing required param must be reported.
1589        let missing = rows
1590            .iter()
1591            .find(|r| r["check_name"] == "parameters:missing-query")
1592            .expect("missing-query must produce a violation");
1593        assert_eq!(missing["violation_type"], "query_missing_required");
1594        assert!(missing["message"].as_str().unwrap().contains("parent"));
1595
1596        // The positive probe must stay clean (no false positives).
1597        assert!(
1598            !rows.iter().any(|r| r["check_name"] == "positive"),
1599            "positive probe must not be flagged: {rows:?}"
1600        );
1601    }
1602}