1use super::custom::{CustomCheck, CustomConformanceConfig};
8use super::generator::ConformanceConfig;
9use super::report::{ConformanceReport, FailureDetail, FailureRequest, FailureResponse};
10use super::spec::ConformanceFeature;
11use super::spec_driven::{AnnotatedOperation, ApiKeyLocation, SecuritySchemeInfo};
12use crate::error::{BenchError, Result};
13use reqwest::{Client, Method};
14use std::collections::{HashMap, HashSet};
15use std::time::Duration;
16use tokio::sync::mpsc;
17
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20pub struct SchemaViolation {
21 pub field_path: String,
23 pub violation_type: String,
25 pub expected: String,
27 pub actual: String,
29}
30
31#[derive(Debug, Clone)]
33pub struct ConformanceCheck {
34 pub name: String,
36 pub method: Method,
38 pub path: String,
40 pub headers: Vec<(String, String)>,
42 pub body: Option<CheckBody>,
44 pub validation: CheckValidation,
46}
47
48#[derive(Debug, Clone)]
50pub enum CheckBody {
51 Json(serde_json::Value),
53 FormUrlencoded(Vec<(String, String)>),
55 Raw {
57 content: String,
58 content_type: String,
59 },
60 Multipart { parts: Vec<MultipartPart> },
67}
68
69#[derive(Debug, Clone)]
71pub struct MultipartPart {
72 pub bytes: Vec<u8>,
74 pub content_type: String,
77 pub field_name: String,
79 pub filename: String,
81}
82
83#[derive(Debug, Clone, Default)]
88struct ChainMeta {
89 extract: super::custom::ExtractRules,
92 repeat: super::custom::Repeat,
95}
96
97#[derive(Debug, Default, Clone)]
102struct ChainContext {
103 vars: HashMap<String, String>,
104 cookies: HashMap<String, String>,
105}
106
107impl ChainContext {
108 fn substitute(&self, text: &str) -> String {
114 let mut out = String::with_capacity(text.len());
115 let mut rest = text;
116 while let Some(start) = rest.find("${") {
117 out.push_str(&rest[..start]);
118 let after = &rest[start + 2..];
119 if let Some(end) = after.find('}') {
120 let token = &after[..end];
121 let replaced = if let Some(name) = token.strip_prefix("var:") {
122 self.vars.get(name).cloned()
123 } else if let Some(name) = token.strip_prefix("cookie:") {
124 self.cookies.get(name).cloned()
125 } else {
126 token.strip_prefix("header:").and_then(|name| self.vars.get(name).cloned())
130 };
131 if let Some(value) = replaced {
132 out.push_str(&value);
133 } else {
134 out.push_str("${");
137 out.push_str(token);
138 out.push('}');
139 }
140 rest = &after[end + 1..];
141 } else {
142 out.push_str("${");
143 rest = after;
144 break;
145 }
146 }
147 out.push_str(rest);
148 out
149 }
150}
151
152#[derive(Debug, Clone)]
154pub enum CheckValidation {
155 StatusRange { min: u16, max_exclusive: u16 },
157 ExactStatus(u16),
159 SchemaValidation {
161 status_min: u16,
162 status_max: u16,
163 schema: serde_json::Value,
164 },
165 Custom {
167 expected_status: u16,
168 expected_headers: Vec<(String, String)>,
169 expected_body_fields: Vec<(String, String)>,
170 },
171}
172
173#[derive(Debug, Clone, serde::Serialize)]
175#[serde(tag = "type")]
176pub enum ConformanceProgress {
177 #[serde(rename = "started")]
179 Started { total_checks: usize },
180 #[serde(rename = "check_completed")]
182 CheckCompleted {
183 name: String,
184 passed: bool,
185 checks_done: usize,
186 },
187 #[serde(rename = "finished")]
189 Finished,
190 #[serde(rename = "error")]
192 Error { message: String },
193}
194
195#[derive(Debug)]
197struct CheckResult {
198 name: String,
199 passed: bool,
200 failure_detail: Option<FailureDetail>,
201 captured: Option<CapturedExchange>,
204}
205
206#[derive(Debug, serde::Serialize)]
208struct CapturedExchange {
209 method: String,
210 url: String,
211 request_headers: HashMap<String, String>,
212 request_body: String,
213 response_status: u16,
214 response_headers: HashMap<String, String>,
215 response_body: String,
216}
217
218pub struct NativeConformanceExecutor {
220 config: ConformanceConfig,
221 client: Client,
222 checks: Vec<ConformanceCheck>,
223 chain_meta: HashMap<usize, ChainMeta>,
228 chain_iterations: u32,
233 network_events: std::sync::Mutex<Vec<NetworkEvent>>,
240}
241
242#[derive(Debug, Clone, serde::Serialize)]
247pub struct NetworkEvent {
248 pub timestamp: chrono::DateTime<chrono::Utc>,
250 pub check: String,
252 pub method: String,
254 pub url: String,
256 pub kind: String,
259 pub message: String,
261}
262
263impl NativeConformanceExecutor {
264 pub fn new(config: ConformanceConfig) -> Result<Self> {
266 Self::with_ssrf_guard(config, crate::ssrf::process_guard())
267 }
268
269 pub fn with_ssrf_guard(
273 config: ConformanceConfig,
274 guard: Option<crate::ssrf::Policy>,
275 ) -> Result<Self> {
276 let mut builder = crate::ssrf::client_builder_for(guard)
277 .timeout(Duration::from_secs(30))
278 .connect_timeout(Duration::from_secs(10));
279
280 if config.skip_tls_verify {
281 builder = builder.danger_accept_invalid_certs(true);
282 }
283
284 let client = builder
285 .build()
286 .map_err(|e| BenchError::Other(format!("Failed to build HTTP client: {}", e)))?;
287
288 Ok(Self {
289 config,
290 client,
291 checks: Vec::new(),
292 chain_meta: HashMap::new(),
293 chain_iterations: 1,
294 network_events: std::sync::Mutex::new(Vec::new()),
295 })
296 }
297
298 fn record_network_event(&self, ev: NetworkEvent) {
303 if let Ok(mut guard) = self.network_events.lock() {
304 guard.push(ev);
305 }
306 }
307
308 #[must_use]
311 pub fn with_reference_checks(mut self) -> Self {
312 if self.config.should_include_category("Parameters") {
314 self.add_ref_get("param:path:string", "/conformance/params/hello");
315 self.add_ref_get("param:path:integer", "/conformance/params/42");
316 self.add_ref_get("param:query:string", "/conformance/params/query?name=test");
317 self.add_ref_get("param:query:integer", "/conformance/params/query?count=10");
318 self.add_ref_get("param:query:array", "/conformance/params/query?tags=a&tags=b");
319 self.checks.push(ConformanceCheck {
320 name: "param:header".to_string(),
321 method: Method::GET,
322 path: "/conformance/params/header".to_string(),
323 headers: self
324 .merge_headers(vec![("X-Custom-Param".to_string(), "test-value".to_string())]),
325 body: None,
326 validation: CheckValidation::StatusRange {
327 min: 200,
328 max_exclusive: 500,
329 },
330 });
331 self.checks.push(ConformanceCheck {
332 name: "param:cookie".to_string(),
333 method: Method::GET,
334 path: "/conformance/params/cookie".to_string(),
335 headers: self
336 .merge_headers(vec![("Cookie".to_string(), "session=abc123".to_string())]),
337 body: None,
338 validation: CheckValidation::StatusRange {
339 min: 200,
340 max_exclusive: 500,
341 },
342 });
343 }
344
345 if self.config.should_include_category("Request Bodies") {
347 self.checks.push(ConformanceCheck {
348 name: "body:json".to_string(),
349 method: Method::POST,
350 path: "/conformance/body/json".to_string(),
351 headers: self.merge_headers(vec![(
352 "Content-Type".to_string(),
353 "application/json".to_string(),
354 )]),
355 body: Some(CheckBody::Json(serde_json::json!({"name": "test", "value": 42}))),
356 validation: CheckValidation::StatusRange {
357 min: 200,
358 max_exclusive: 500,
359 },
360 });
361 self.checks.push(ConformanceCheck {
362 name: "body:form-urlencoded".to_string(),
363 method: Method::POST,
364 path: "/conformance/body/form".to_string(),
365 headers: self.custom_headers_only(),
366 body: Some(CheckBody::FormUrlencoded(vec![
367 ("field1".to_string(), "value1".to_string()),
368 ("field2".to_string(), "value2".to_string()),
369 ])),
370 validation: CheckValidation::StatusRange {
371 min: 200,
372 max_exclusive: 500,
373 },
374 });
375 self.checks.push(ConformanceCheck {
376 name: "body:multipart".to_string(),
377 method: Method::POST,
378 path: "/conformance/body/multipart".to_string(),
379 headers: self.custom_headers_only(),
380 body: Some(CheckBody::Raw {
381 content: "test content".to_string(),
382 content_type: "text/plain".to_string(),
383 }),
384 validation: CheckValidation::StatusRange {
385 min: 200,
386 max_exclusive: 500,
387 },
388 });
389 }
390
391 if self.config.should_include_category("Schema Types") {
393 let types = [
394 ("string", r#"{"value": "hello"}"#, "schema:string"),
395 ("integer", r#"{"value": 42}"#, "schema:integer"),
396 ("number", r#"{"value": 3.14}"#, "schema:number"),
397 ("boolean", r#"{"value": true}"#, "schema:boolean"),
398 ("array", r#"{"value": [1, 2, 3]}"#, "schema:array"),
399 ("object", r#"{"value": {"nested": "data"}}"#, "schema:object"),
400 ];
401 for (type_name, body_str, check_name) in types {
402 self.checks.push(ConformanceCheck {
403 name: check_name.to_string(),
404 method: Method::POST,
405 path: format!("/conformance/schema/{}", type_name),
406 headers: self.merge_headers(vec![(
407 "Content-Type".to_string(),
408 "application/json".to_string(),
409 )]),
410 body: Some(CheckBody::Json(
411 serde_json::from_str(body_str).expect("valid JSON"),
412 )),
413 validation: CheckValidation::StatusRange {
414 min: 200,
415 max_exclusive: 500,
416 },
417 });
418 }
419 }
420
421 if self.config.should_include_category("Composition") {
423 let compositions = [
424 ("oneOf", r#"{"type": "string", "value": "test"}"#, "composition:oneOf"),
425 ("anyOf", r#"{"value": "test"}"#, "composition:anyOf"),
426 ("allOf", r#"{"name": "test", "id": 1}"#, "composition:allOf"),
427 ];
428 for (kind, body_str, check_name) in compositions {
429 self.checks.push(ConformanceCheck {
430 name: check_name.to_string(),
431 method: Method::POST,
432 path: format!("/conformance/composition/{}", kind),
433 headers: self.merge_headers(vec![(
434 "Content-Type".to_string(),
435 "application/json".to_string(),
436 )]),
437 body: Some(CheckBody::Json(
438 serde_json::from_str(body_str).expect("valid JSON"),
439 )),
440 validation: CheckValidation::StatusRange {
441 min: 200,
442 max_exclusive: 500,
443 },
444 });
445 }
446 }
447
448 if self.config.should_include_category("String Formats") {
450 let formats = [
451 ("date", r#"{"value": "2024-01-15"}"#, "format:date"),
452 ("date-time", r#"{"value": "2024-01-15T10:30:00Z"}"#, "format:date-time"),
453 ("email", r#"{"value": "test@example.com"}"#, "format:email"),
454 ("uuid", r#"{"value": "550e8400-e29b-41d4-a716-446655440000"}"#, "format:uuid"),
455 ("uri", r#"{"value": "https://example.com/path"}"#, "format:uri"),
456 ("ipv4", r#"{"value": "192.168.1.1"}"#, "format:ipv4"),
457 ("ipv6", r#"{"value": "::1"}"#, "format:ipv6"),
458 ];
459 for (fmt, body_str, check_name) in formats {
460 self.checks.push(ConformanceCheck {
461 name: check_name.to_string(),
462 method: Method::POST,
463 path: format!("/conformance/formats/{}", fmt),
464 headers: self.merge_headers(vec![(
465 "Content-Type".to_string(),
466 "application/json".to_string(),
467 )]),
468 body: Some(CheckBody::Json(
469 serde_json::from_str(body_str).expect("valid JSON"),
470 )),
471 validation: CheckValidation::StatusRange {
472 min: 200,
473 max_exclusive: 500,
474 },
475 });
476 }
477 }
478
479 if self.config.should_include_category("Constraints") {
481 let constraints = [
482 ("required", r#"{"required_field": "present"}"#, "constraint:required"),
483 ("optional", r#"{}"#, "constraint:optional"),
484 ("minmax", r#"{"value": 50}"#, "constraint:minmax"),
485 ("pattern", r#"{"value": "ABC-123"}"#, "constraint:pattern"),
486 ("enum", r#"{"status": "active"}"#, "constraint:enum"),
487 ];
488 for (kind, body_str, check_name) in constraints {
489 self.checks.push(ConformanceCheck {
490 name: check_name.to_string(),
491 method: Method::POST,
492 path: format!("/conformance/constraints/{}", kind),
493 headers: self.merge_headers(vec![(
494 "Content-Type".to_string(),
495 "application/json".to_string(),
496 )]),
497 body: Some(CheckBody::Json(
498 serde_json::from_str(body_str).expect("valid JSON"),
499 )),
500 validation: CheckValidation::StatusRange {
501 min: 200,
502 max_exclusive: 500,
503 },
504 });
505 }
506 }
507
508 if self.config.should_include_category("Response Codes") {
510 for (code_str, check_name) in [
511 ("200", "response:200"),
512 ("201", "response:201"),
513 ("204", "response:204"),
514 ("400", "response:400"),
515 ("404", "response:404"),
516 ] {
517 let code: u16 = code_str.parse().unwrap();
518 self.checks.push(ConformanceCheck {
519 name: check_name.to_string(),
520 method: Method::GET,
521 path: format!("/conformance/responses/{}", code_str),
522 headers: self.custom_headers_only(),
523 body: None,
524 validation: CheckValidation::ExactStatus(code),
525 });
526 }
527 }
528
529 if self.config.should_include_category("HTTP Methods") {
531 self.add_ref_get("method:GET", "/conformance/methods");
532 for (method, check_name) in [
533 (Method::POST, "method:POST"),
534 (Method::PUT, "method:PUT"),
535 (Method::PATCH, "method:PATCH"),
536 ] {
537 self.checks.push(ConformanceCheck {
538 name: check_name.to_string(),
539 method,
540 path: "/conformance/methods".to_string(),
541 headers: self.merge_headers(vec![(
542 "Content-Type".to_string(),
543 "application/json".to_string(),
544 )]),
545 body: Some(CheckBody::Json(serde_json::json!({"action": "test"}))),
546 validation: CheckValidation::StatusRange {
547 min: 200,
548 max_exclusive: 500,
549 },
550 });
551 }
552 for (method, check_name) in [
553 (Method::DELETE, "method:DELETE"),
554 (Method::HEAD, "method:HEAD"),
555 (Method::OPTIONS, "method:OPTIONS"),
556 ] {
557 self.checks.push(ConformanceCheck {
558 name: check_name.to_string(),
559 method,
560 path: "/conformance/methods".to_string(),
561 headers: self.custom_headers_only(),
562 body: None,
563 validation: CheckValidation::StatusRange {
564 min: 200,
565 max_exclusive: 500,
566 },
567 });
568 }
569 }
570
571 if self.config.should_include_category("Content Types") {
573 self.checks.push(ConformanceCheck {
574 name: "content:negotiation".to_string(),
575 method: Method::GET,
576 path: "/conformance/content-types".to_string(),
577 headers: self
578 .merge_headers(vec![("Accept".to_string(), "application/json".to_string())]),
579 body: None,
580 validation: CheckValidation::StatusRange {
581 min: 200,
582 max_exclusive: 500,
583 },
584 });
585 }
586
587 if self.config.should_include_category("Security") {
589 self.checks.push(ConformanceCheck {
591 name: "security:bearer".to_string(),
592 method: Method::GET,
593 path: "/conformance/security/bearer".to_string(),
594 headers: self.merge_headers(vec![(
595 "Authorization".to_string(),
596 "Bearer test-token-123".to_string(),
597 )]),
598 body: None,
599 validation: CheckValidation::StatusRange {
600 min: 200,
601 max_exclusive: 500,
602 },
603 });
604
605 let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
607 self.checks.push(ConformanceCheck {
608 name: "security:apikey".to_string(),
609 method: Method::GET,
610 path: "/conformance/security/apikey".to_string(),
611 headers: self.merge_headers(vec![("X-API-Key".to_string(), api_key.to_string())]),
612 body: None,
613 validation: CheckValidation::StatusRange {
614 min: 200,
615 max_exclusive: 500,
616 },
617 });
618
619 let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
621 use base64::Engine;
622 let encoded = base64::engine::general_purpose::STANDARD.encode(basic_creds.as_bytes());
623 self.checks.push(ConformanceCheck {
624 name: "security:basic".to_string(),
625 method: Method::GET,
626 path: "/conformance/security/basic".to_string(),
627 headers: self.merge_headers(vec![(
628 "Authorization".to_string(),
629 format!("Basic {}", encoded),
630 )]),
631 body: None,
632 validation: CheckValidation::StatusRange {
633 min: 200,
634 max_exclusive: 500,
635 },
636 });
637 }
638
639 self
640 }
641
642 #[must_use]
644 pub fn with_spec_driven_checks(mut self, operations: &[AnnotatedOperation]) -> Self {
645 let mut feature_seen: HashSet<&'static str> = HashSet::new();
647
648 for op in operations {
649 for feature in &op.features {
650 let category = feature.category();
651 if !self.config.should_include_category(category) {
652 continue;
653 }
654
655 let check_name_base = feature.check_name();
656
657 if self.config.all_operations {
658 let check_name = format!("{}:{}", check_name_base, op.path);
660 let check = self.build_spec_check(&check_name, op, feature);
661 self.checks.push(check);
662 } else {
663 if feature_seen.insert(check_name_base) {
665 let check_name = format!("{}:{}", check_name_base, op.path);
666 let check = self.build_spec_check(&check_name, op, feature);
667 self.checks.push(check);
668 }
669 }
670 }
671 }
672
673 self
674 }
675
676 pub fn with_custom_checks(self) -> Result<Self> {
678 let path = match &self.config.custom_checks_file {
679 Some(p) => p.clone(),
680 None => return Ok(self),
681 };
682 let custom_config = CustomConformanceConfig::from_file(&path)?;
683 self.append_custom_checks(&custom_config)
684 }
685
686 pub fn with_custom_checks_from_config(
694 self,
695 custom_config: CustomConformanceConfig,
696 ) -> Result<Self> {
697 self.append_custom_checks(&custom_config)
698 }
699
700 fn append_custom_checks(mut self, custom_config: &CustomConformanceConfig) -> Result<Self> {
705 let filter_re = match &self.config.custom_filter {
706 Some(pattern) => Some(regex::Regex::new(pattern).map_err(|e| {
707 BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
708 })?),
709 None => None,
710 };
711
712 let mut included = 0usize;
713 let total = custom_config.custom_checks.len();
714 self.chain_iterations = custom_config.chain_iterations.max(1);
718 for check in &custom_config.custom_checks {
719 if let Some(ref re) = filter_re {
720 if !re.is_match(&check.name) && !re.is_match(&check.path) {
721 continue;
722 }
723 }
724 self.add_custom_check(check);
725 included += 1;
726 }
727
728 if filter_re.is_some() {
729 tracing::info!("Custom check filter: {}/{} checks matched pattern", included, total);
730 }
731
732 Ok(self)
733 }
734
735 pub fn check_count(&self) -> usize {
737 self.checks.len()
738 }
739
740 pub async fn execute(&self) -> Result<ConformanceReport> {
742 let chain_iters = self.chain_iterations.max(1);
743 let mut results = Vec::with_capacity(self.checks.len() * chain_iters as usize);
744 let delay = self.config.request_delay_ms;
745
746 for _iter in 0..chain_iters {
747 let mut ctx = ChainContext::default();
752 for (i, check) in self.checks.iter().enumerate() {
753 if delay > 0 && i > 0 {
754 tokio::time::sleep(Duration::from_millis(delay)).await;
755 }
756 if let Some(meta) = self.chain_meta.get(&i).cloned() {
757 results.extend(self.execute_chain_check(check, &meta, &mut ctx).await);
758 } else {
759 results.push(self.execute_check(check).await);
760 }
761 }
762 }
763
764 if self.config.export_requests {
766 if let Some(ref output_dir) = self.config.output_dir {
767 let request_log: Vec<_> = results
768 .iter()
769 .filter_map(|r| {
770 r.captured.as_ref().map(|c| {
771 serde_json::json!({
772 "check": r.name,
773 "passed": r.passed,
774 "request": {
775 "method": c.method,
776 "url": c.url,
777 "headers": c.request_headers,
778 "body": c.request_body,
779 },
780 "response": {
781 "status": c.response_status,
782 "headers": c.response_headers,
783 "body": c.response_body,
784 },
785 })
786 })
787 })
788 .collect();
789 let path = output_dir.join("conformance-requests.json");
790 if let Ok(json) = serde_json::to_string_pretty(&request_log) {
791 let _ = std::fs::write(&path, json);
792 tracing::info!(
793 "Exported {} request/response pairs to {}",
794 request_log.len(),
795 path.display()
796 );
797 }
798 }
799 }
800
801 if let Some(ref output_dir) = self.config.output_dir {
807 if let Ok(guard) = self.network_events.lock() {
808 if !guard.is_empty() {
809 let path = output_dir.join("conformance-network-events.json");
810 if let Ok(json) = serde_json::to_string_pretty(&*guard) {
811 let _ = std::fs::write(&path, json);
812 tracing::warn!(
813 "Recorded {} wire-level network event(s) to {} — see file for timestamps and classified reasons",
814 guard.len(),
815 path.display()
816 );
817 }
818 }
819 }
820 }
821
822 Ok(Self::aggregate(results))
823 }
824
825 pub async fn execute_with_progress(
827 &self,
828 tx: mpsc::Sender<ConformanceProgress>,
829 ) -> Result<ConformanceReport> {
830 let chain_iters = self.chain_iterations.max(1);
831 let total = self.checks.len() * chain_iters as usize;
832 let delay = self.config.request_delay_ms;
833 let _ = tx
834 .send(ConformanceProgress::Started {
835 total_checks: total,
836 })
837 .await;
838
839 let mut results = Vec::with_capacity(total);
840
841 for _iter in 0..chain_iters {
842 let mut ctx = ChainContext::default();
843 for (i, check) in self.checks.iter().enumerate() {
844 if delay > 0 && i > 0 {
845 tokio::time::sleep(Duration::from_millis(delay)).await;
846 }
847 let new_results = if let Some(meta) = self.chain_meta.get(&i).cloned() {
848 self.execute_chain_check(check, &meta, &mut ctx).await
849 } else {
850 vec![self.execute_check(check).await]
851 };
852 for result in new_results {
853 let passed = result.passed;
854 let name = result.name.clone();
855 results.push(result);
856 let _ = tx
857 .send(ConformanceProgress::CheckCompleted {
858 name,
859 passed,
860 checks_done: results.len(),
861 })
862 .await;
863 }
864 }
865 }
866
867 let _ = tx.send(ConformanceProgress::Finished).await;
868 Ok(Self::aggregate(results))
869 }
870
871 async fn execute_check(&self, check: &ConformanceCheck) -> CheckResult {
873 let base_url = self.config.effective_base_url();
874 let url = format!("{}{}", base_url.trim_end_matches('/'), check.path);
875
876 let mut request = self.client.request(check.method.clone(), &url);
877
878 for (name, value) in &check.headers {
880 request = request.header(name.as_str(), value.as_str());
881 }
882
883 match &check.body {
885 Some(CheckBody::Json(value)) => {
886 request = request.json(value);
887 }
888 Some(CheckBody::FormUrlencoded(fields)) => {
889 request = request.form(fields);
890 }
891 Some(CheckBody::Raw {
892 content,
893 content_type,
894 }) => {
895 if content_type == "text/plain" && check.path.contains("multipart") {
897 let part = reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
898 .file_name("test.txt")
899 .mime_str(content_type)
900 .unwrap_or_else(|_| {
901 reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
902 });
903 let form = reqwest::multipart::Form::new().part("field", part);
904 request = request.multipart(form);
905 } else {
906 request =
907 request.header("Content-Type", content_type.as_str()).body(content.clone());
908 }
909 }
910 Some(CheckBody::Multipart { parts }) => {
917 let mut form = reqwest::multipart::Form::new();
918 for part_spec in parts {
919 let mut part = reqwest::multipart::Part::bytes(part_spec.bytes.clone())
920 .file_name(part_spec.filename.clone());
921 part = match part.mime_str(&part_spec.content_type) {
922 Ok(p) => p,
923 Err(_) => reqwest::multipart::Part::bytes(part_spec.bytes.clone())
924 .file_name(part_spec.filename.clone())
925 .mime_str("application/octet-stream")
926 .expect("application/octet-stream is a valid MIME type"),
927 };
928 form = form.part(part_spec.field_name.clone(), part);
929 }
930 request = request.multipart(form);
931 }
932 None => {}
933 }
934
935 let req_body_str = match &check.body {
936 Some(CheckBody::Json(v)) => v.to_string(),
937 Some(CheckBody::FormUrlencoded(f)) => {
938 f.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&")
939 }
940 Some(CheckBody::Raw { content, .. }) => content.clone(),
941 Some(CheckBody::Multipart { parts }) => {
948 let summary: Vec<String> = parts
949 .iter()
950 .map(|p| {
951 format!("{} ({}, {} bytes)", p.filename, p.content_type, p.bytes.len())
952 })
953 .collect();
954 format!("<{} file(s): {}>", parts.len(), summary.join(", "))
955 }
956 None => String::new(),
957 };
958
959 let response = match request.send().await {
960 Ok(resp) => resp,
961 Err(e) => {
962 let kind = if e.is_connect() {
973 "connect"
974 } else if e.is_timeout() {
975 "timeout"
976 } else if e.is_request() {
977 "request"
978 } else if e.is_body() {
979 "body"
980 } else if e.is_decode() {
981 "decode"
982 } else if format!("{}", e).to_ascii_lowercase().contains("tls") {
983 "tls"
984 } else {
985 "other"
986 };
987 self.record_network_event(NetworkEvent {
988 timestamp: chrono::Utc::now(),
989 check: check.name.clone(),
990 method: check.method.to_string(),
991 url: url.clone(),
992 kind: kind.to_string(),
993 message: format!("{}", e),
994 });
995 return CheckResult {
996 name: check.name.clone(),
997 passed: false,
998 failure_detail: Some(FailureDetail {
999 check: check.name.clone(),
1000 request: FailureRequest {
1001 method: check.method.to_string(),
1002 url: url.clone(),
1003 headers: HashMap::new(),
1004 body: String::new(),
1005 },
1006 response: FailureResponse {
1007 status: 0,
1008 headers: HashMap::new(),
1009 body: format!("Request failed ({}): {}", kind, e),
1010 },
1011 expected: format!("{:?}", check.validation),
1012 schema_violations: Vec::new(),
1013 }),
1014 captured: None,
1015 };
1016 }
1017 };
1018
1019 let status = response.status().as_u16();
1020 let resp_headers: HashMap<String, String> = response
1021 .headers()
1022 .iter()
1023 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
1024 .collect();
1025 let resp_body = response.text().await.unwrap_or_default();
1026
1027 let (passed, schema_violations) =
1028 self.validate_response(&check.validation, status, &resp_headers, &resp_body);
1029
1030 let need_capture = self.config.export_requests || !self.chain_meta.is_empty();
1037 let captured = if need_capture {
1038 Some(CapturedExchange {
1039 method: check.method.to_string(),
1040 url: url.clone(),
1041 request_headers: check.headers.iter().cloned().collect(),
1042 request_body: if req_body_str.len() > 65536 {
1050 format!(
1051 "{}\n<truncated at 65536 bytes; full body was {} bytes>",
1052 &req_body_str[..65536],
1053 req_body_str.len()
1054 )
1055 } else {
1056 req_body_str
1057 },
1058 response_status: status,
1059 response_headers: resp_headers.clone(),
1060 response_body: if resp_body.len() > 65536 {
1061 format!(
1062 "{}\n<truncated at 65536 bytes; full body was {} bytes>",
1063 &resp_body[..65536],
1064 resp_body.len()
1065 )
1066 } else {
1067 resp_body.clone()
1068 },
1069 })
1070 } else {
1071 None
1072 };
1073
1074 let failure_detail = if !passed {
1075 Some(FailureDetail {
1076 check: check.name.clone(),
1077 request: FailureRequest {
1078 method: check.method.to_string(),
1079 url,
1080 headers: check.headers.iter().cloned().collect(),
1081 body: match &check.body {
1082 Some(CheckBody::Json(v)) => v.to_string(),
1083 Some(CheckBody::FormUrlencoded(f)) => f
1084 .iter()
1085 .map(|(k, v)| format!("{}={}", k, v))
1086 .collect::<Vec<_>>()
1087 .join("&"),
1088 Some(CheckBody::Raw { content, .. }) => content.clone(),
1089 Some(CheckBody::Multipart { parts }) => {
1093 format!("<{} multipart file(s)>", parts.len())
1094 }
1095 None => String::new(),
1096 },
1097 },
1098 response: FailureResponse {
1099 status,
1100 headers: resp_headers,
1101 body: if resp_body.len() > 500 {
1102 format!("{}...", &resp_body[..500])
1103 } else {
1104 resp_body
1105 },
1106 },
1107 expected: Self::describe_validation(&check.validation),
1108 schema_violations,
1109 })
1110 } else {
1111 None
1112 };
1113
1114 CheckResult {
1115 name: check.name.clone(),
1116 passed,
1117 failure_detail,
1118 captured,
1119 }
1120 }
1121
1122 async fn execute_chain_check(
1134 &self,
1135 check: &ConformanceCheck,
1136 meta: &ChainMeta,
1137 ctx: &mut ChainContext,
1138 ) -> Vec<CheckResult> {
1139 let substituted = apply_chain_context(check, ctx);
1140 let count = meta.repeat.count.max(1);
1141 let results = match meta.repeat.mode {
1142 super::custom::RepeatMode::Sequential => {
1143 let mut out = Vec::with_capacity(count as usize);
1144 for _ in 0..count {
1145 out.push(self.execute_check(&substituted).await);
1146 }
1147 out
1148 }
1149 super::custom::RepeatMode::Parallel => {
1150 let futs = (0..count).map(|_| self.execute_check(&substituted));
1151 futures::future::join_all(futs).await
1152 }
1153 };
1154
1155 if !meta.extract.is_empty() {
1161 if let Some(first) = results.first() {
1162 if let Some(captured) = &first.captured {
1163 extract_into_context(
1164 &meta.extract,
1165 &captured.response_headers,
1166 &captured.response_body,
1167 ctx,
1168 );
1169 }
1170 }
1171 }
1172 results
1173 }
1174
1175 fn validate_response(
1180 &self,
1181 validation: &CheckValidation,
1182 status: u16,
1183 headers: &HashMap<String, String>,
1184 body: &str,
1185 ) -> (bool, Vec<SchemaViolation>) {
1186 match validation {
1187 CheckValidation::StatusRange { min, max_exclusive } => {
1188 (status >= *min && status < *max_exclusive, Vec::new())
1189 }
1190 CheckValidation::ExactStatus(expected) => (status == *expected, Vec::new()),
1191 CheckValidation::SchemaValidation {
1192 status_min,
1193 status_max,
1194 schema,
1195 } => {
1196 if status < *status_min || status >= *status_max {
1197 return (false, Vec::new());
1198 }
1199 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
1201 return (
1202 false,
1203 vec![SchemaViolation {
1204 field_path: "/".to_string(),
1205 violation_type: "parse_error".to_string(),
1206 expected: "valid JSON".to_string(),
1207 actual: "non-JSON response body".to_string(),
1208 }],
1209 );
1210 };
1211 match jsonschema::validator_for(schema) {
1212 Ok(validator) => {
1213 let errors: Vec<_> = validator.iter_errors(&body_value).collect();
1214 if errors.is_empty() {
1215 (true, Vec::new())
1216 } else {
1217 let violations = errors
1218 .iter()
1219 .map(|err| {
1220 let field_path = err.instance_path.to_string();
1221 let field_path = if field_path.is_empty() {
1222 "/".to_string()
1223 } else {
1224 field_path
1225 };
1226 SchemaViolation {
1227 field_path,
1228 violation_type: format!("{:?}", err.kind)
1229 .split('(')
1230 .next()
1231 .unwrap_or("unknown")
1232 .split('{')
1233 .next()
1234 .unwrap_or("unknown")
1235 .split(' ')
1236 .next()
1237 .unwrap_or("unknown")
1238 .trim()
1239 .to_string(),
1240 expected: {
1241 let schema_str = format!("{}", err.schema_path);
1245 match &err.kind {
1246 jsonschema::error::ValidationErrorKind::Type { kind } => {
1247 format!("type: {:?}", kind)
1248 }
1249 jsonschema::error::ValidationErrorKind::Required { property } => {
1250 format!("required field: {}", property)
1251 }
1252 _ => {
1253 schema_str
1255 .rsplit('/')
1256 .next()
1257 .unwrap_or(&schema_str)
1258 .to_string()
1259 }
1260 }
1261 },
1262 actual: format!("{}", err),
1263 }
1264 })
1265 .collect();
1266 (false, violations)
1267 }
1268 }
1269 Err(_) => {
1270 (
1272 false,
1273 vec![SchemaViolation {
1274 field_path: "/".to_string(),
1275 violation_type: "schema_compile_error".to_string(),
1276 expected: "valid JSON schema".to_string(),
1277 actual: "schema failed to compile".to_string(),
1278 }],
1279 )
1280 }
1281 }
1282 }
1283 CheckValidation::Custom {
1284 expected_status,
1285 expected_headers,
1286 expected_body_fields,
1287 } => {
1288 if status != *expected_status {
1289 return (false, Vec::new());
1290 }
1291 for (header_name, pattern) in expected_headers {
1293 let header_val = headers
1294 .get(header_name)
1295 .or_else(|| headers.get(&header_name.to_lowercase()))
1296 .map(|s| s.as_str())
1297 .unwrap_or("");
1298 if let Ok(re) = regex::Regex::new(pattern) {
1299 if !re.is_match(header_val) {
1300 return (false, Vec::new());
1301 }
1302 }
1303 }
1304 if !expected_body_fields.is_empty() {
1306 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
1307 return (false, Vec::new());
1308 };
1309 for (field_name, field_type) in expected_body_fields {
1310 let field = &body_value[field_name];
1311 let ok = match field_type.as_str() {
1312 "string" => field.is_string(),
1313 "integer" => field.is_i64() || field.is_u64(),
1314 "number" => field.is_number(),
1315 "boolean" => field.is_boolean(),
1316 "array" => field.is_array(),
1317 "object" => field.is_object(),
1318 _ => !field.is_null(),
1319 };
1320 if !ok {
1321 return (false, Vec::new());
1322 }
1323 }
1324 }
1325 (true, Vec::new())
1326 }
1327 }
1328 }
1329
1330 fn describe_validation(validation: &CheckValidation) -> String {
1332 match validation {
1333 CheckValidation::StatusRange { min, max_exclusive } => {
1334 format!("status >= {} && status < {}", min, max_exclusive)
1335 }
1336 CheckValidation::ExactStatus(code) => format!("status === {}", code),
1337 CheckValidation::SchemaValidation {
1338 status_min,
1339 status_max,
1340 ..
1341 } => {
1342 format!("status >= {} && status < {} + schema validation", status_min, status_max)
1343 }
1344 CheckValidation::Custom {
1345 expected_status, ..
1346 } => {
1347 format!("status === {}", expected_status)
1348 }
1349 }
1350 }
1351
1352 fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
1354 let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
1355 let mut failure_details = Vec::new();
1356
1357 for result in results {
1358 let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
1359 if result.passed {
1360 entry.0 += 1;
1361 } else {
1362 entry.1 += 1;
1363 }
1364 if let Some(detail) = result.failure_detail {
1365 failure_details.push(detail);
1366 }
1367 }
1368
1369 ConformanceReport::from_results(check_results, failure_details)
1370 }
1371
1372 fn build_spec_check(
1376 &self,
1377 check_name: &str,
1378 op: &AnnotatedOperation,
1379 feature: &ConformanceFeature,
1380 ) -> ConformanceCheck {
1381 let mut url_path = op.path.clone();
1383 for (name, value) in &op.path_params {
1384 url_path = url_path.replace(&format!("{{{}}}", name), value);
1385 }
1386 if !op.query_params.is_empty() {
1388 let qs: Vec<String> =
1389 op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1390 url_path = format!("{}?{}", url_path, qs.join("&"));
1391 }
1392
1393 let mut effective_headers = self.effective_headers(&op.header_params);
1395
1396 if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
1398 let code = match feature {
1399 ConformanceFeature::Response400 => "400",
1400 ConformanceFeature::Response404 => "404",
1401 _ => unreachable!(),
1402 };
1403 effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
1404 }
1405
1406 let needs_auth = matches!(
1408 feature,
1409 ConformanceFeature::SecurityBearer
1410 | ConformanceFeature::SecurityBasic
1411 | ConformanceFeature::SecurityApiKey
1412 ) || !op.security_schemes.is_empty();
1413
1414 if needs_auth {
1415 self.inject_security_headers(&op.security_schemes, &mut effective_headers);
1416 }
1417
1418 let method = match op.method.as_str() {
1420 "GET" => Method::GET,
1421 "POST" => Method::POST,
1422 "PUT" => Method::PUT,
1423 "PATCH" => Method::PATCH,
1424 "DELETE" => Method::DELETE,
1425 "HEAD" => Method::HEAD,
1426 "OPTIONS" => Method::OPTIONS,
1427 _ => Method::GET,
1428 };
1429
1430 let body = match method {
1432 Method::POST | Method::PUT | Method::PATCH => {
1433 if let Some(sample) = &op.sample_body {
1434 let content_type =
1436 op.request_body_content_type.as_deref().unwrap_or("application/json");
1437 if !effective_headers
1438 .iter()
1439 .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1440 {
1441 effective_headers
1442 .push(("Content-Type".to_string(), content_type.to_string()));
1443 }
1444 match content_type {
1445 "application/x-www-form-urlencoded" => {
1446 let fields: Vec<(String, String)> = serde_json::from_str::<
1448 serde_json::Value,
1449 >(
1450 sample
1451 )
1452 .ok()
1453 .and_then(|v| {
1454 v.as_object().map(|obj| {
1455 obj.iter()
1456 .map(|(k, v)| {
1457 (k.clone(), v.as_str().unwrap_or("").to_string())
1458 })
1459 .collect()
1460 })
1461 })
1462 .unwrap_or_default();
1463 Some(CheckBody::FormUrlencoded(fields))
1464 }
1465 _ => {
1466 match serde_json::from_str::<serde_json::Value>(sample) {
1468 Ok(v) => Some(CheckBody::Json(v)),
1469 Err(_) => Some(CheckBody::Raw {
1470 content: sample.clone(),
1471 content_type: content_type.to_string(),
1472 }),
1473 }
1474 }
1475 }
1476 } else {
1477 None
1478 }
1479 }
1480 _ => None,
1481 };
1482
1483 let validation = self.determine_validation(feature, op);
1485
1486 ConformanceCheck {
1487 name: check_name.to_string(),
1488 method,
1489 path: url_path,
1490 headers: effective_headers,
1491 body,
1492 validation,
1493 }
1494 }
1495
1496 fn determine_validation(
1498 &self,
1499 feature: &ConformanceFeature,
1500 op: &AnnotatedOperation,
1501 ) -> CheckValidation {
1502 match feature {
1503 ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
1504 ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
1505 ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
1506 ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
1507 ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
1508 ConformanceFeature::SecurityBearer
1509 | ConformanceFeature::SecurityBasic
1510 | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
1511 min: 200,
1512 max_exclusive: 400,
1513 },
1514 ConformanceFeature::ResponseValidation => {
1515 if let Some(schema) = &op.response_schema {
1516 let schema_json = openapi_schema_to_json_schema(schema);
1518 CheckValidation::SchemaValidation {
1519 status_min: 200,
1520 status_max: 500,
1521 schema: schema_json,
1522 }
1523 } else {
1524 CheckValidation::StatusRange {
1525 min: 200,
1526 max_exclusive: 500,
1527 }
1528 }
1529 }
1530 _ => CheckValidation::StatusRange {
1531 min: 200,
1532 max_exclusive: 500,
1533 },
1534 }
1535 }
1536
1537 fn add_ref_get(&mut self, name: &str, path: &str) {
1539 self.checks.push(ConformanceCheck {
1540 name: name.to_string(),
1541 method: Method::GET,
1542 path: path.to_string(),
1543 headers: self.custom_headers_only(),
1544 body: None,
1545 validation: CheckValidation::StatusRange {
1546 min: 200,
1547 max_exclusive: 500,
1548 },
1549 });
1550 }
1551
1552 fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
1554 let mut headers = Vec::new();
1555 for (k, v) in spec_headers {
1556 if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
1558 continue;
1559 }
1560 headers.push((k.clone(), v.clone()));
1561 }
1562 headers.extend(self.config.custom_headers.clone());
1564 headers
1565 }
1566
1567 fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
1569 for (k, v) in &self.config.custom_headers {
1570 if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1571 headers.push((k.clone(), v.clone()));
1572 }
1573 }
1574 headers
1575 }
1576
1577 fn custom_headers_only(&self) -> Vec<(String, String)> {
1579 self.config.custom_headers.clone()
1580 }
1581
1582 fn inject_security_headers(
1586 &self,
1587 schemes: &[SecuritySchemeInfo],
1588 headers: &mut Vec<(String, String)>,
1589 ) {
1590 let has_cookie_auth =
1592 self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1593 let mut to_add: Vec<(String, String)> = Vec::new();
1594
1595 for scheme in schemes {
1596 match scheme {
1597 SecuritySchemeInfo::Bearer => {
1598 if !has_cookie_auth
1599 && !Self::header_present(
1600 "Authorization",
1601 headers,
1602 &self.config.custom_headers,
1603 )
1604 {
1605 to_add.push((
1606 "Authorization".to_string(),
1607 "Bearer mockforge-conformance-test-token".to_string(),
1608 ));
1609 }
1610 }
1611 SecuritySchemeInfo::Basic => {
1612 if !has_cookie_auth
1613 && !Self::header_present(
1614 "Authorization",
1615 headers,
1616 &self.config.custom_headers,
1617 )
1618 {
1619 let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1620 use base64::Engine;
1621 let encoded =
1622 base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1623 to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1624 }
1625 }
1626 SecuritySchemeInfo::ApiKey { location, name } => match location {
1627 ApiKeyLocation::Header => {
1628 if !Self::header_present(name, headers, &self.config.custom_headers) {
1629 let key = self
1630 .config
1631 .api_key
1632 .as_deref()
1633 .unwrap_or("mockforge-conformance-test-key");
1634 to_add.push((name.clone(), key.to_string()));
1635 }
1636 }
1637 ApiKeyLocation::Cookie => {
1638 if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1639 to_add.push((
1640 "Cookie".to_string(),
1641 format!("{}=mockforge-conformance-test-session", name),
1642 ));
1643 }
1644 }
1645 ApiKeyLocation::Query => {
1646 }
1648 },
1649 }
1650 }
1651
1652 headers.extend(to_add);
1653 }
1654
1655 fn header_present(
1657 name: &str,
1658 headers: &[(String, String)],
1659 custom_headers: &[(String, String)],
1660 ) -> bool {
1661 headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1662 || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1663 }
1664
1665 fn add_custom_check(&mut self, check: &CustomCheck) {
1667 let method = match check.method.to_uppercase().as_str() {
1668 "GET" => Method::GET,
1669 "POST" => Method::POST,
1670 "PUT" => Method::PUT,
1671 "PATCH" => Method::PATCH,
1672 "DELETE" => Method::DELETE,
1673 "HEAD" => Method::HEAD,
1674 "OPTIONS" => Method::OPTIONS,
1675 _ => Method::GET,
1676 };
1677
1678 let mut headers: Vec<(String, String)> =
1680 check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1681 for (k, v) in &self.config.custom_headers {
1683 if !check.headers.contains_key(k) {
1684 headers.push((k.clone(), v.clone()));
1685 }
1686 }
1687 if check.body.is_some()
1689 && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1690 {
1691 headers.push(("Content-Type".to_string(), "application/json".to_string()));
1692 }
1693
1694 let upload_specs: Vec<&super::custom::UploadFile> =
1704 check.upload.as_ref().into_iter().chain(check.uploads.iter()).collect();
1705 let body = if check.body.is_some() {
1706 if !upload_specs.is_empty() {
1707 eprintln!(
1708 "warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring uploads",
1709 check.name
1710 );
1711 }
1712 check.body.as_ref().and_then(|b| {
1713 serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json)
1714 })
1715 } else if !upload_specs.is_empty() {
1716 let mut parts = Vec::with_capacity(upload_specs.len());
1717 for spec in upload_specs {
1718 match std::fs::read(&spec.path) {
1719 Ok(bytes) => {
1720 let filename = spec.filename.clone().unwrap_or_else(|| {
1721 std::path::Path::new(&spec.path)
1722 .file_name()
1723 .and_then(|n| n.to_str())
1724 .unwrap_or("upload.bin")
1725 .to_string()
1726 });
1727 parts.push(MultipartPart {
1728 bytes,
1729 content_type: spec.content_type.clone(),
1730 field_name: spec.field_name.clone(),
1731 filename,
1732 });
1733 }
1734 Err(e) => {
1735 eprintln!(
1736 "warning: custom check '{}' could not read upload '{}': {}",
1737 check.name, spec.path, e
1738 );
1739 }
1740 }
1741 }
1742 if parts.is_empty() {
1743 None
1744 } else {
1745 Some(CheckBody::Multipart { parts })
1746 }
1747 } else {
1748 None
1749 };
1750
1751 let expected_headers: Vec<(String, String)> =
1753 check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1754
1755 let expected_body_fields: Vec<(String, String)> = check
1757 .expected_body_fields
1758 .iter()
1759 .map(|f| (f.name.clone(), f.field_type.clone()))
1760 .collect();
1761
1762 let needs_chain = !check.extract.is_empty() || !check.repeat.is_default();
1767 let next_index = self.checks.len();
1768 if needs_chain {
1769 self.chain_meta.insert(
1770 next_index,
1771 ChainMeta {
1772 extract: check.extract.clone(),
1773 repeat: check.repeat.clone(),
1774 },
1775 );
1776 }
1777
1778 self.checks.push(ConformanceCheck {
1780 name: check.name.clone(),
1781 method,
1782 path: check.path.clone(),
1783 headers,
1784 body,
1785 validation: CheckValidation::Custom {
1786 expected_status: check.expected_status,
1787 expected_headers,
1788 expected_body_fields,
1789 },
1790 });
1791 }
1792}
1793
1794fn apply_chain_context(check: &ConformanceCheck, ctx: &ChainContext) -> ConformanceCheck {
1801 let path = ctx.substitute(&check.path);
1802 let headers = check.headers.iter().map(|(k, v)| (k.clone(), ctx.substitute(v))).collect();
1803 let body = check.body.as_ref().map(|b| match b {
1804 CheckBody::Json(v) => CheckBody::Json(substitute_in_json(v, ctx)),
1805 CheckBody::FormUrlencoded(fields) => CheckBody::FormUrlencoded(
1806 fields.iter().map(|(k, v)| (k.clone(), ctx.substitute(v))).collect(),
1807 ),
1808 CheckBody::Raw {
1809 content,
1810 content_type,
1811 } => CheckBody::Raw {
1812 content: ctx.substitute(content),
1813 content_type: content_type.clone(),
1814 },
1815 CheckBody::Multipart { parts } => CheckBody::Multipart {
1819 parts: parts.clone(),
1820 },
1821 });
1822 ConformanceCheck {
1823 name: check.name.clone(),
1824 method: check.method.clone(),
1825 path,
1826 headers,
1827 body,
1828 validation: check.validation.clone(),
1829 }
1830}
1831
1832fn substitute_in_json(value: &serde_json::Value, ctx: &ChainContext) -> serde_json::Value {
1833 use serde_json::Value;
1834 match value {
1835 Value::String(s) => Value::String(ctx.substitute(s)),
1836 Value::Array(arr) => Value::Array(arr.iter().map(|v| substitute_in_json(v, ctx)).collect()),
1837 Value::Object(obj) => Value::Object(
1838 obj.iter().map(|(k, v)| (k.clone(), substitute_in_json(v, ctx))).collect(),
1839 ),
1840 other => other.clone(),
1841 }
1842}
1843
1844fn extract_into_context(
1850 rules: &super::custom::ExtractRules,
1851 response_headers: &HashMap<String, String>,
1852 response_body: &str,
1853 ctx: &mut ChainContext,
1854) {
1855 for cookie_name in &rules.cookies {
1862 if let Some(raw) = response_headers
1863 .iter()
1864 .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
1865 .map(|(_, v)| v)
1866 {
1867 for entry in raw.split(',') {
1870 let head = entry.split(';').next().unwrap_or(entry).trim();
1871 if let Some((name, value)) = head.split_once('=') {
1872 if name.trim().eq_ignore_ascii_case(cookie_name) {
1873 ctx.cookies.insert(cookie_name.clone(), value.trim().to_string());
1874 break;
1875 }
1876 }
1877 }
1878 }
1879 }
1880 for (var_name, header_name) in &rules.headers {
1882 if let Some((_, value)) =
1883 response_headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(header_name))
1884 {
1885 ctx.vars.insert(var_name.clone(), value.clone());
1886 }
1887 }
1888 if !rules.body_fields.is_empty() {
1891 if let Ok(json) = serde_json::from_str::<serde_json::Value>(response_body) {
1892 for (var_name, field_path) in &rules.body_fields {
1893 if let Some(value) = lookup_json_path(&json, field_path) {
1894 let stringified = match value {
1895 serde_json::Value::String(s) => s.clone(),
1896 other => other.to_string(),
1897 };
1898 ctx.vars.insert(var_name.clone(), stringified);
1899 }
1900 }
1901 }
1902 }
1903}
1904
1905fn lookup_json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
1906 let mut current = value;
1907 for segment in path.split('.') {
1908 current = match current {
1909 serde_json::Value::Object(obj) => obj.get(segment)?,
1910 _ => return None,
1911 };
1912 }
1913 Some(current)
1914}
1915
1916fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1919 use openapiv3::{SchemaKind, Type};
1920
1921 match &schema.schema_kind {
1922 SchemaKind::Type(Type::Object(obj)) => {
1923 let mut props = serde_json::Map::new();
1924 for (name, prop_ref) in &obj.properties {
1925 if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1926 props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1927 }
1928 }
1929 let mut schema_obj = serde_json::json!({
1930 "type": "object",
1931 "properties": props,
1932 });
1933 if !obj.required.is_empty() {
1934 schema_obj["required"] = serde_json::Value::Array(
1935 obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1936 );
1937 }
1938 schema_obj
1939 }
1940 SchemaKind::Type(Type::Array(arr)) => {
1941 let mut schema_obj = serde_json::json!({"type": "array"});
1942 if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1943 schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1944 }
1945 schema_obj
1946 }
1947 SchemaKind::Type(Type::String(s)) => {
1948 let mut obj = serde_json::json!({"type": "string"});
1949 if let Some(min) = s.min_length {
1950 obj["minLength"] = serde_json::json!(min);
1951 }
1952 if let Some(max) = s.max_length {
1953 obj["maxLength"] = serde_json::json!(max);
1954 }
1955 if let Some(pattern) = &s.pattern {
1956 obj["pattern"] = serde_json::json!(pattern);
1957 }
1958 if !s.enumeration.is_empty() {
1959 obj["enum"] = serde_json::Value::Array(
1960 s.enumeration
1961 .iter()
1962 .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1963 .collect(),
1964 );
1965 }
1966 obj
1967 }
1968 SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1969 SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1970 SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1971 _ => serde_json::json!({}),
1972 }
1973}
1974
1975#[cfg(test)]
1976mod tests {
1977 use super::*;
1978
1979 #[test]
1984 fn chain_context_substitutes_all_token_kinds() {
1985 let mut ctx = ChainContext::default();
1986 ctx.vars.insert("csrf".to_string(), "abc123".to_string());
1987 ctx.vars.insert("trace".to_string(), "xyz".to_string());
1988 ctx.cookies.insert("session".to_string(), "deadbeef".to_string());
1989 assert_eq!(ctx.substitute("plain"), "plain");
1990 assert_eq!(ctx.substitute("X-CSRF: ${var:csrf}"), "X-CSRF: abc123");
1991 assert_eq!(ctx.substitute("Cookie: session=${cookie:session}"), "Cookie: session=deadbeef");
1992 assert_eq!(ctx.substitute("X-Trace: ${header:trace}"), "X-Trace: xyz");
1994 assert_eq!(ctx.substitute("missing: ${var:nope}"), "missing: ${var:nope}");
1996 }
1997
1998 #[test]
2002 fn extract_into_context_captures_cookies_headers_and_body_fields() {
2003 let mut headers = HashMap::new();
2004 headers.insert("Set-Cookie".to_string(), "session=abc123; Path=/; HttpOnly".to_string());
2005 headers.insert("X-CSRF-Token".to_string(), "csrf-token-xyz".to_string());
2006 let body = r#"{"data":{"token":"body-token-456"},"id":42}"#;
2007 let mut rules = super::super::custom::ExtractRules::default();
2008 rules.cookies.push("session".to_string());
2009 rules.headers.insert("csrf".to_string(), "X-CSRF-Token".to_string());
2010 rules.body_fields.insert("nested_token".to_string(), "data.token".to_string());
2011 rules.body_fields.insert("user_id".to_string(), "id".to_string());
2012 let mut ctx = ChainContext::default();
2013 extract_into_context(&rules, &headers, body, &mut ctx);
2014 assert_eq!(ctx.cookies.get("session").map(|s| s.as_str()), Some("abc123"));
2015 assert_eq!(ctx.vars.get("csrf").map(|s| s.as_str()), Some("csrf-token-xyz"));
2016 assert_eq!(ctx.vars.get("nested_token").map(|s| s.as_str()), Some("body-token-456"));
2017 assert_eq!(ctx.vars.get("user_id").map(|s| s.as_str()), Some("42"));
2019 }
2020
2021 #[test]
2025 fn extract_into_context_skips_missing_captures_gracefully() {
2026 let mut headers = HashMap::new();
2027 headers.insert("X-CSRF-Token".to_string(), "csrf-value".to_string());
2028 let body = r#"{"id":1}"#;
2029 let mut rules = super::super::custom::ExtractRules::default();
2030 rules.cookies.push("never-set".to_string());
2031 rules.headers.insert("csrf".to_string(), "X-CSRF-Token".to_string());
2032 let mut ctx = ChainContext::default();
2033 extract_into_context(&rules, &headers, body, &mut ctx);
2034 assert!(ctx.cookies.is_empty(), "missing cookie should not insert anything");
2035 assert_eq!(ctx.vars.get("csrf").map(|s| s.as_str()), Some("csrf-value"));
2036 }
2037
2038 #[test]
2043 fn apply_chain_context_substitutes_path_headers_and_body() {
2044 let mut ctx = ChainContext::default();
2045 ctx.vars.insert("user".to_string(), "alice".to_string());
2046 ctx.cookies.insert("sid".to_string(), "deadbeef".to_string());
2047 let check = ConformanceCheck {
2048 name: "custom:t".into(),
2049 method: Method::POST,
2050 path: "/users/${var:user}".into(),
2051 headers: vec![("Cookie".into(), "sid=${cookie:sid}".into())],
2052 body: Some(CheckBody::Json(serde_json::json!({"by": "${var:user}", "ts": 1}))),
2053 validation: CheckValidation::ExactStatus(200),
2054 };
2055 let substituted = apply_chain_context(&check, &ctx);
2056 assert_eq!(substituted.path, "/users/alice");
2057 assert_eq!(substituted.headers[0].1, "sid=deadbeef");
2058 match substituted.body {
2059 Some(CheckBody::Json(v)) => {
2060 assert_eq!(v["by"], "alice");
2061 assert_eq!(v["ts"], 1);
2062 }
2063 _ => panic!("expected json body"),
2064 }
2065 }
2066
2067 #[test]
2068 fn test_reference_check_count() {
2069 let config = ConformanceConfig {
2070 target_url: "http://localhost:3000".to_string(),
2071 ..Default::default()
2072 };
2073 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
2074 assert_eq!(executor.check_count(), 47);
2077 }
2078
2079 #[test]
2080 fn with_custom_checks_from_config_appends() {
2081 let custom_yaml = r#"
2086custom_checks:
2087 - name: "custom:health"
2088 path: /health
2089 method: GET
2090 expected_status: 200
2091 - name: "custom:create"
2092 path: /widgets
2093 method: POST
2094 expected_status: 201
2095"#;
2096 let parsed: CustomConformanceConfig =
2097 serde_yaml::from_str(custom_yaml).expect("YAML parses");
2098 assert_eq!(parsed.custom_checks.len(), 2);
2099
2100 let base = ConformanceConfig {
2101 target_url: "http://localhost:3000".to_string(),
2102 ..Default::default()
2103 };
2104 let executor = NativeConformanceExecutor::new(base)
2105 .unwrap()
2106 .with_reference_checks()
2107 .with_custom_checks_from_config(parsed)
2108 .expect("custom checks load");
2109 assert_eq!(executor.check_count(), 49);
2111 }
2112
2113 #[test]
2114 fn with_custom_checks_from_config_respects_filter() {
2115 let custom_yaml = r#"
2118custom_checks:
2119 - name: "custom:health"
2120 path: /health
2121 method: GET
2122 expected_status: 200
2123 - name: "custom:create-widget"
2124 path: /widgets
2125 method: POST
2126 expected_status: 201
2127"#;
2128 let parsed: CustomConformanceConfig =
2129 serde_yaml::from_str(custom_yaml).expect("YAML parses");
2130
2131 let base = ConformanceConfig {
2132 target_url: "http://localhost:3000".to_string(),
2133 categories: Some(vec!["no_such_category".to_string()]),
2136 custom_filter: Some("health".to_string()),
2137 ..Default::default()
2138 };
2139 let executor = NativeConformanceExecutor::new(base)
2140 .unwrap()
2141 .with_reference_checks()
2142 .with_custom_checks_from_config(parsed)
2143 .expect("custom checks load");
2144 assert_eq!(executor.check_count(), 1);
2147 }
2148
2149 #[test]
2150 fn with_custom_checks_from_config_rejects_bad_filter_regex() {
2151 let parsed: CustomConformanceConfig =
2152 serde_yaml::from_str("custom_checks: []").expect("YAML parses");
2153 let base = ConformanceConfig {
2154 target_url: "http://localhost:3000".to_string(),
2155 custom_filter: Some("[unclosed".to_string()),
2156 ..Default::default()
2157 };
2158 let result = NativeConformanceExecutor::new(base)
2159 .unwrap()
2160 .with_reference_checks()
2161 .with_custom_checks_from_config(parsed);
2162 assert!(result.is_err(), "bad regex should bubble up as BenchError");
2163 }
2164
2165 #[test]
2166 fn test_reference_checks_with_category_filter() {
2167 let config = ConformanceConfig {
2168 target_url: "http://localhost:3000".to_string(),
2169 categories: Some(vec!["Parameters".to_string()]),
2170 ..Default::default()
2171 };
2172 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
2173 assert_eq!(executor.check_count(), 7);
2174 }
2175
2176 #[test]
2177 fn test_validate_status_range() {
2178 let config = ConformanceConfig {
2179 target_url: "http://localhost:3000".to_string(),
2180 ..Default::default()
2181 };
2182 let executor = NativeConformanceExecutor::new(config).unwrap();
2183 let headers = HashMap::new();
2184
2185 assert!(
2186 executor
2187 .validate_response(
2188 &CheckValidation::StatusRange {
2189 min: 200,
2190 max_exclusive: 500,
2191 },
2192 200,
2193 &headers,
2194 "",
2195 )
2196 .0
2197 );
2198 assert!(
2199 executor
2200 .validate_response(
2201 &CheckValidation::StatusRange {
2202 min: 200,
2203 max_exclusive: 500,
2204 },
2205 404,
2206 &headers,
2207 "",
2208 )
2209 .0
2210 );
2211 assert!(
2212 !executor
2213 .validate_response(
2214 &CheckValidation::StatusRange {
2215 min: 200,
2216 max_exclusive: 500,
2217 },
2218 500,
2219 &headers,
2220 "",
2221 )
2222 .0
2223 );
2224 }
2225
2226 #[test]
2227 fn test_validate_exact_status() {
2228 let config = ConformanceConfig {
2229 target_url: "http://localhost:3000".to_string(),
2230 ..Default::default()
2231 };
2232 let executor = NativeConformanceExecutor::new(config).unwrap();
2233 let headers = HashMap::new();
2234
2235 assert!(
2236 executor
2237 .validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "")
2238 .0
2239 );
2240 assert!(
2241 !executor
2242 .validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "")
2243 .0
2244 );
2245 }
2246
2247 #[test]
2248 fn test_validate_schema() {
2249 let config = ConformanceConfig {
2250 target_url: "http://localhost:3000".to_string(),
2251 ..Default::default()
2252 };
2253 let executor = NativeConformanceExecutor::new(config).unwrap();
2254 let headers = HashMap::new();
2255
2256 let schema = serde_json::json!({
2257 "type": "object",
2258 "properties": {
2259 "name": {"type": "string"},
2260 "age": {"type": "integer"}
2261 },
2262 "required": ["name"]
2263 });
2264
2265 let (passed, violations) = executor.validate_response(
2266 &CheckValidation::SchemaValidation {
2267 status_min: 200,
2268 status_max: 300,
2269 schema: schema.clone(),
2270 },
2271 200,
2272 &headers,
2273 r#"{"name": "test", "age": 25}"#,
2274 );
2275 assert!(passed);
2276 assert!(violations.is_empty());
2277
2278 let (passed, violations) = executor.validate_response(
2280 &CheckValidation::SchemaValidation {
2281 status_min: 200,
2282 status_max: 300,
2283 schema: schema.clone(),
2284 },
2285 200,
2286 &headers,
2287 r#"{"age": 25}"#,
2288 );
2289 assert!(!passed);
2290 assert!(!violations.is_empty());
2291 assert_eq!(violations[0].violation_type, "Required");
2292 }
2293
2294 #[test]
2295 fn test_validate_custom() {
2296 let config = ConformanceConfig {
2297 target_url: "http://localhost:3000".to_string(),
2298 ..Default::default()
2299 };
2300 let executor = NativeConformanceExecutor::new(config).unwrap();
2301 let mut headers = HashMap::new();
2302 headers.insert("content-type".to_string(), "application/json".to_string());
2303
2304 assert!(
2305 executor
2306 .validate_response(
2307 &CheckValidation::Custom {
2308 expected_status: 200,
2309 expected_headers: vec![(
2310 "content-type".to_string(),
2311 "application/json".to_string(),
2312 )],
2313 expected_body_fields: vec![("name".to_string(), "string".to_string())],
2314 },
2315 200,
2316 &headers,
2317 r#"{"name": "test"}"#,
2318 )
2319 .0
2320 );
2321
2322 assert!(
2324 !executor
2325 .validate_response(
2326 &CheckValidation::Custom {
2327 expected_status: 200,
2328 expected_headers: vec![],
2329 expected_body_fields: vec![],
2330 },
2331 404,
2332 &headers,
2333 "",
2334 )
2335 .0
2336 );
2337 }
2338
2339 #[test]
2340 fn test_aggregate_results() {
2341 let results = vec![
2342 CheckResult {
2343 name: "check1".to_string(),
2344 passed: true,
2345 failure_detail: None,
2346 captured: None,
2347 },
2348 CheckResult {
2349 name: "check2".to_string(),
2350 passed: false,
2351 captured: None,
2352 failure_detail: Some(FailureDetail {
2353 check: "check2".to_string(),
2354 request: FailureRequest {
2355 method: "GET".to_string(),
2356 url: "http://example.com".to_string(),
2357 headers: HashMap::new(),
2358 body: String::new(),
2359 },
2360 response: FailureResponse {
2361 status: 500,
2362 headers: HashMap::new(),
2363 body: "error".to_string(),
2364 },
2365 expected: "status >= 200 && status < 500".to_string(),
2366 schema_violations: Vec::new(),
2367 }),
2368 },
2369 ];
2370
2371 let report = NativeConformanceExecutor::aggregate(results);
2372 let raw = report.raw_check_results();
2373 assert_eq!(raw.get("check1"), Some(&(1, 0)));
2374 assert_eq!(raw.get("check2"), Some(&(0, 1)));
2375 }
2376
2377 #[test]
2378 fn test_custom_check_building() {
2379 let config = ConformanceConfig {
2380 target_url: "http://localhost:3000".to_string(),
2381 ..Default::default()
2382 };
2383 let mut executor = NativeConformanceExecutor::new(config).unwrap();
2384
2385 let custom = CustomCheck {
2386 name: "custom:test-get".to_string(),
2387 path: "/api/test".to_string(),
2388 method: "GET".to_string(),
2389 expected_status: 200,
2390 body: None,
2391 expected_headers: HashMap::new(),
2392 expected_body_fields: vec![],
2393 headers: HashMap::new(),
2394 upload: None,
2395 uploads: vec![],
2396 extract: crate::conformance::custom::ExtractRules::default(),
2397 repeat: crate::conformance::custom::Repeat::default(),
2398 };
2399
2400 executor.add_custom_check(&custom);
2401 assert_eq!(executor.check_count(), 1);
2402 assert_eq!(executor.checks[0].name, "custom:test-get");
2403 }
2404
2405 #[test]
2406 fn test_openapi_schema_to_json_schema_object() {
2407 use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
2408
2409 let schema = Schema {
2410 schema_data: SchemaData::default(),
2411 schema_kind: SchemaKind::Type(Type::Object(ObjectType {
2412 required: vec!["name".to_string()],
2413 ..Default::default()
2414 })),
2415 };
2416
2417 let json = openapi_schema_to_json_schema(&schema);
2418 assert_eq!(json["type"], "object");
2419 assert_eq!(json["required"][0], "name");
2420 }
2421
2422 #[tokio::test]
2423 async fn guarded_client_refuses_internal_names_and_redirects() {
2424 use crate::ssrf::{test_server, Policy};
2425 use std::sync::atomic::Ordering;
2426
2427 let config = || ConformanceConfig {
2428 target_url: "https://example.com".to_string(),
2429 skip_tls_verify: true,
2430 ..Default::default()
2431 };
2432
2433 let (port, hits) = test_server::serve(test_server::OK).await;
2436 let strict = NativeConformanceExecutor::with_ssrf_guard(config(), Some(Policy::strict()))
2437 .expect("skip_tls_verify still builds under the guard");
2438 let err = strict.client.get(format!("http://localhost:{port}/")).send().await.unwrap_err();
2439 assert!(err.is_connect(), "{err:?}");
2440 assert_eq!(hits.load(Ordering::SeqCst), 0);
2441
2442 let (outer, _) =
2444 test_server::serve(test_server::redirect_to(&format!("http://0.0.0.0:{port}/"))).await;
2445 let relaxed =
2446 NativeConformanceExecutor::with_ssrf_guard(config(), Some(Policy::for_test())).unwrap();
2447 let err = relaxed
2448 .client
2449 .get(format!("http://127.0.0.1:{outer}/"))
2450 .send()
2451 .await
2452 .unwrap_err();
2453 assert!(err.is_redirect(), "{err:?}");
2454 assert_eq!(hits.load(Ordering::SeqCst), 0);
2455
2456 let open = NativeConformanceExecutor::with_ssrf_guard(config(), None).unwrap();
2458 open.client.get(format!("http://127.0.0.1:{outer}/")).send().await.unwrap();
2459 assert_eq!(hits.load(Ordering::SeqCst), 1);
2460 }
2461}