Skip to main content

mockforge_bench/conformance/
executor.rs

1//! Native Rust conformance test executor
2//!
3//! Replaces the k6-based execution path with direct HTTP requests via reqwest,
4//! producing the same `ConformanceReport` output. This removes the k6 binary
5//! dependency and enables API/SDK integration.
6
7use 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/// A field-level schema validation violation
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20pub struct SchemaViolation {
21    /// JSON path to the field that failed validation (e.g., "/name", "/items/0/age")
22    pub field_path: String,
23    /// Type of violation (e.g., "type", "required", "additionalProperties")
24    pub violation_type: String,
25    /// What the schema expected
26    pub expected: String,
27    /// What was actually found
28    pub actual: String,
29}
30
31/// A single conformance check to execute
32#[derive(Debug, Clone)]
33pub struct ConformanceCheck {
34    /// Check name (e.g., "param:path:string" or "param:path:string:/users/{id}")
35    pub name: String,
36    /// HTTP method
37    pub method: Method,
38    /// Relative path (appended to base_url)
39    pub path: String,
40    /// Request headers
41    pub headers: Vec<(String, String)>,
42    /// Optional request body
43    pub body: Option<CheckBody>,
44    /// How to validate the response
45    pub validation: CheckValidation,
46}
47
48/// Request body variants
49#[derive(Debug, Clone)]
50pub enum CheckBody {
51    /// JSON body
52    Json(serde_json::Value),
53    /// Form-urlencoded body
54    FormUrlencoded(Vec<(String, String)>),
55    /// Raw string body with content type
56    Raw {
57        content: String,
58        content_type: String,
59    },
60    /// Round 38 (#79) — multipart/form-data with file attachments.
61    /// Bytes are read from disk at request time so a YAML can name a
62    /// 50 MiB `.docx` without inflating the config. Use one entry per
63    /// part; the executor builds a single `reqwest::multipart::Form`
64    /// containing all of them so multi-file uploads land in one
65    /// request.
66    Multipart { parts: Vec<MultipartPart> },
67}
68
69/// Round 38 (#79) — one part of a multipart/form-data request body.
70#[derive(Debug, Clone)]
71pub struct MultipartPart {
72    /// Bytes that go on the wire as the part body.
73    pub bytes: Vec<u8>,
74    /// `Content-Type` for this part (e.g. `image/jpeg`,
75    /// `application/octet-stream`, `application/json`).
76    pub content_type: String,
77    /// Multipart form field name (the key on the receiving server).
78    pub field_name: String,
79    /// Filename announced in this part's `Content-Disposition` header.
80    pub filename: String,
81}
82
83/// Round 38 (#79) — chain metadata stored alongside a custom
84/// `ConformanceCheck`. Tells the executor how to substitute captured
85/// values into the request, how to capture values from the response,
86/// and how to repeat the check.
87#[derive(Debug, Clone, Default)]
88struct ChainMeta {
89    /// What to capture from the response on success. Empty when the
90    /// check declared no `extract` rules.
91    extract: super::custom::ExtractRules,
92    /// How many times to fire this check within one outer
93    /// `chain_iterations` pass, sequentially or in parallel.
94    repeat: super::custom::Repeat,
95}
96
97/// Round 38 (#79) — values captured during a chain run, keyed by the
98/// name the YAML asked for them under. `cookies` is kept separate so
99/// `${cookie:session}` and `${var:session}` substitute different
100/// things even if they happen to share a name.
101#[derive(Debug, Default, Clone)]
102struct ChainContext {
103    vars: HashMap<String, String>,
104    cookies: HashMap<String, String>,
105}
106
107impl ChainContext {
108    /// Substitute every `${var:NAME}`, `${cookie:NAME}`, and
109    /// `${header:NAME}` token in `text` with the corresponding
110    /// captured value. Unknown tokens are left in place verbatim so a
111    /// missing capture is obvious in the request log rather than
112    /// silently sending an empty string.
113    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                    // Round 38 — `${header:...}` aliases `${var:...}` for
127                    // captures recorded under the `headers` extraction
128                    // block. Same map, just a more readable token shape.
129                    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                    // Preserve the original `${...}` so a missing
135                    // capture is visible in failure detail / logs.
136                    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/// How to validate a conformance check response
153#[derive(Debug, Clone)]
154pub enum CheckValidation {
155    /// status >= min && status < max_exclusive
156    StatusRange { min: u16, max_exclusive: u16 },
157    /// status === code
158    ExactStatus(u16),
159    /// Schema validation: status in range + JSON body matches schema
160    SchemaValidation {
161        status_min: u16,
162        status_max: u16,
163        schema: serde_json::Value,
164    },
165    /// Custom: exact status + optional header regex + optional body field type checks
166    Custom {
167        expected_status: u16,
168        expected_headers: Vec<(String, String)>,
169        expected_body_fields: Vec<(String, String)>,
170    },
171}
172
173/// Progress event for SSE streaming
174#[derive(Debug, Clone, serde::Serialize)]
175#[serde(tag = "type")]
176pub enum ConformanceProgress {
177    /// Test run started
178    #[serde(rename = "started")]
179    Started { total_checks: usize },
180    /// A single check completed
181    #[serde(rename = "check_completed")]
182    CheckCompleted {
183        name: String,
184        passed: bool,
185        checks_done: usize,
186    },
187    /// All checks finished
188    #[serde(rename = "finished")]
189    Finished,
190    /// An error occurred
191    #[serde(rename = "error")]
192    Error { message: String },
193}
194
195/// Result of executing a single conformance check
196#[derive(Debug)]
197struct CheckResult {
198    name: String,
199    passed: bool,
200    failure_detail: Option<FailureDetail>,
201    /// Captured request/response data for --export-requests (always populated
202    /// when export_requests is enabled, regardless of pass/fail).
203    captured: Option<CapturedExchange>,
204}
205
206/// A captured HTTP exchange for the --export-requests feature
207#[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
218/// Native conformance executor using reqwest
219pub struct NativeConformanceExecutor {
220    config: ConformanceConfig,
221    client: Client,
222    checks: Vec<ConformanceCheck>,
223    /// Round 38 (#79) — per-check chain metadata. Keyed by the index
224    /// in `checks`. Entries are only present for custom checks that
225    /// declared a non-default `extract` block or `repeat` config in
226    /// the YAML.
227    chain_meta: HashMap<usize, ChainMeta>,
228    /// Round 38 (#79) — how many times to repeat the entire chain
229    /// of custom checks. Built-in spec checks always run once. Reset
230    /// to a fresh `ChainContext` at the start of each iteration so
231    /// captures from iteration K do not leak into K+1.
232    chain_iterations: u32,
233    /// Round 46 (#79) — Srikanth on 0.3.190 Q3: append-only sink for
234    /// network-level wire errors that fell short of an HTTP response
235    /// (connect refused, TLS reject, request timeout, etc). Drained
236    /// at the end of the run into `conformance-network-events.json`
237    /// so the user can see WHEN traffic stopped landing and WHY,
238    /// without having to grep through reqwest's debug logs.
239    network_events: std::sync::Mutex<Vec<NetworkEvent>>,
240}
241
242/// Round 46 (#79) — one wire-level error captured by the native
243/// executor (no HTTP response from the target). Persisted to
244/// `conformance-network-events.json` so the user has a grep-able log
245/// of when the target stopped responding and the classified reason.
246#[derive(Debug, Clone, serde::Serialize)]
247pub struct NetworkEvent {
248    /// When the failure was observed by the bench client.
249    pub timestamp: chrono::DateTime<chrono::Utc>,
250    /// Check name (e.g. `body:json:/v1/organizations` or `custom:get`).
251    pub check: String,
252    /// HTTP method we attempted.
253    pub method: String,
254    /// Full target URL (post-substitution) we tried to reach.
255    pub url: String,
256    /// Classified error kind: `connect` (TCP refused / no route),
257    /// `timeout`, `tls`, `body`, `decode`, `request`, `other`.
258    pub kind: String,
259    /// Verbatim error string from reqwest for context.
260    pub message: String,
261}
262
263impl NativeConformanceExecutor {
264    /// Create a new executor from a `ConformanceConfig`
265    pub fn new(config: ConformanceConfig) -> Result<Self> {
266        Self::with_ssrf_guard(config, crate::ssrf::process_guard())
267    }
268
269    /// Like [`Self::new`] with an explicit SSRF guard. With `Some`, the
270    /// client refuses blocked addresses at DNS time and on every redirect
271    /// hop, and ignores proxy env vars (see `ssrf::guarded_client_builder`).
272    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    /// Round 46 (#79) — push one wire-level failure into the
299    /// `conformance-network-events.json` sink. Best-effort; if the
300    /// mutex is poisoned the event is dropped silently rather than
301    /// crashing the run mid-bench.
302    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    /// Populate checks from hardcoded reference endpoints (`/conformance/*`).
309    /// Used when no `--spec` is provided.
310    #[must_use]
311    pub fn with_reference_checks(mut self) -> Self {
312        // --- Parameters ---
313        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        // --- Request Bodies ---
346        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        // --- Schema Types ---
392        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        // --- Composition ---
422        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        // --- String Formats ---
449        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        // --- Constraints ---
480        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        // --- Response Codes ---
509        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        // --- HTTP Methods ---
530        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        // --- Content Types ---
572        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        // --- Security ---
588        if self.config.should_include_category("Security") {
589            // Bearer
590            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            // API Key
606            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            // Basic auth
620            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    /// Populate checks from annotated spec operations (spec-driven mode)
643    #[must_use]
644    pub fn with_spec_driven_checks(mut self, operations: &[AnnotatedOperation]) -> Self {
645        // Track which features have been seen to deduplicate in default mode
646        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                    // All-operations mode: test every operation with path-qualified names
659                    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                    // Default mode: one representative operation per feature
664                    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    /// Load custom checks from the configured YAML file
677    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    /// Load custom checks from an already-parsed `CustomConformanceConfig`.
687    ///
688    /// Counterpart to [`Self::with_custom_checks`] for callers that
689    /// don't have a filesystem path — the cloud test-runner receives
690    /// the YAML inline on the suite config and parses it server-side
691    /// before reaching the executor, so it can't (and shouldn't) write
692    /// the bytes to a tempfile just to satisfy a file-based API.
693    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    /// Shared implementation: filter + add a parsed
701    /// `CustomConformanceConfig`'s entries onto the check list.
702    /// Pulled out of `with_custom_checks` so the inline-config and
703    /// file-based paths can't drift.
704    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        // Round 38 — propagate the chain-iteration count from the YAML
715        // into the executor. Saturates to `1` when the YAML omits the
716        // field, matching the existing single-pass behaviour.
717        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    /// Return the number of checks that will be executed
736    pub fn check_count(&self) -> usize {
737        self.checks.len()
738    }
739
740    /// Execute all checks and return a `ConformanceReport`
741    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            // Round 38 (#79) — every iteration starts with a fresh
748            // chain context so captures from iteration K do not leak
749            // into K+1. This is what Srikanth wants for repeated
750            // login + work + logout cycles.
751            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        // Write request log if --export-requests was set
765        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        // Round 46 (#79) — drain the wire-level network-events sink
802        // into `conformance-network-events.json` so the user can see
803        // every connect / timeout / TLS failure with a timestamp + the
804        // classified `kind` field. Empty file when the run had no
805        // wire failures (cleanest signal that connectivity stayed up).
806        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    /// Execute all checks with progress events sent to the channel
826    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    /// Execute a single check
872    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        // Add headers
879        for (name, value) in &check.headers {
880            request = request.header(name.as_str(), value.as_str());
881        }
882
883        // Add body
884        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                // For multipart, use the multipart API
896                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            // Round 38 (#79) — file-upload via multipart/form-data.
911            // One reqwest `Part` per declared upload, all merged into a
912            // single `Form`. `mime_str` rejects malformed Content-Types
913            // (e.g. "application / json" with spaces); fall back to
914            // octet-stream so the request still goes out and the
915            // server can decide what to do with the bytes.
916            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            // Round 38 — for `--export-requests`, write a brief
942            // multipart summary (`<N files: a.jpg (image/jpeg, 1234
943            // bytes), b.json (application/json, 56 bytes)>`) instead
944            // of the raw multipart envelope (which would be megabytes
945            // for real file uploads and not useful in a JSON capture
946            // anyway).
947            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                // Round 46 (#79) — Srikanth on 0.3.190 Q3: "Is there a
963                // way or logs in mockforge client to understand
964                // timestamp range of traffic drops and client reason
965                // for traffic drop (network reachability, duplicate
966                // ip, choking, congestion etc)". Classify the
967                // reqwest::Error into a coarse `kind` (connect /
968                // timeout / tls / decode / body / other) and push it
969                // to the run's network-events sink so the user can
970                // grep `conformance-network-events.json` for what
971                // went wrong on the wire and when.
972                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        // Capture the exchange when export_requests is on OR when any
1031        // check in this run declared chain semantics (extract /
1032        // repeat). The chain executor needs the response headers and
1033        // body to extract captured values — without `captured` the
1034        // chain context can't populate `${cookie:...}` /
1035        // `${var:...}`. Round 38 (#79).
1036        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                // Round 45 (#79) — Srikanth on 0.3.189: showed a 13MB
1043                // multipart upload getting cut mid-PEM at the 2000-byte
1044                // cap. Bump to 64KB so a single PEM cert + headers fits
1045                // inline and we still get the boundary chain for visual
1046                // confirmation. Append a marker reporting the original
1047                // size so a downstream consumer can tell a truncation
1048                // from a full payload.
1049                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                        // Round 38 — same brief summary used by the
1090                        // export path; keeps failure detail readable
1091                        // without dumping multi-MB upload bytes.
1092                        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    /// Round 38 (#79) — execute a custom check with chain semantics:
1123    /// substitute captured values from `ctx` into the request, run
1124    /// the configured repeat (sequential or parallel), and pour any
1125    /// `extract`-matched response data back into `ctx` for the next
1126    /// check to use. The first response's data is the one that
1127    /// becomes visible to extract; under `parallel` repeat the
1128    /// captures from the racing requests would be ambiguous so we
1129    /// pull only from the first to finish.
1130    ///
1131    /// Returns a vector because a `repeat.count > 1` produces N
1132    /// `CheckResult`s, all of which need to flow through the aggregate.
1133    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        // Extract from the first response. Under sequential repeat
1156        // this is the first request fired; under parallel it's the
1157        // first slot in the result array, which join_all preserves
1158        // by input order (not completion order), so the extraction
1159        // is at least deterministic.
1160        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    /// Validate a response against the check's validation rules.
1176    ///
1177    /// Returns `(passed, schema_violations)` where `schema_violations` contains
1178    /// field-level details when a `SchemaValidation` check fails.
1179    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                // Parse body as JSON and validate against schema
1200                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                                            // Extract human-readable expected value from the error.
1242                                            // The schema_path is like "/properties/field/type" —
1243                                            // extract the last meaningful segment instead.
1244                                            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                                                    // For other kinds, use last path segment
1254                                                    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                        // Schema compilation failed — fall back to is_valid behavior
1271                        (
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                // Check headers with regex
1292                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                // Check body field types
1305                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    /// Human-readable validation description for failure reports
1331    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    /// Aggregate check results into a `ConformanceReport`
1353    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    // --- Helper methods ---
1373
1374    /// Build a spec-driven check from an annotated operation and feature
1375    fn build_spec_check(
1376        &self,
1377        check_name: &str,
1378        op: &AnnotatedOperation,
1379        feature: &ConformanceFeature,
1380    ) -> ConformanceCheck {
1381        // Build URL path with parameters substituted
1382        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        // Append query params
1387        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        // Build effective headers
1394        let mut effective_headers = self.effective_headers(&op.header_params);
1395
1396        // For non-default response codes, add mock server header
1397        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        // Inject auth headers for security checks or secured endpoints
1407        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        // Determine method
1419        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        // Determine body
1431        let body = match method {
1432            Method::POST | Method::PUT | Method::PATCH => {
1433                if let Some(sample) = &op.sample_body {
1434                    // Add Content-Type if not present
1435                    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                            // Parse as form fields
1447                            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                            // Try JSON, fall back to raw
1467                            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        // Determine validation
1484        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    /// Determine validation strategy based on the conformance feature
1497    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                    // Convert openapiv3 Schema to JSON Schema value for jsonschema crate
1517                    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    /// Add a simple GET reference check with default status range validation
1538    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    /// Merge spec-derived headers with custom headers (custom overrides spec)
1553    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            // Skip if custom headers override this one
1557            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        // Append custom headers
1563        headers.extend(self.config.custom_headers.clone());
1564        headers
1565    }
1566
1567    /// Merge provided headers with custom headers
1568    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    /// Return only custom headers (for checks that don't have spec-derived headers)
1578    fn custom_headers_only(&self) -> Vec<(String, String)> {
1579        self.config.custom_headers.clone()
1580    }
1581
1582    /// Inject security headers based on resolved security schemes.
1583    /// If the user provides a Cookie header via --conformance-header, skip automatic
1584    /// Authorization headers (Bearer/Basic) since the user manages their own auth.
1585    fn inject_security_headers(
1586        &self,
1587        schemes: &[SecuritySchemeInfo],
1588        headers: &mut Vec<(String, String)>,
1589    ) {
1590        // If user provides Cookie header, they're using session-based auth — skip auto auth
1591        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                        // Handled in URL, not headers
1647                    }
1648                },
1649            }
1650        }
1651
1652        headers.extend(to_add);
1653    }
1654
1655    /// Check if a header name is present in either the existing headers or custom headers
1656    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    /// Add a custom check from YAML config
1666    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        // Build headers
1679        let mut headers: Vec<(String, String)> =
1680            check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1681        // Add global custom headers (check-specific take priority)
1682        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        // Add Content-Type for JSON body if not present
1688        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        // Body. Round 38 (#79) — `body` wins over `upload` /
1695        // `uploads` when both are set; the YAML is a misconfiguration
1696        // and we warn rather than silently picking one. When `body`
1697        // is absent and `upload` / `uploads` is set, every file is
1698        // read off disk at construction time and folded into a
1699        // `CheckBody::Multipart`. The file read is *eager* (not at
1700        // request time) so the executor's send path stays purely
1701        // synchronous to the network and a missing file is surfaced
1702        // here, not mid-run.
1703        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        // Build expected headers for validation
1752        let expected_headers: Vec<(String, String)> =
1753            check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1754
1755        // Build expected body fields
1756        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        // Round 38 (#79) — register chain metadata when the YAML
1763        // asked for capture / replay. Only non-default extract or
1764        // repeat blocks reach the chain map so the steady-state
1765        // (single-shot custom check) still skips the chain path.
1766        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        // Primary status check
1779        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
1794/// Round 38 (#79) — return a clone of `check` with every
1795/// `${var:...}` / `${cookie:...}` / `${header:...}` token in `path`,
1796/// header values, and string bodies replaced by the corresponding
1797/// captured value. Free function (not `&self`) so unit tests can
1798/// drive it directly. JSON bodies are substituted by walking each
1799/// string leaf; non-string JSON values are left untouched.
1800fn 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        // Multipart bytes are not text and are not template-targets;
1816        // pass-through unchanged so binary uploads are byte-identical
1817        // across iterations.
1818        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
1844/// Round 38 (#79) — read captured cookies / headers / body fields off
1845/// a response and store them in the chain context for subsequent
1846/// requests to reference. Unknown extractions (e.g. a cookie name the
1847/// server didn't set) are silently skipped so a partially-met
1848/// extract block doesn't fail the whole chain.
1849fn extract_into_context(
1850    rules: &super::custom::ExtractRules,
1851    response_headers: &HashMap<String, String>,
1852    response_body: &str,
1853    ctx: &mut ChainContext,
1854) {
1855    // Cookies: every Set-Cookie header is parsed for `name=value`
1856    // (the cookie's own attributes after the value are dropped).
1857    // Multi-Set-Cookie responses (different `Set-Cookie` values
1858    // each with the same header name) are coalesced into one
1859    // comma-separated string when reqwest collects the headers,
1860    // so we split on commas and try each candidate.
1861    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            // Each Set-Cookie entry looks like `name=value; attr=...`.
1868            // Multiple cookies in one header are comma-separated.
1869            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    // Headers: case-insensitive lookup.
1881    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    // Body fields via simple dotted lookup. Empty / non-JSON bodies
1889    // simply contribute nothing rather than failing the chain.
1890    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
1916/// Convert an `openapiv3::Schema` to a JSON Schema `serde_json::Value`
1917/// suitable for use with the `jsonschema` crate.
1918fn 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    /// Round 38 (#79) — substitution replaces `${var:...}`,
1980    /// `${cookie:...}`, and `${header:...}` tokens with the matching
1981    /// captured value. Unknown tokens are preserved verbatim so a
1982    /// missing capture is visible in the request log.
1983    #[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        // header: aliases var: so the same map is used.
1993        assert_eq!(ctx.substitute("X-Trace: ${header:trace}"), "X-Trace: xyz");
1994        // Unknown name preserved verbatim (no silent empty string).
1995        assert_eq!(ctx.substitute("missing: ${var:nope}"), "missing: ${var:nope}");
1996    }
1997
1998    /// Round 38 — extraction pulls cookies, headers, and body fields
1999    /// off a response and stores them in the chain context under the
2000    /// caller-named keys.
2001    #[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        // Numeric body field stringifies to "42".
2018        assert_eq!(ctx.vars.get("user_id").map(|s| s.as_str()), Some("42"));
2019    }
2020
2021    /// Round 38 — a missing cookie name doesn't poison subsequent
2022    /// extractions; the cookie pull silently no-ops and the header /
2023    /// body extractions still happen.
2024    #[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    /// Round 38 — substitution into a ConformanceCheck flows through
2039    /// path, headers, raw bodies, JSON bodies, and form-urlencoded
2040    /// fields. Multipart bodies are pass-through (binary uploads
2041    /// shouldn't be touched by string templating).
2042    #[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        // 7 params + 3 bodies + 6 schema + 3 composition + 7 formats + 5 constraints
2075        // + 5 response codes + 7 methods + 1 content + 3 security = 47
2076        assert_eq!(executor.check_count(), 47);
2077    }
2078
2079    #[test]
2080    fn with_custom_checks_from_config_appends() {
2081        // Construct an inline CustomConformanceConfig and verify both
2082        // that the checks are appended on top of reference-checks
2083        // mode and that the filter regex (when present) drops
2084        // non-matching entries.
2085        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        // 47 reference checks + 2 custom = 49.
2110        assert_eq!(executor.check_count(), 49);
2111    }
2112
2113    #[test]
2114    fn with_custom_checks_from_config_respects_filter() {
2115        // custom_filter is regex-based; only matching entries should
2116        // make it onto the executor.
2117        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            // Reference checks would add 47; turn them off so the
2134            // count is purely the custom set after filtering.
2135            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        // categories filter drops all reference checks; custom_filter
2145        // keeps the one entry whose name matches /health/.
2146        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        // Missing required field
2279        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        // Wrong status
2323        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        // A name resolving to an internal address (stand-in for a Compose
2434        // service name or a rebinding domain).
2435        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        // A redirect inward.
2443        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        // Control: the unguarded client (the CLI) follows it.
2457        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}