1use 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#[derive(Debug, Serialize)]
18pub struct RequestViolation {
19 pub check_name: String,
21 pub method: String,
23 pub path: String,
25 pub violation_type: String,
27 pub message: String,
29}
30
31pub 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 let spec_ops = build_spec_operation_map(spec);
44
45 for check in &config.custom_checks {
46 let check_path = check.path.split('?').next().unwrap_or(&check.path);
48
49 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 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 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 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
132type SpecOperationMap = HashMap<String, Vec<String>>; fn 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
167fn find_matching_spec_path(
170 check_path: &str,
171 spec_ops: &SpecOperationMap,
172 base_path: Option<&str>,
173) -> Option<String> {
174 if spec_ops.contains_key(check_path) {
176 return Some(check_path.to_string());
177 }
178
179 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 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
204fn 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 _ => {
238 if template_seg == concrete_seg {
239 Some(None)
240 } else {
241 None
242 }
243 }
244 }
245}
246
247fn collapse_slashes(path: &str) -> String {
251 let mut out = String::with_capacity(path.len());
252 let mut prev_slash = false;
253 for ch in path.chars() {
254 if ch == '/' {
255 if !prev_slash {
256 out.push(ch);
257 }
258 prev_slash = true;
259 } else {
260 out.push(ch);
261 prev_slash = false;
262 }
263 }
264 out
265}
266
267fn path_matches_template(concrete: &str, template: &str) -> bool {
269 let concrete_parts: Vec<&str> = concrete.split('/').collect();
270 let template_parts: Vec<&str> = template.split('/').collect();
271
272 if concrete_parts.len() != template_parts.len() {
273 return false;
274 }
275
276 concrete_parts
277 .iter()
278 .zip(template_parts.iter())
279 .all(|(c, t)| match_path_segment(t, c).is_some())
280}
281
282#[allow(clippy::too_many_arguments)]
284fn validate_request_body(
285 check_name: &str,
286 method: &str,
287 path: &str,
288 body: Option<&str>,
289 operation: &openapiv3::Operation,
290 spec: &OpenAPI,
291 violations: &mut Vec<RequestViolation>,
292) {
293 let request_body_ref = match &operation.request_body {
294 Some(rb) => rb,
295 None => {
296 return;
298 }
299 };
300
301 let request_body = match request_body_ref {
303 ReferenceOr::Item(rb) => rb,
304 ReferenceOr::Reference { reference } => {
305 let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
306 match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
307 Some(ReferenceOr::Item(rb)) => rb,
308 _ => return,
309 }
310 }
311 };
312
313 if request_body.required && body.is_none() {
315 violations.push(RequestViolation {
316 check_name: check_name.to_string(),
317 method: method.to_string(),
318 path: path.to_string(),
319 violation_type: "missing_required_body".to_string(),
320 message: "Spec requires a request body but none is provided in the check".to_string(),
321 });
322 return;
323 }
324
325 if let Some(body_str) = body {
327 let json_media = request_body.content.get("application/json").or_else(|| {
329 request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v)
330 });
331
332 if let Some(media) = json_media {
333 if let Some(schema_ref) = &media.schema {
334 let root_schema = match schema_ref {
349 ReferenceOr::Item(s) => s.clone(),
350 ReferenceOr::Reference { reference } => {
351 let name =
352 reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
353 match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
354 Some(ReferenceOr::Item(s)) => s.clone(),
355 _ => return,
356 }
357 }
358 };
359
360 match serde_json::from_str::<serde_json::Value>(body_str) {
362 Ok(body_value) => {
363 match mockforge_openapi::schema_ref_resolver::build_validator(
364 &root_schema,
365 spec,
366 ) {
367 Ok(validator) => {
368 let errors: Vec<_> = validator.iter_errors(&body_value).collect();
369 for err in errors.iter().take(5) {
370 violations.push(RequestViolation {
371 check_name: check_name.to_string(),
372 method: method.to_string(),
373 path: path.to_string(),
374 violation_type: "body_schema_violation".to_string(),
375 message: format!(
376 "Request body schema violation at {}: {}",
377 err.instance_path, err
378 ),
379 });
380 }
381 }
382 Err(_) => {
383 }
385 }
386 }
387 Err(e) => {
388 violations.push(RequestViolation {
389 check_name: check_name.to_string(),
390 method: method.to_string(),
391 path: path.to_string(),
392 violation_type: "body_not_json".to_string(),
393 message: format!("Request body is not valid JSON: {}", e),
394 });
395 }
396 }
397 }
398 }
399 }
400}
401
402#[allow(clippy::too_many_arguments)]
404fn validate_parameters(
405 check_name: &str,
406 method: &str,
407 path: &str,
408 check_path_no_query: &str,
409 check_headers: &HashMap<String, String>,
410 operation: &openapiv3::Operation,
411 path_item: &openapiv3::PathItem,
412 spec: &OpenAPI,
413 violations: &mut Vec<RequestViolation>,
414) {
415 let mut all_params = Vec::new();
417 for p in &path_item.parameters {
418 if let Some(param) = resolve_parameter(p, spec) {
419 all_params.push(param);
420 }
421 }
422 for p in &operation.parameters {
423 if let Some(param) = resolve_parameter(p, spec) {
424 all_params.push(param);
425 }
426 }
427
428 for param in &all_params {
429 let param_data = match param {
430 openapiv3::Parameter::Query { parameter_data, .. } => {
431 if !parameter_data.required {
432 continue;
433 }
434 let has_param = check_path_no_query != path
436 && path.contains(&format!("{}=", parameter_data.name));
437 if !has_param {
438 violations.push(RequestViolation {
439 check_name: check_name.to_string(),
440 method: method.to_string(),
441 path: path.to_string(),
442 violation_type: "missing_required_query_param".to_string(),
443 message: format!(
444 "Required query parameter '{}' is missing",
445 parameter_data.name
446 ),
447 });
448 }
449 continue;
450 }
451 openapiv3::Parameter::Header { parameter_data, .. } => parameter_data,
452 openapiv3::Parameter::Path { parameter_data, .. } => {
453 let _ = parameter_data;
456 continue;
457 }
458 openapiv3::Parameter::Cookie { .. } => continue,
459 };
460
461 if param_data.required {
462 let has_header = check_headers.keys().any(|k| k.eq_ignore_ascii_case(¶m_data.name));
463 if !has_header {
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_header".to_string(),
469 message: format!("Required header parameter '{}' is missing", param_data.name),
470 });
471 }
472 }
473 }
474}
475
476fn resolve_parameter<'a>(
478 param_ref: &'a ReferenceOr<openapiv3::Parameter>,
479 spec: &'a OpenAPI,
480) -> Option<&'a openapiv3::Parameter> {
481 match param_ref {
482 ReferenceOr::Item(p) => Some(p),
483 ReferenceOr::Reference { reference } => {
484 let name = reference.strip_prefix("#/components/parameters/")?;
485 match spec.components.as_ref()?.parameters.get(name)? {
486 ReferenceOr::Item(p) => Some(p),
487 _ => None,
488 }
489 }
490 }
491}
492
493fn pct_decode(s: &str) -> String {
505 urlencoding::decode(s).map(|c| c.into_owned()).unwrap_or_else(|_| s.to_string())
506}
507
508fn resolve_param_schema<'a>(
512 schema_ref: &'a ReferenceOr<openapiv3::Schema>,
513 spec: &'a OpenAPI,
514) -> Option<&'a openapiv3::Schema> {
515 match schema_ref {
516 ReferenceOr::Item(s) => Some(s),
517 ReferenceOr::Reference { reference } => {
518 let name = reference.strip_prefix("#/components/schemas/")?;
519 match spec.components.as_ref()?.schemas.get(name)? {
520 ReferenceOr::Item(s) => Some(s),
521 _ => None,
522 }
523 }
524 }
525}
526
527#[allow(dead_code)]
531fn resolve_schema_to_json(
532 schema_ref: &ReferenceOr<openapiv3::Schema>,
533 spec: &OpenAPI,
534) -> Option<serde_json::Value> {
535 let schema = match schema_ref {
536 ReferenceOr::Item(s) => s,
537 ReferenceOr::Reference { reference } => {
538 let name = reference.strip_prefix("#/components/schemas/")?;
539 match spec.components.as_ref()?.schemas.get(name)? {
540 ReferenceOr::Item(s) => s,
541 _ => return None,
542 }
543 }
544 };
545 serde_json::to_value(schema).ok()
546}
547
548pub async fn run_request_validation(
551 spec_files: &[std::path::PathBuf],
552 custom_checks_file: Option<&Path>,
553 base_path: Option<&str>,
554 output_dir: &Path,
555) -> Result<usize> {
556 let custom_file = match custom_checks_file {
557 Some(f) => f,
558 None => return Ok(0),
559 };
560
561 if spec_files.is_empty() {
562 return Ok(0);
563 }
564
565 let parser = SpecParser::from_file(&spec_files[0]).await?;
566 let spec = parser.spec();
567
568 let violations = validate_custom_checks(spec, custom_file, base_path)?;
569
570 if !violations.is_empty() {
571 let path = output_dir.join("conformance-request-violations.json");
572 if let Ok(json) = serde_json::to_string_pretty(&violations) {
573 let _ = std::fs::write(&path, json);
574 tracing::info!(
575 "Found {} request validation violation(s), saved to {}",
576 violations.len(),
577 path.display()
578 );
579 }
580 }
581
582 Ok(violations.len())
583}
584
585pub async fn validate_emitted_requests(
607 spec_files: &[std::path::PathBuf],
608 output_dir: &Path,
609) -> Result<usize> {
610 validate_emitted_requests_with_base_path(spec_files, output_dir, None).await
611}
612
613pub async fn validate_emitted_requests_with_base_path(
632 spec_files: &[std::path::PathBuf],
633 output_dir: &Path,
634 base_path: Option<&str>,
635) -> Result<usize> {
636 use serde_json::Value;
637
638 if spec_files.is_empty() {
639 return Ok(0);
640 }
641 let requests_path = output_dir.join("conformance-requests.json");
642 let self_test_jsonl_path = output_dir.join("conformance-self-test-requests.jsonl");
643
644 let entries: Vec<Value> = if requests_path.exists() {
654 let bytes = match std::fs::read(&requests_path) {
655 Ok(b) => b,
656 Err(_) => return Ok(0),
657 };
658 match serde_json::from_slice(&bytes) {
659 Ok(v) => v,
660 Err(_) => return Ok(0),
661 }
662 } else if self_test_jsonl_path.exists() {
663 let bytes = match std::fs::read(&self_test_jsonl_path) {
664 Ok(b) => b,
665 Err(_) => return Ok(0),
666 };
667 let text = String::from_utf8_lossy(&bytes);
668 text.lines()
669 .filter(|l| !l.is_empty())
670 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
671 .map(|case| {
672 let label = case.get("label").and_then(|v| v.as_str()).unwrap_or("").to_string();
673 let method = case.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
674 let url = case.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
675 let body = case.get("request_body").cloned().unwrap_or(Value::Null);
676 let mut req = serde_json::Map::new();
677 req.insert("method".into(), Value::String(method));
678 req.insert("url".into(), Value::String(url));
679 req.insert(
680 "body".into(),
681 match body {
682 Value::String(s) => Value::String(s),
683 Value::Null => Value::String(String::new()),
684 other => other,
685 },
686 );
687 let mut out = serde_json::Map::new();
688 out.insert("check".into(), Value::String(label));
689 out.insert("request".into(), Value::Object(req));
690 Value::Object(out)
691 })
692 .collect()
693 } else {
694 return Ok(0);
695 };
696 if entries.is_empty() {
697 return Ok(0);
698 }
699
700 let parser = SpecParser::from_file(&spec_files[0]).await?;
701 let spec = parser.spec();
702 let spec_ops = build_spec_operation_map(spec);
703
704 let mut emitted_violations: Vec<RequestViolation> = Vec::new();
705
706 for entry in &entries {
707 let check = entry.get("check").and_then(|v| v.as_str()).unwrap_or("").to_string();
708 let req = match entry.get("request") {
709 Some(r) => r,
710 None => continue,
711 };
712 let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("").to_uppercase();
713 let url = req.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
714 if method.is_empty() || url.is_empty() {
715 continue;
716 }
717 let (path_only, query_string) = match url.find('?') {
718 Some(i) => (url[..i].to_string(), url[i + 1..].to_string()),
719 None => (url.clone(), String::new()),
720 };
721 let path_only = if let Some(stripped) = path_only.split_once("://") {
724 match stripped.1.find('/') {
725 Some(i) => stripped.1[i..].to_string(),
726 None => "/".to_string(),
727 }
728 } else {
729 path_only
730 };
731
732 let path_only = collapse_slashes(&path_only);
739
740 let lookup_path = if let Some(bp) = base_path {
744 let bp = bp.trim_end_matches('/');
745 if !bp.is_empty() && path_only.starts_with(bp) {
746 let stripped = &path_only[bp.len()..];
747 if stripped.is_empty() {
748 "/".to_string()
749 } else {
750 stripped.to_string()
751 }
752 } else {
753 path_only.clone()
754 }
755 } else {
756 path_only.clone()
757 };
758
759 let spec_path = match find_matching_spec_path(&lookup_path, &spec_ops, None) {
760 Some(p) => p,
761 None => continue,
762 };
763 let path_item = match spec.paths.paths.get(&spec_path) {
764 Some(ReferenceOr::Item(item)) => item,
765 _ => continue,
766 };
767 let operation = match method.as_str() {
768 "GET" => path_item.get.as_ref(),
769 "POST" => path_item.post.as_ref(),
770 "PUT" => path_item.put.as_ref(),
771 "DELETE" => path_item.delete.as_ref(),
772 "PATCH" => path_item.patch.as_ref(),
773 "HEAD" => path_item.head.as_ref(),
774 "OPTIONS" => path_item.options.as_ref(),
775 _ => None,
776 };
777 let Some(operation) = operation else { continue };
778
779 let sent_query: HashMap<String, String> = query_string
792 .split('&')
793 .filter_map(|kv| {
794 let mut it = kv.splitn(2, '=');
795 let k = pct_decode(it.next()?);
796 let v = pct_decode(it.next().unwrap_or(""));
797 if k.is_empty() {
798 None
799 } else {
800 Some((k, v))
801 }
802 })
803 .collect();
804
805 let path_params: HashMap<String, String> = {
811 let mut out = HashMap::new();
812 let concrete_parts: Vec<&str> = lookup_path.split('/').collect();
813 let template_parts: Vec<&str> = spec_path.split('/').collect();
814 if concrete_parts.len() == template_parts.len() {
815 for (c, t) in concrete_parts.iter().zip(template_parts.iter()) {
816 if let Some(Some((name, value))) = match_path_segment(t, c) {
820 out.insert(name.to_string(), pct_decode(&value));
821 }
822 }
823 }
824 out
825 };
826
827 let mut all_params: Vec<&openapiv3::Parameter> = Vec::new();
828 for p in &path_item.parameters {
829 if let Some(param) = resolve_parameter(p, spec) {
830 all_params.push(param);
831 }
832 }
833 for p in &operation.parameters {
834 if let Some(param) = resolve_parameter(p, spec) {
835 all_params.push(param);
836 }
837 }
838
839 for param in &all_params {
840 let (loc_str, name, schema_ref) = match param {
841 openapiv3::Parameter::Query { parameter_data, .. } => {
842 let openapiv3::ParameterSchemaOrContent::Schema(sref) = ¶meter_data.format
843 else {
844 continue;
845 };
846 let Some(v) = sent_query.get(¶meter_data.name) else {
847 if parameter_data.required {
853 emitted_violations.push(RequestViolation {
854 check_name: check.clone(),
855 method: method.clone(),
856 path: url.clone(),
857 violation_type: "query_missing_required".to_string(),
858 message: format!(
859 "query.{}: required parameter missing",
860 parameter_data.name
861 ),
862 });
863 }
864 continue;
865 };
866 ("query", ¶meter_data.name, (sref, v.clone()))
867 }
868 openapiv3::Parameter::Path { parameter_data, .. } => {
869 let openapiv3::ParameterSchemaOrContent::Schema(sref) = ¶meter_data.format
870 else {
871 continue;
872 };
873 let Some(v) = path_params.get(¶meter_data.name) else {
874 continue;
875 };
876 ("path", ¶meter_data.name, (sref, v.clone()))
877 }
878 _ => continue,
879 };
880 let (schema_ref, value) = schema_ref;
881 let Some(schema) = resolve_param_schema(schema_ref, spec) else {
885 continue;
886 };
887 if let Some(msg) = check_value_against_schema(&value, schema) {
888 emitted_violations.push(RequestViolation {
889 check_name: check.clone(),
890 method: method.clone(),
891 path: url.clone(),
892 violation_type: format!("{}_value_mismatch", loc_str),
893 message: format!("{}.{}: {}", loc_str, name, msg),
894 });
895 }
896 }
897
898 let body_str = req.get("body").and_then(|v| v.as_str()).unwrap_or("");
918 if !body_str.is_empty() {
919 if let Ok(body_json) = serde_json::from_str::<serde_json::Value>(body_str) {
920 validate_emitted_body(
921 &check,
922 &method,
923 &url,
924 &body_json,
925 operation,
926 spec,
927 &mut emitted_violations,
928 );
929 }
930 }
931 }
932
933 let dst = output_dir.join("conformance-request-violations.json");
935 let mut all: Vec<Value> = if dst.exists() {
936 match std::fs::read(&dst) {
937 Ok(b) => serde_json::from_slice(&b).unwrap_or_default(),
938 Err(_) => Vec::new(),
939 }
940 } else {
941 Vec::new()
942 };
943 for v in &emitted_violations {
944 if let Ok(val) = serde_json::to_value(v) {
945 all.push(val);
946 }
947 }
948 {
956 let mut seen: std::collections::HashSet<(String, String, String, String, String)> =
957 std::collections::HashSet::new();
958 all.retain(|v| {
959 let f = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
960 seen.insert((
961 f("check_name"),
962 f("method"),
963 f("path"),
964 f("violation_type"),
965 f("message"),
966 ))
967 });
968 }
969 if !all.is_empty() {
970 if let Ok(json) = serde_json::to_string_pretty(&all) {
971 let _ = std::fs::write(&dst, json);
972 tracing::info!(
973 "validate-requests: wrote {} entries to {} ({} from emitted requests)",
974 all.len(),
975 dst.display(),
976 emitted_violations.len()
977 );
978 }
979 }
980
981 let grouped_dst = output_dir.join("conformance-request-violations-by-request.json");
990 let grouped_value = group_violations_by_request(&all);
991 if let Ok(json) = serde_json::to_string_pretty(&grouped_value) {
992 let _ = std::fs::write(&grouped_dst, json);
993 }
994
995 let drill_dst = output_dir.join("conformance-request-violations-by-probe.json");
1006 let drill_value = group_violations_by_probe(&all);
1007 if let Ok(json) = serde_json::to_string_pretty(&drill_value) {
1008 let _ = std::fs::write(&drill_dst, json);
1009 }
1010 Ok(emitted_violations.len())
1011}
1012
1013fn group_violations_by_probe(flat: &[serde_json::Value]) -> serde_json::Value {
1020 use serde_json::{Map, Value};
1021
1022 let mut by_probe_order: Vec<(String, String, String)> = Vec::new();
1023 let mut by_probe: std::collections::HashMap<(String, String, String), Vec<(String, String)>> =
1024 std::collections::HashMap::new();
1025
1026 let mut seen_in_probe: std::collections::HashSet<(String, String, String, String)> =
1034 std::collections::HashSet::new();
1035 for v in flat {
1036 let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
1037 let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
1038 let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
1039 let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
1040 let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
1041 let key = (check.clone(), method.clone(), path.clone());
1042 if !by_probe.contains_key(&key) {
1043 by_probe_order.push(key.clone());
1044 }
1045 if seen_in_probe.insert((check, method, path, format!("{vt}\u{0}{msg}"))) {
1046 by_probe.entry(key).or_default().push((vt, msg));
1047 }
1048 }
1049
1050 by_probe_order.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)).then(a.0.cmp(&b.0)));
1052
1053 let mut rows: Vec<Value> = Vec::with_capacity(by_probe_order.len());
1054 for key in &by_probe_order {
1055 let (check, method, path) = key;
1056 let entries = by_probe.get(key).cloned().unwrap_or_default();
1057 let mut row = Map::new();
1058 row.insert("check_name".into(), Value::String(check.clone()));
1059 row.insert("method".into(), Value::String(method.clone()));
1060 row.insert("path".into(), Value::String(path.clone()));
1061 row.insert(
1062 "violation_count".into(),
1063 Value::Number(serde_json::Number::from(entries.len())),
1064 );
1065 for (i, (vt, msg)) in entries.iter().enumerate() {
1066 let mut entry = Map::new();
1067 entry.insert("violation_type".into(), Value::String(vt.clone()));
1068 entry.insert("message".into(), Value::String(msg.clone()));
1069 row.insert(format!("violation_{}", i + 1), Value::Object(entry));
1070 }
1071 rows.push(Value::Object(row));
1072 }
1073 Value::Array(rows)
1074}
1075
1076fn group_violations_by_request(flat: &[serde_json::Value]) -> serde_json::Value {
1097 use serde_json::{Map, Value};
1098
1099 let mut order: Vec<(String, String)> = Vec::new();
1100 let mut checks_by_key: std::collections::HashMap<(String, String), Vec<String>> =
1101 std::collections::HashMap::new();
1102 let mut viols_by_key: std::collections::HashMap<(String, String), Vec<(String, String)>> =
1103 std::collections::HashMap::new();
1104 let mut seen_check: std::collections::HashSet<(String, String, String)> =
1107 std::collections::HashSet::new();
1108 let mut seen_viol: std::collections::HashSet<(String, String, String)> =
1109 std::collections::HashSet::new();
1110
1111 for v in flat {
1112 let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
1113 let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
1114 let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
1115 let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
1116 let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
1117 let key = (method.clone(), path.clone());
1118 if !checks_by_key.contains_key(&key) && !viols_by_key.contains_key(&key) {
1119 order.push(key.clone());
1120 }
1121 if !check.is_empty() && seen_check.insert((method.clone(), path.clone(), check.clone())) {
1122 checks_by_key.entry(key.clone()).or_default().push(check);
1123 }
1124 if seen_viol.insert((method.clone(), path.clone(), format!("{vt}\u{0}{msg}"))) {
1125 viols_by_key.entry(key).or_default().push((vt, msg));
1126 }
1127 }
1128
1129 let mut rows: Vec<Value> = Vec::with_capacity(order.len());
1130 for key in &order {
1131 let (method, path) = key;
1132 let checks = checks_by_key.get(key).cloned().unwrap_or_default();
1133 let viols = viols_by_key.get(key).cloned().unwrap_or_default();
1134 let mut row = Map::new();
1135 row.insert(
1136 "checks".into(),
1137 Value::Array(checks.iter().map(|s| Value::String(s.clone())).collect()),
1138 );
1139 let dominant_prefix: &str = viols
1144 .first()
1145 .map(|(vt, _)| {
1146 if vt.starts_with("query_") {
1147 "param:query"
1148 } else if vt.starts_with("body_") {
1149 "body:"
1150 } else if vt.starts_with("path_") {
1151 "param:path"
1152 } else if vt.starts_with("header_") {
1153 "param:header"
1154 } else {
1155 ""
1156 }
1157 })
1158 .unwrap_or("");
1159 let best_check = if !dominant_prefix.is_empty() {
1160 checks
1161 .iter()
1162 .find(|c| c.starts_with(dominant_prefix))
1163 .cloned()
1164 .or_else(|| checks.first().cloned())
1165 .unwrap_or_default()
1166 } else {
1167 checks.first().cloned().unwrap_or_default()
1168 };
1169 row.insert("check_name".into(), Value::String(best_check));
1170 row.insert("method".into(), Value::String(method.clone()));
1171 row.insert("path".into(), Value::String(path.clone()));
1172 row.insert("violation_count".into(), Value::Number(serde_json::Number::from(viols.len())));
1173 for (i, (vt, msg)) in viols.iter().enumerate() {
1174 let mut entry = Map::new();
1175 entry.insert("violation_type".into(), Value::String(vt.clone()));
1176 entry.insert("message".into(), Value::String(msg.clone()));
1177 row.insert(format!("violation_{}", i + 1), Value::Object(entry));
1178 }
1179 rows.push(Value::Object(row));
1180 }
1181 Value::Array(rows)
1182}
1183
1184fn validate_emitted_body(
1197 check: &str,
1198 method: &str,
1199 url: &str,
1200 body: &serde_json::Value,
1201 operation: &openapiv3::Operation,
1202 spec: &OpenAPI,
1203 violations: &mut Vec<RequestViolation>,
1204) {
1205 let Some(request_body_ref) = &operation.request_body else {
1207 return;
1208 };
1209 let request_body = match request_body_ref {
1210 ReferenceOr::Item(rb) => rb,
1211 ReferenceOr::Reference { reference } => {
1212 let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
1213 match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
1214 Some(ReferenceOr::Item(rb)) => rb,
1215 _ => return,
1216 }
1217 }
1218 };
1219
1220 let json_media = request_body
1223 .content
1224 .get("application/json")
1225 .or_else(|| request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v));
1226 let Some(media) = json_media else {
1227 return;
1228 };
1229 let Some(schema_ref) = &media.schema else {
1230 return;
1231 };
1232
1233 let root_schema = match schema_ref {
1237 ReferenceOr::Item(s) => s.clone(),
1238 ReferenceOr::Reference { reference } => {
1239 let name = reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
1240 match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
1241 Some(ReferenceOr::Item(s)) => s.clone(),
1242 _ => return,
1243 }
1244 }
1245 };
1246
1247 let Ok(validator) = mockforge_openapi::schema_ref_resolver::build_validator(&root_schema, spec)
1248 else {
1249 return;
1251 };
1252 for err in validator.iter_errors(body).take(5) {
1253 let loc = err.instance_path.to_string();
1254 let loc = if loc.is_empty() { "$".to_string() } else { loc };
1255 violations.push(RequestViolation {
1256 check_name: check.to_string(),
1257 method: method.to_string(),
1258 path: url.to_string(),
1259 violation_type: "body_schema_violation".to_string(),
1260 message: format!("body{}: {}", loc, err),
1261 });
1262 }
1263}
1264
1265fn check_value_against_schema(value: &str, schema: &openapiv3::Schema) -> Option<String> {
1272 use openapiv3::{SchemaKind, Type};
1273
1274 let SchemaKind::Type(t) = &schema.schema_kind else {
1275 return None;
1276 };
1277 match t {
1278 Type::String(s) => {
1279 if !s.enumeration.is_empty() {
1280 let allowed: Vec<String> = s.enumeration.iter().filter_map(|e| e.clone()).collect();
1281 if !allowed.iter().any(|a| a == value) {
1282 let quoted: Vec<String> =
1283 allowed.iter().map(|a| format!("\"{}\"", a)).collect();
1284 return Some(format!(
1285 "value \"{}\" is not one of {}",
1286 value,
1287 quoted.join(" or ")
1288 ));
1289 }
1290 }
1291 let len = value.chars().count();
1296 if let Some(min) = s.min_length {
1297 if len < min {
1298 return Some(format!("value \"{value}\" is shorter than minLength {min}"));
1299 }
1300 }
1301 if let Some(max) = s.max_length {
1302 if len > max {
1303 return Some(format!("value \"{value}\" is longer than maxLength {max}"));
1304 }
1305 }
1306 if let Some(pat) = &s.pattern {
1307 if let Ok(re) = regex::Regex::new(pat) {
1310 if !re.is_match(value) {
1311 return Some(format!("value \"{value}\" does not match pattern /{pat}/"));
1312 }
1313 }
1314 }
1315 None
1316 }
1317 Type::Integer(_) => {
1318 if value.parse::<i64>().is_err() {
1319 Some(format!("value \"{}\" is not of type \"integer\"", value))
1320 } else {
1321 None
1322 }
1323 }
1324 Type::Number(_) => {
1325 if value.parse::<f64>().is_err() {
1326 Some(format!("value \"{}\" is not of type \"number\"", value))
1327 } else {
1328 None
1329 }
1330 }
1331 Type::Boolean(_) => match value {
1332 "true" | "false" => None,
1333 _ => Some(format!("value \"{}\" is not of type \"boolean\"", value)),
1334 },
1335 _ => None,
1336 }
1337}
1338
1339#[cfg(test)]
1340mod grouping_tests {
1341 use super::{group_violations_by_probe, group_violations_by_request};
1342 use serde_json::json;
1343
1344 fn viol(check: &str, method: &str, path: &str, vt: &str, msg: &str) -> serde_json::Value {
1346 json!({
1347 "check_name": check,
1348 "method": method,
1349 "path": path,
1350 "violation_type": vt,
1351 "message": msg,
1352 })
1353 }
1354
1355 #[test]
1362 fn by_request_unions_all_checks_for_a_url() {
1363 let path = "https://host/v1/organizations?alt=test-value&prettyPrint=test-value";
1364 let flat = vec![
1365 viol(
1366 "request-body:type-mismatch:billingType",
1367 "POST",
1368 path,
1369 "body_type_mismatch",
1370 "body.billingType: expected string",
1371 ),
1372 viol(
1373 "owasp:ldap-injection",
1374 "POST",
1375 path,
1376 "query_value_mismatch",
1377 "query.alt: value \"test-value\" is not one of \"json\" or \"media\"",
1378 ),
1379 viol(
1380 "owasp:ldap-injection",
1381 "POST",
1382 path,
1383 "query_value_mismatch",
1384 "query.prettyPrint: value \"test-value\" is not of type \"boolean\"",
1385 ),
1386 ];
1387
1388 let out = group_violations_by_request(&flat);
1389 let rows = out.as_array().expect("array");
1390 assert_eq!(rows.len(), 1, "expected a single by-request row per URL");
1392 let row = &rows[0];
1393 assert_eq!(row["violation_count"], 3);
1394 let checks: Vec<&str> =
1395 row["checks"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect();
1396 assert!(checks.contains(&"owasp:ldap-injection"), "owasp check must appear: {checks:?}");
1397 assert!(
1398 checks.iter().any(|c| c.starts_with("request-body:")),
1399 "body check must appear: {checks:?}"
1400 );
1401 }
1402
1403 #[test]
1407 fn by_probe_dedups_repeated_iterations() {
1408 let path = "https://host/v1/organizations?alt=test-value";
1409 let mut flat = Vec::new();
1410 for _ in 0..22 {
1411 flat.push(viol(
1412 "owasp:ldap-injection",
1413 "POST",
1414 path,
1415 "query_value_mismatch",
1416 "query.alt: value \"test-value\" is not one of \"json\" or \"media\"",
1417 ));
1418 }
1419
1420 let out = group_violations_by_probe(&flat);
1421 let rows = out.as_array().expect("array");
1422 assert_eq!(rows.len(), 1, "one probe row");
1423 assert_eq!(rows[0]["violation_count"], 1, "22 identical iterations collapse to 1");
1424 assert!(rows[0].get("violation_1").is_some());
1425 assert!(rows[0].get("violation_2").is_none(), "no duplicate violation_2");
1426 }
1427
1428 #[test]
1431 fn by_request_dedups_repeated_iterations() {
1432 let path = "https://host/v1/widgets";
1433 let mut flat = Vec::new();
1434 for _ in 0..22 {
1435 flat.push(viol(
1436 "request-body:type-mismatch:name",
1437 "POST",
1438 path,
1439 "body_type_mismatch",
1440 "body.name: expected string",
1441 ));
1442 }
1443 let out = group_violations_by_request(&flat);
1444 let rows = out.as_array().unwrap();
1445 assert_eq!(rows.len(), 1);
1446 assert_eq!(rows[0]["violation_count"], 1, "duplicate iterations collapse");
1447 let checks = rows[0]["checks"].as_array().unwrap();
1448 assert_eq!(checks.len(), 1, "the same check listed once");
1449 }
1450
1451 #[test]
1453 fn by_request_keeps_distinct_urls_separate() {
1454 let flat = vec![
1455 viol("c1", "POST", "https://host/a", "body_type_mismatch", "a"),
1456 viol("c2", "GET", "https://host/b", "query_value_mismatch", "b"),
1457 ];
1458 let out = group_violations_by_request(&flat);
1459 assert_eq!(out.as_array().unwrap().len(), 2);
1460 }
1461}
1462
1463#[cfg(test)]
1464mod emitted_body_tests {
1465 use super::validate_emitted_requests_with_base_path;
1466 use std::io::Write;
1467
1468 #[tokio::test]
1486 async fn emitted_requests_validate_ref_bodied_negatives() {
1487 let dir = tempfile::tempdir().expect("tempdir");
1488
1489 let spec_json = serde_json::json!({
1493 "openapi": "3.0.0",
1494 "info": { "title": "apigee-min", "version": "1.0.0" },
1495 "paths": {
1496 "/v1/organizations": {
1497 "post": {
1498 "requestBody": {
1499 "content": {
1500 "application/json": {
1501 "schema": { "$ref": "#/components/schemas/Organization" }
1502 }
1503 }
1504 },
1505 "responses": { "200": { "description": "ok" } }
1506 }
1507 }
1508 },
1509 "components": {
1510 "schemas": {
1511 "Organization": {
1512 "type": "object",
1513 "properties": {
1514 "analyticsRegion": { "type": "string" },
1515 "displayName": { "type": "string" }
1516 }
1517 }
1518 }
1519 }
1520 });
1521 let spec_path = dir.path().join("apigee-min.json");
1522 std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1523
1524 let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1529 let mut f = std::fs::File::create(&jsonl_path).unwrap();
1530 let base = "https://172.22.232.2:443/v1/organizations?alt=json";
1531 for line in [
1532 serde_json::json!({
1533 "label": "positive", "method": "POST", "url": base, "request_body": "{}"
1534 }),
1535 serde_json::json!({
1536 "label": "request-body:type-mismatch:analyticsRegion",
1537 "method": "POST", "url": base,
1538 "request_body": "{\"analyticsRegion\":12345}"
1539 }),
1540 serde_json::json!({
1541 "label": "request-body:wrong-type",
1542 "method": "POST", "url": base, "request_body": "[]"
1543 }),
1544 ] {
1545 writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1546 }
1547 drop(f);
1548
1549 let n = validate_emitted_requests_with_base_path(
1550 std::slice::from_ref(&spec_path),
1551 dir.path(),
1552 None,
1553 )
1554 .await
1555 .expect("validation runs");
1556
1557 assert!(n >= 2, "expected the two request-body negatives to be flagged, got {n}");
1558
1559 let by_request = std::fs::read_to_string(
1561 dir.path().join("conformance-request-violations-by-request.json"),
1562 )
1563 .unwrap();
1564 let by_request: serde_json::Value = serde_json::from_str(&by_request).unwrap();
1565 assert!(
1566 !by_request.as_array().unwrap().is_empty(),
1567 "by-request file must not be empty for a spec with $ref request bodies"
1568 );
1569
1570 let by_probe = std::fs::read_to_string(
1571 dir.path().join("conformance-request-violations-by-probe.json"),
1572 )
1573 .unwrap();
1574 let by_probe: serde_json::Value = serde_json::from_str(&by_probe).unwrap();
1575 assert!(!by_probe.as_array().unwrap().is_empty(), "by-probe file must not be empty");
1576
1577 let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1579 .unwrap();
1580 assert!(
1581 flat.contains("analyticsRegion"),
1582 "the number-where-string probe must be reported: {flat}"
1583 );
1584 }
1585
1586 #[tokio::test]
1604 async fn emitted_requests_flag_encoded_query_and_missing_required() {
1605 let dir = tempfile::tempdir().expect("tempdir");
1606
1607 let spec_json = serde_json::json!({
1611 "openapi": "3.0.0",
1612 "info": { "title": "apigee-min", "version": "1.0.0" },
1613 "paths": {
1614 "/v1/organizations": {
1615 "post": {
1616 "parameters": [
1617 { "name": "$.xgafv", "in": "query",
1618 "schema": { "type": "string", "enum": ["1", "2"] } },
1619 { "name": "alt", "in": "query",
1620 "schema": { "type": "string", "enum": ["json", "media"] } },
1621 { "name": "parent", "in": "query", "required": true,
1622 "schema": { "type": "string" } }
1623 ],
1624 "responses": { "200": { "description": "ok" } }
1625 }
1626 }
1627 }
1628 });
1629 let spec_path = dir.path().join("apigee-min.json");
1630 std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1631
1632 let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1633 let mut f = std::fs::File::create(&jsonl_path).unwrap();
1634 let base = "https://172.22.232.2:443/v1/organizations";
1635 for line in [
1636 serde_json::json!({
1638 "label": "positive", "method": "POST",
1639 "url": format!("{base}?%24.xgafv=1&alt=json&parent=test-value"),
1640 "request_body": ""
1641 }),
1642 serde_json::json!({
1644 "label": "owasp:sqli", "method": "POST",
1645 "url": format!("{base}?%24.xgafv=%27%20OR%20%271%27%3D%271&alt=json&parent=test-value"),
1646 "request_body": ""
1647 }),
1648 serde_json::json!({
1650 "label": "parameters:missing-query", "method": "POST",
1651 "url": format!("{base}?%24.xgafv=1&alt=json"),
1652 "request_body": ""
1653 }),
1654 ] {
1655 writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1656 }
1657 drop(f);
1658
1659 let n = validate_emitted_requests_with_base_path(
1660 std::slice::from_ref(&spec_path),
1661 dir.path(),
1662 None,
1663 )
1664 .await
1665 .expect("validation runs");
1666 assert!(n >= 2, "expected owasp + missing-required to be flagged, got {n}");
1667
1668 let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1669 .unwrap();
1670 let flat: serde_json::Value = serde_json::from_str(&flat).unwrap();
1671 let rows = flat.as_array().unwrap();
1672
1673 let owasp = rows
1676 .iter()
1677 .find(|r| r["check_name"] == "owasp:sqli")
1678 .expect("owasp:sqli must produce a violation");
1679 assert_eq!(owasp["violation_type"], "query_value_mismatch");
1680 let msg = owasp["message"].as_str().unwrap();
1681 assert!(msg.contains("$.xgafv"), "decoded param name expected: {msg}");
1682 assert!(msg.contains("' OR '1'='1"), "decoded value expected: {msg}");
1683
1684 let missing = rows
1686 .iter()
1687 .find(|r| r["check_name"] == "parameters:missing-query")
1688 .expect("missing-query must produce a violation");
1689 assert_eq!(missing["violation_type"], "query_missing_required");
1690 assert!(missing["message"].as_str().unwrap().contains("parent"));
1691
1692 assert!(
1694 !rows.iter().any(|r| r["check_name"] == "positive"),
1695 "positive probe must not be flagged: {rows:?}"
1696 );
1697 }
1698
1699 #[test]
1702 fn collapse_slashes_normalises_double_slashes() {
1703 use super::collapse_slashes;
1704 assert_eq!(collapse_slashes("//v1/organizations"), "/v1/organizations");
1705 assert_eq!(collapse_slashes("/v1//x///y"), "/v1/x/y");
1706 assert_eq!(collapse_slashes("/v1/organizations"), "/v1/organizations");
1707 assert_eq!(collapse_slashes("/"), "/");
1708 }
1709
1710 #[tokio::test]
1716 async fn double_slashed_url_still_validates() {
1717 let dir = tempfile::tempdir().expect("tempdir");
1718 let spec_json = serde_json::json!({
1719 "openapi": "3.0.0",
1720 "info": { "title": "apigee-min", "version": "1.0.0" },
1721 "paths": {
1722 "/v1/organizations": {
1723 "post": {
1724 "requestBody": {
1725 "content": {
1726 "application/json": {
1727 "schema": {
1728 "type": "object",
1729 "properties": { "analyticsRegion": { "type": "string" } }
1730 }
1731 }
1732 }
1733 },
1734 "responses": { "200": { "description": "ok" } }
1735 }
1736 }
1737 }
1738 });
1739 let spec_path = dir.path().join("apigee-min.json");
1740 std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1741
1742 let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1743 std::fs::write(
1744 &jsonl_path,
1745 serde_json::to_string(&serde_json::json!({
1746 "label": "request-body:type-mismatch:analyticsRegion",
1747 "method": "POST",
1748 "url": "https://172.22.232.2:443//v1/organizations",
1750 "request_body": "{\"analyticsRegion\":12345}"
1751 }))
1752 .unwrap()
1753 + "\n",
1754 )
1755 .unwrap();
1756
1757 let n = validate_emitted_requests_with_base_path(
1758 std::slice::from_ref(&spec_path),
1759 dir.path(),
1760 Some("/"),
1762 )
1763 .await
1764 .expect("validation runs");
1765 assert!(n >= 1, "double-slashed URL must still match and flag the body, got {n}");
1766 let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1767 .unwrap();
1768 assert!(flat.contains("analyticsRegion"), "body probe must be reported: {flat}");
1769 }
1770
1771 #[test]
1774 fn segment_matcher_handles_custom_verbs() {
1775 use super::match_path_segment;
1776 assert_eq!(match_path_segment("{name}", "abc"), Some(Some(("name", "abc".to_string()))));
1778 assert_eq!(
1780 match_path_segment("{instance}:reportStatus", "self-test-invalid-id:reportStatus"),
1781 Some(Some(("instance", "self-test-invalid-id".to_string())))
1782 );
1783 assert_eq!(match_path_segment("{instance}:reportStatus", "x:other"), None);
1785 assert_eq!(match_path_segment("v1", "v1"), Some(None));
1787 assert_eq!(match_path_segment("v1", "v2"), None);
1788 }
1789
1790 #[tokio::test]
1797 async fn emitted_requests_flag_bad_custom_verb_path_param() {
1798 let dir = tempfile::tempdir().expect("tempdir");
1799 let spec_json = serde_json::json!({
1800 "openapi": "3.0.0",
1801 "info": { "title": "apigee-min", "version": "1.0.0" },
1802 "paths": {
1803 "/v1/{instance}:reportStatus": {
1804 "post": {
1805 "parameters": [
1806 { "name": "instance", "in": "path", "required": true,
1807 "schema": { "type": "string", "maxLength": 8 } }
1808 ],
1809 "responses": { "200": { "description": "ok" } }
1810 }
1811 }
1812 }
1813 });
1814 let spec_path = dir.path().join("apigee-min.json");
1815 std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1816
1817 let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1818 std::fs::write(
1819 &jsonl_path,
1820 serde_json::to_string(&serde_json::json!({
1821 "label": "parameters:bad-path-param",
1822 "method": "POST",
1823 "url": "https://172.22.232.2:443/v1/self-test-invalid-id:reportStatus",
1825 "request_body": ""
1826 }))
1827 .unwrap()
1828 + "\n",
1829 )
1830 .unwrap();
1831
1832 let n = validate_emitted_requests_with_base_path(
1833 std::slice::from_ref(&spec_path),
1834 dir.path(),
1835 None,
1836 )
1837 .await
1838 .expect("validation runs");
1839 assert!(n >= 1, "the bad custom-verb path param must be flagged, got {n}");
1840
1841 let flat: serde_json::Value = serde_json::from_str(
1842 &std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1843 .unwrap(),
1844 )
1845 .unwrap();
1846 let row = flat
1847 .as_array()
1848 .unwrap()
1849 .iter()
1850 .find(|r| r["check_name"] == "parameters:bad-path-param")
1851 .expect("bad-path-param violation present");
1852 assert_eq!(row["violation_type"], "path_value_mismatch");
1853 let msg = row["message"].as_str().unwrap();
1854 assert!(msg.contains("instance") && msg.contains("maxLength"), "unexpected: {msg}");
1855 }
1856}