Skip to main content

pi/
vcr.rs

1//! VCR-style recording for HTTP streaming tests.
2//!
3//! This module provides utilities to record and replay real HTTP streaming
4//! responses (e.g., SSE) for deterministic provider tests.
5
6use crate::error::{Error, Result};
7use base64::Engine;
8use base64::engine::general_purpose::STANDARD;
9use chrono::{SecondsFormat, Utc};
10use futures::StreamExt;
11use futures::stream::{self, BoxStream};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use sha2::{Digest, Sha256};
15#[cfg(test)]
16use std::collections::HashMap;
17use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicUsize, Ordering};
21#[cfg(test)]
22use std::sync::{Mutex, OnceLock};
23use tracing::{debug, info, warn};
24
25pub const VCR_ENV_MODE: &str = "VCR_MODE";
26pub const VCR_ENV_DIR: &str = "VCR_CASSETTE_DIR";
27pub const DEFAULT_CASSETTE_DIR: &str = "tests/fixtures/vcr";
28const CASSETTE_VERSION: &str = "1.0";
29const REDACTED: &str = "[REDACTED]";
30
31#[derive(Debug, Clone, Copy, Default)]
32pub struct RedactionSummary {
33    pub headers_redacted: usize,
34    pub json_fields_redacted: usize,
35}
36
37/// Map from env var name to override value. `Some(val)` overrides to that value,
38/// `None` means the var is explicitly unset (tombstone), preventing fallthrough
39/// to the real process environment.
40#[cfg(test)]
41static TEST_ENV_OVERRIDES: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
42
43#[cfg(test)]
44fn test_env_overrides() -> &'static Mutex<HashMap<String, Option<String>>> {
45    TEST_ENV_OVERRIDES.get_or_init(|| Mutex::new(HashMap::new()))
46}
47
48#[cfg(test)]
49fn test_env_var_with<F>(
50    overrides: &Mutex<HashMap<String, Option<String>>>,
51    name: &str,
52    fallback: F,
53) -> Option<String>
54where
55    F: FnOnce() -> Option<String>,
56{
57    let maybe_value = {
58        let guard = overrides
59            .lock()
60            .unwrap_or_else(std::sync::PoisonError::into_inner);
61        guard.get(name).cloned()
62    };
63    if let Some(maybe_value) = maybe_value {
64        return maybe_value;
65    }
66    fallback()
67}
68
69#[cfg(test)]
70#[derive(Debug, Clone, PartialEq, Eq)]
71enum TestEnvOverrideSnapshot {
72    Absent,
73    Unset,
74    Value(String),
75}
76
77#[cfg(test)]
78fn env_var(name: &str) -> Option<String> {
79    test_env_var_with(test_env_overrides(), name, || std::env::var(name).ok())
80}
81
82#[cfg(not(test))]
83fn env_var(name: &str) -> Option<String> {
84    std::env::var(name).ok()
85}
86
87#[cfg(test)]
88fn set_test_env_var(name: &str, value: Option<&str>) -> TestEnvOverrideSnapshot {
89    let mut guard = test_env_overrides()
90        .lock()
91        .unwrap_or_else(std::sync::PoisonError::into_inner);
92    let previous = match guard.get(name) {
93        Some(Some(previous)) => TestEnvOverrideSnapshot::Value(previous.clone()),
94        Some(None) => TestEnvOverrideSnapshot::Unset,
95        None => TestEnvOverrideSnapshot::Absent,
96    };
97    // Store Some(val) for override or None as tombstone (explicitly unset)
98    guard.insert(name.to_string(), value.map(String::from));
99    previous
100}
101
102#[cfg(test)]
103fn restore_test_env_var(name: &str, previous: TestEnvOverrideSnapshot) {
104    let mut guard = test_env_overrides()
105        .lock()
106        .unwrap_or_else(std::sync::PoisonError::into_inner);
107    match previous {
108        TestEnvOverrideSnapshot::Value(value) => {
109            guard.insert(name.to_string(), Some(value));
110        }
111        TestEnvOverrideSnapshot::Unset => {
112            guard.insert(name.to_string(), None);
113        }
114        TestEnvOverrideSnapshot::Absent => {
115            // Remove the override entirely (go back to real env)
116            guard.remove(name);
117        }
118    }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "lowercase")]
123pub enum VcrMode {
124    Record,
125    Playback,
126    Auto,
127}
128
129impl VcrMode {
130    pub fn from_env() -> Result<Option<Self>> {
131        let Some(value) = env_var(VCR_ENV_MODE) else {
132            return Ok(None);
133        };
134        let mode = match value.to_ascii_lowercase().as_str() {
135            "record" => Self::Record,
136            "playback" => Self::Playback,
137            "auto" => Self::Auto,
138            _ => {
139                return Err(Error::config(format!(
140                    "Invalid {VCR_ENV_MODE} value: {value}"
141                )));
142            }
143        };
144        Ok(Some(mode))
145    }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct Cassette {
150    pub version: String,
151    pub test_name: String,
152    pub recorded_at: String,
153    pub interactions: Vec<Interaction>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct Interaction {
158    pub request: RecordedRequest,
159    pub response: RecordedResponse,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct RecordedRequest {
164    pub method: String,
165    pub url: String,
166    pub headers: Vec<(String, String)>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub body: Option<Value>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub body_text: Option<String>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct RecordedResponse {
175    pub status: u16,
176    pub headers: Vec<(String, String)>,
177    pub body_chunks: Vec<String>,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub body_chunks_base64: Option<Vec<String>>,
180}
181
182impl RecordedResponse {
183    pub fn into_byte_stream(
184        self,
185    ) -> BoxStream<'static, std::result::Result<Vec<u8>, std::io::Error>> {
186        if let Some(chunks) = self.body_chunks_base64 {
187            stream::iter(chunks.into_iter().map(|chunk| {
188                STANDARD
189                    .decode(chunk)
190                    .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
191            }))
192            .boxed()
193        } else {
194            stream::iter(
195                self.body_chunks
196                    .into_iter()
197                    .map(|chunk| Ok(chunk.into_bytes())),
198            )
199            .boxed()
200        }
201    }
202}
203
204#[derive(Debug, Clone)]
205pub struct VcrRecorder {
206    cassette_path: PathBuf,
207    mode: VcrMode,
208    test_name: String,
209    playback_cursor: Arc<AtomicUsize>,
210}
211
212impl VcrRecorder {
213    pub fn new(test_name: &str) -> Result<Self> {
214        let mode = VcrMode::from_env()?.unwrap_or_else(default_mode);
215        let cassette_dir =
216            env_var(VCR_ENV_DIR).map_or_else(|| PathBuf::from(DEFAULT_CASSETTE_DIR), PathBuf::from);
217        let cassette_name = sanitize_test_name(test_name);
218        let cassette_path = cassette_dir.join(format!("{cassette_name}.json"));
219        let recorder = Self {
220            cassette_path,
221            mode,
222            test_name: test_name.to_string(),
223            playback_cursor: Arc::new(AtomicUsize::new(0)),
224        };
225        info!(
226            mode = ?recorder.mode,
227            cassette_path = %recorder.cassette_path.display(),
228            test_name = %recorder.test_name,
229            "VCR recorder initialized"
230        );
231        Ok(recorder)
232    }
233
234    pub fn new_with(test_name: &str, mode: VcrMode, cassette_dir: impl AsRef<Path>) -> Self {
235        let cassette_name = sanitize_test_name(test_name);
236        let cassette_path = cassette_dir.as_ref().join(format!("{cassette_name}.json"));
237        Self {
238            cassette_path,
239            mode,
240            test_name: test_name.to_string(),
241            playback_cursor: Arc::new(AtomicUsize::new(0)),
242        }
243    }
244
245    pub const fn mode(&self) -> VcrMode {
246        self.mode
247    }
248
249    pub fn cassette_path(&self) -> &Path {
250        &self.cassette_path
251    }
252
253    pub async fn request_streaming_with<F, Fut, S>(
254        &self,
255        request: RecordedRequest,
256        send: F,
257    ) -> Result<RecordedResponse>
258    where
259        F: FnOnce() -> Fut,
260        Fut: std::future::Future<Output = Result<(u16, Vec<(String, String)>, S)>>,
261        S: futures::Stream<Item = std::result::Result<Vec<u8>, std::io::Error>> + Unpin,
262    {
263        let request_key = request_debug_key(&request);
264
265        match self.mode {
266            VcrMode::Playback => {
267                info!(
268                    cassette_path = %self.cassette_path.display(),
269                    request = %request_key,
270                    "VCR playback request"
271                );
272                self.playback(&request)
273            }
274            VcrMode::Record => {
275                info!(
276                    cassette_path = %self.cassette_path.display(),
277                    request = %request_key,
278                    "VCR recording request"
279                );
280                self.record_streaming_with(request, send).await
281            }
282            VcrMode::Auto => {
283                if self.cassette_path.exists() {
284                    info!(
285                        cassette_path = %self.cassette_path.display(),
286                        request = %request_key,
287                        "VCR auto mode: cassette exists, using playback"
288                    );
289                    self.playback(&request)
290                } else {
291                    info!(
292                        cassette_path = %self.cassette_path.display(),
293                        request = %request_key,
294                        "VCR auto mode: cassette missing, recording"
295                    );
296                    self.record_streaming_with(request, send).await
297                }
298            }
299        }
300    }
301
302    pub async fn record_streaming_with<F, Fut, S>(
303        &self,
304        request: RecordedRequest,
305        send: F,
306    ) -> Result<RecordedResponse>
307    where
308        F: FnOnce() -> Fut,
309        Fut: std::future::Future<Output = Result<(u16, Vec<(String, String)>, S)>>,
310        S: futures::Stream<Item = std::result::Result<Vec<u8>, std::io::Error>> + Unpin,
311    {
312        debug!(
313            cassette_path = %self.cassette_path.display(),
314            request = %request_debug_key(&request),
315            "VCR record: sending streaming HTTP request"
316        );
317        let (status, headers, mut stream) = send().await?;
318
319        let mut body_chunks = Vec::new();
320        let mut body_chunks_base64: Option<Vec<String>> = None;
321        let mut body_bytes = 0usize;
322        while let Some(chunk) = stream.next().await {
323            let chunk = chunk.map_err(|e| Error::api(format!("HTTP stream read failed: {e}")))?;
324            if chunk.is_empty() {
325                continue;
326            }
327            body_bytes = body_bytes.saturating_add(chunk.len());
328            if let Some(encoded) = body_chunks_base64.as_mut() {
329                encoded.push(STANDARD.encode(&chunk));
330            } else if let Ok(text) = std::str::from_utf8(&chunk) {
331                body_chunks.push(text.to_string());
332            } else {
333                let mut encoded = Vec::with_capacity(body_chunks.len() + 1);
334                for existing in &body_chunks {
335                    encoded.push(STANDARD.encode(existing.as_bytes()));
336                }
337                encoded.push(STANDARD.encode(&chunk));
338                body_chunks.clear();
339                body_chunks_base64 = Some(encoded);
340            }
341        }
342
343        let recorded = RecordedResponse {
344            status,
345            headers,
346            body_chunks,
347            body_chunks_base64,
348        };
349        let chunk_count = recorded
350            .body_chunks_base64
351            .as_ref()
352            .map_or(recorded.body_chunks.len(), Vec::len);
353
354        info!(
355            cassette_path = %self.cassette_path.display(),
356            status = recorded.status,
357            header_count = recorded.headers.len(),
358            chunk_count,
359            body_bytes,
360            "VCR record: captured streaming response"
361        );
362
363        let mut cassette = if self.cassette_path.exists() {
364            load_cassette(&self.cassette_path)?
365        } else {
366            Cassette {
367                version: CASSETTE_VERSION.to_string(),
368                test_name: self.test_name.clone(),
369                recorded_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
370                interactions: Vec::new(),
371            }
372        };
373        cassette.test_name.clone_from(&self.test_name);
374        cassette.recorded_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
375        cassette.interactions.push(Interaction {
376            request,
377            response: recorded.clone(),
378        });
379
380        let redaction = redact_cassette(&mut cassette);
381        info!(
382            cassette_path = %self.cassette_path.display(),
383            headers_redacted = redaction.headers_redacted,
384            json_fields_redacted = redaction.json_fields_redacted,
385            "VCR record: redacted sensitive data"
386        );
387        save_cassette(&self.cassette_path, &cassette)?;
388        info!(
389            cassette_path = %self.cassette_path.display(),
390            "VCR record: saved cassette"
391        );
392
393        Ok(recorded)
394    }
395
396    fn playback(&self, request: &RecordedRequest) -> Result<RecordedResponse> {
397        let cassette = load_cassette(&self.cassette_path)?;
398        let start_index = self.playback_cursor.load(Ordering::SeqCst);
399        let Some((matched_index, interaction)) =
400            find_interaction_from(&cassette, request, start_index)
401        else {
402            return Err(playback_no_match_error(
403                &self.cassette_path,
404                request,
405                &cassette,
406                start_index,
407            ));
408        };
409
410        info!(
411            cassette_path = %self.cassette_path.display(),
412            request = %request_debug_key(request),
413            "VCR playback: matched interaction"
414        );
415        self.playback_cursor
416            .store(matched_index + 1, Ordering::SeqCst);
417        Ok(interaction.response.clone())
418    }
419}
420
421fn playback_no_match_error(
422    cassette_path: &Path,
423    request: &RecordedRequest,
424    cassette: &Cassette,
425    start_index: usize,
426) -> Error {
427    let incoming_key = request_debug_key(request);
428    let recorded_keys = recorded_request_keys(cassette);
429
430    warn!(
431        cassette_path = %cassette_path.display(),
432        request = %incoming_key,
433        recorded_count = recorded_keys.len(),
434        start_index,
435        "VCR playback: no matching interaction"
436    );
437
438    maybe_write_debug_body_file(request, cassette);
439    let mut message = playback_no_match_message(cassette_path, &incoming_key, &recorded_keys);
440    if env_truthy("VCR_DEBUG_BODY") {
441        append_request_debug_details(&mut message, request, cassette);
442    }
443    message.push_str(
444        "Match criteria: method + url + body + body_text (headers ignored). If the request changed, re-record with VCR_MODE=record.",
445    );
446    Error::config(message)
447}
448
449fn recorded_request_keys(cassette: &Cassette) -> Vec<String> {
450    cassette
451        .interactions
452        .iter()
453        .enumerate()
454        .map(|(idx, interaction)| format!("[{idx}] {}", request_debug_key(&interaction.request)))
455        .collect()
456}
457
458fn playback_no_match_message(
459    cassette_path: &Path,
460    incoming_key: &str,
461    recorded_keys: &[String],
462) -> String {
463    let mut message = format!(
464        "No matching interaction found in cassette {}.\nIncoming: {incoming_key}\nRecorded interactions ({}):\n",
465        cassette_path.display(),
466        recorded_keys.len()
467    );
468    for key in recorded_keys {
469        message.push_str("  ");
470        message.push_str(key);
471        message.push('\n');
472    }
473    message
474}
475
476fn maybe_write_debug_body_file(request: &RecordedRequest, cassette: &Cassette) {
477    let Ok(debug_path) = std::env::var("VCR_DEBUG_BODY_FILE") else {
478        return;
479    };
480
481    let mut debug = String::new();
482    append_request_debug_block(
483        &mut debug,
484        "INCOMING (redacted)",
485        "INCOMING TEXT (redacted)",
486        request,
487        false,
488    );
489    for (idx, interaction) in cassette.interactions.iter().enumerate() {
490        append_request_debug_block(
491            &mut debug,
492            &format!("RECORDED [{idx}]"),
493            &format!("RECORDED TEXT [{idx}]"),
494            &interaction.request,
495            false,
496        );
497    }
498    let _ = std::fs::write(&debug_path, debug);
499}
500
501fn append_request_debug_details(
502    message: &mut String,
503    request: &RecordedRequest,
504    cassette: &Cassette,
505) {
506    use std::fmt::Write as _;
507
508    append_request_debug_block(
509        message,
510        "Incoming JSON body (redacted)",
511        "Incoming text body",
512        request,
513        true,
514    );
515    for (idx, interaction) in cassette.interactions.iter().enumerate() {
516        let _ = writeln!(message);
517        append_request_debug_block(
518            message,
519            &format!("Recorded JSON body [{idx}]"),
520            &format!("Recorded text body [{idx}]"),
521            &interaction.request,
522            true,
523        );
524    }
525}
526
527fn append_request_debug_block(
528    out: &mut String,
529    json_heading: &str,
530    text_heading: &str,
531    request: &RecordedRequest,
532    inline_headings: bool,
533) {
534    use std::fmt::Write as _;
535
536    if let Some(pretty) = pretty_redacted_json_body(request) {
537        if inline_headings {
538            let _ = writeln!(out, "\n{json_heading}:");
539        } else {
540            let _ = writeln!(out, "=== {json_heading} ===");
541        }
542        out.push_str(&pretty);
543        out.push('\n');
544    }
545
546    if let Some(body_text) = &request.body_text {
547        let redacted = normalize_body_text_for_matching(&request.headers, body_text);
548        if inline_headings {
549            let _ = writeln!(out, "\n{text_heading}:");
550        } else {
551            let _ = writeln!(out, "=== {text_heading} ===");
552        }
553        out.push_str(&redacted);
554        out.push('\n');
555    }
556}
557
558fn pretty_redacted_json_body(request: &RecordedRequest) -> Option<String> {
559    let mut body = request.body.clone()?;
560    redact_json(&mut body);
561    serde_json::to_string_pretty(&body).ok()
562}
563
564fn default_mode() -> VcrMode {
565    if env_truthy("CI") {
566        VcrMode::Playback
567    } else {
568        VcrMode::Auto
569    }
570}
571
572fn env_truthy(name: &str) -> bool {
573    env_var(name).is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
574}
575
576fn sanitize_test_name(value: &str) -> String {
577    let mut out = String::with_capacity(value.len());
578    for ch in value.chars() {
579        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
580            out.push(ch);
581        } else {
582            out.push('_');
583        }
584    }
585    if out.is_empty() {
586        "vcr".to_string()
587    } else {
588        out
589    }
590}
591
592fn load_cassette(path: &Path) -> Result<Cassette> {
593    let content = std::fs::read_to_string(path)
594        .map_err(|e| Error::config(format!("Failed to read cassette {}: {e}", path.display())))?;
595    let cassette: Cassette = serde_json::from_str(&content)
596        .map_err(|e| Error::config(format!("Failed to parse cassette {}: {e}", path.display())))?;
597    if cassette.version != CASSETTE_VERSION {
598        return Err(Error::config(format!(
599            "Cassette {} has version {:?}, expected {:?}",
600            path.display(),
601            cassette.version,
602            CASSETTE_VERSION,
603        )));
604    }
605    Ok(cassette)
606}
607
608fn save_cassette(path: &Path, cassette: &Cassette) -> Result<()> {
609    if let Some(parent) = path.parent() {
610        std::fs::create_dir_all(parent).map_err(|e| {
611            Error::config(format!(
612                "Failed to create cassette dir {}: {e}",
613                parent.display()
614            ))
615        })?;
616    }
617    let content = serde_json::to_string_pretty(cassette)
618        .map_err(|e| Error::config(format!("Failed to serialize cassette: {e}")))?;
619    std::fs::write(path, content)
620        .map_err(|e| Error::config(format!("Failed to write cassette {}: {e}", path.display())))?;
621    Ok(())
622}
623
624fn find_interaction_from<'a>(
625    cassette: &'a Cassette,
626    request: &RecordedRequest,
627    start: usize,
628) -> Option<(usize, &'a Interaction)> {
629    cassette
630        .interactions
631        .iter()
632        .enumerate()
633        .skip(start)
634        .find(|(_, interaction)| request_matches(&interaction.request, request))
635}
636
637fn request_debug_key(request: &RecordedRequest) -> String {
638    use std::fmt::Write as _;
639
640    let method = request.method.to_ascii_uppercase();
641    let mut out = format!("{method} {}", request.url);
642
643    if let Some(body) = &request.body {
644        let body_bytes = serde_json::to_vec(body).unwrap_or_default();
645        let hash = short_sha256(&body_bytes);
646        let _ = write!(out, " body_sha256={hash}");
647    } else {
648        out.push_str(" body_sha256=<none>");
649    }
650
651    if let Some(body_text) = &request.body_text {
652        let normalized = normalize_body_text_for_matching(&request.headers, body_text);
653        let hash = short_sha256(normalized.as_bytes());
654        let _ = write!(
655            out,
656            " body_text_sha256={hash} body_text_len={}",
657            normalized.len()
658        );
659    } else {
660        out.push_str(" body_text_sha256=<none>");
661    }
662
663    out
664}
665
666fn short_sha256(bytes: &[u8]) -> String {
667    use std::fmt::Write as _;
668
669    let digest = Sha256::digest(bytes);
670    let mut out = String::with_capacity(12);
671    for b in &digest[..6] {
672        let _ = write!(out, "{b:02x}");
673    }
674    out
675}
676
677fn request_matches(recorded: &RecordedRequest, incoming: &RecordedRequest) -> bool {
678    if !recorded.method.eq_ignore_ascii_case(&incoming.method) {
679        return false;
680    }
681    if recorded.url != incoming.url {
682        return false;
683    }
684
685    // Redact incoming body to match recorded body state (which is always redacted)
686    let mut incoming_body = incoming.body.clone();
687    if let Some(body) = &mut incoming_body {
688        redact_json(body);
689    }
690
691    if !match_optional_json(recorded.body.as_ref(), incoming_body.as_ref()) {
692        return false;
693    }
694
695    // Treat a missing recorded `body_text` as a wildcard. This is useful for
696    // tests where the JSON body is dynamic (paths, timestamps, etc.) and the
697    // cassette only wants to constrain method+URL (and optionally structured JSON).
698    if let Some(recorded_text) = recorded.body_text.as_ref() {
699        let recorded_text = normalize_body_text_for_matching(&recorded.headers, recorded_text);
700        let incoming_text = incoming
701            .body_text
702            .as_deref()
703            .map(|text| normalize_body_text_for_matching(&incoming.headers, text));
704        if incoming_text.as_deref() != Some(recorded_text.as_str()) {
705            return false;
706        }
707    }
708
709    true
710}
711
712fn match_optional_json(recorded: Option<&Value>, incoming: Option<&Value>) -> bool {
713    let Some(recorded) = recorded else {
714        // Cassette does not constrain JSON body.
715        return true;
716    };
717    let Some(incoming) = incoming else {
718        return false;
719    };
720    match_json_template(recorded, incoming)
721}
722
723/// Match a recorded JSON "template" against an incoming JSON value.
724///
725/// Semantics:
726/// - Objects: recorded keys must match; incoming may have extra keys.
727///   - A recorded key with `null` matches both missing and `null` incoming keys.
728/// - Arrays: strict length + element matching (order-sensitive).
729/// - Scalars: strict equality.
730fn match_json_template(recorded: &Value, incoming: &Value) -> bool {
731    match (recorded, incoming) {
732        (Value::Object(recorded_obj), Value::Object(incoming_obj)) => {
733            for (key, recorded_value) in recorded_obj {
734                match incoming_obj.get(key) {
735                    Some(incoming_value) => {
736                        if !match_json_template(recorded_value, incoming_value) {
737                            return false;
738                        }
739                    }
740                    None => {
741                        if !recorded_value.is_null() {
742                            return false;
743                        }
744                    }
745                }
746            }
747            true
748        }
749        (Value::Array(recorded_items), Value::Array(incoming_items)) => {
750            if recorded_items.len() != incoming_items.len() {
751                return false;
752            }
753            recorded_items
754                .iter()
755                .zip(incoming_items)
756                .all(|(left, right)| match_json_template(left, right))
757        }
758        _ => recorded == incoming,
759    }
760}
761
762pub fn redact_cassette(cassette: &mut Cassette) -> RedactionSummary {
763    let sensitive_headers = sensitive_header_keys();
764    let mut summary = RedactionSummary::default();
765    for interaction in &mut cassette.interactions {
766        summary.headers_redacted +=
767            redact_headers(&mut interaction.request.headers, &sensitive_headers);
768        summary.headers_redacted +=
769            redact_headers(&mut interaction.response.headers, &sensitive_headers);
770        if let Some(body) = &mut interaction.request.body {
771            summary.json_fields_redacted += redact_json(body);
772        }
773        if let Some(body_text) = interaction.request.body_text.as_deref() {
774            interaction.request.body_text = Some(normalize_body_text_for_matching(
775                &interaction.request.headers,
776                body_text,
777            ));
778        }
779    }
780    summary
781}
782
783fn sensitive_header_keys() -> HashSet<String> {
784    [
785        "authorization",
786        "x-api-key",
787        "api-key",
788        "x-goog-api-key",
789        "x-azure-api-key",
790        "proxy-authorization",
791    ]
792    .iter()
793    .map(ToString::to_string)
794    .collect()
795}
796
797fn redact_headers(headers: &mut Vec<(String, String)>, sensitive: &HashSet<String>) -> usize {
798    let mut count = 0usize;
799    for (name, value) in headers {
800        if sensitive.contains(&name.to_ascii_lowercase()) {
801            count += 1;
802            *value = REDACTED.to_string();
803        }
804    }
805    count
806}
807
808fn redact_json(value: &mut Value) -> usize {
809    match value {
810        Value::Object(map) => {
811            let mut count = 0usize;
812            for (key, entry) in map.iter_mut() {
813                if is_sensitive_key(key) {
814                    *entry = Value::String(REDACTED.to_string());
815                    count += 1;
816                } else {
817                    count += redact_json(entry);
818                }
819            }
820            count
821        }
822        Value::Array(items) => {
823            let mut count = 0usize;
824            for item in items {
825                count += redact_json(item);
826            }
827            count
828        }
829        _ => 0usize,
830    }
831}
832
833fn normalize_body_text_for_matching(headers: &[(String, String)], body_text: &str) -> String {
834    if let Some(redacted) = redact_json_body_text(body_text) {
835        return redacted;
836    }
837
838    if is_form_body_content_type(headers) || looks_like_form_body_text(body_text) {
839        return redact_form_body_text(body_text);
840    }
841
842    body_text.to_string()
843}
844
845fn redact_json_body_text(body_text: &str) -> Option<String> {
846    let mut value: Value = serde_json::from_str(body_text.trim()).ok()?;
847    redact_json(&mut value);
848    serde_json::to_string(&value).ok()
849}
850
851fn is_form_body_content_type(headers: &[(String, String)]) -> bool {
852    headers.iter().any(|(name, value)| {
853        name.eq_ignore_ascii_case("content-type")
854            && value
855                .split_once(';')
856                .map_or(value.as_str(), |(media_type, _)| media_type)
857                .trim()
858                .eq_ignore_ascii_case("application/x-www-form-urlencoded")
859    })
860}
861
862fn looks_like_form_body_text(body_text: &str) -> bool {
863    if body_text.is_empty() || body_text.contains('\n') {
864        return false;
865    }
866
867    let mut pair_count = 0usize;
868    let mut first_key_is_sensitive = false;
869    for segment in body_text.split('&') {
870        let Some((key, _)) = segment.split_once('=') else {
871            return false;
872        };
873        if key.is_empty() {
874            return false;
875        }
876        if pair_count == 0 {
877            first_key_is_sensitive = is_sensitive_form_key(key);
878        }
879        pair_count += 1;
880    }
881
882    pair_count > 1 || first_key_is_sensitive
883}
884
885fn is_sensitive_form_key(raw_key: &str) -> bool {
886    if is_sensitive_key(raw_key) {
887        return true;
888    }
889
890    let mut encoded = String::with_capacity(raw_key.len() + 1);
891    encoded.push_str(raw_key);
892    encoded.push('=');
893    url::form_urlencoded::parse(encoded.as_bytes())
894        .next()
895        .is_some_and(|(decoded_key, _)| is_sensitive_key(&decoded_key))
896}
897
898fn redact_form_body_text(body_text: &str) -> String {
899    let mut serializer = url::form_urlencoded::Serializer::new(String::new());
900    for (key, value) in url::form_urlencoded::parse(body_text.as_bytes()) {
901        if is_sensitive_key(&key) {
902            serializer.append_pair(&key, REDACTED);
903        } else {
904            serializer.append_pair(&key, &value);
905        }
906    }
907    serializer.finish()
908}
909
910fn is_sensitive_key(key: &str) -> bool {
911    let key = key.to_ascii_lowercase();
912    key.contains("api_key")
913        || key.contains("apikey")
914        || key.contains("authorization")
915        // "token" is sensitive when it refers to auth tokens (access_token, id_token, etc),
916        // but many APIs also use fields like "max_tokens"/"prompt_tokens" which are just counts.
917        // Redacting those breaks matching with existing cassettes and is not necessary.
918        || ((key.contains("token") && !key.contains("tokens"))
919            || key.contains("access_tokens")
920            || key.contains("refresh_tokens")
921            || key.contains("id_tokens"))
922        || key.contains("secret")
923        || key.contains("password")
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929    use std::future::Future;
930    use std::sync::{Mutex, OnceLock};
931
932    type ByteStream = BoxStream<'static, std::result::Result<Vec<u8>, std::io::Error>>;
933
934    fn env_test_lock() -> &'static Mutex<()> {
935        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
936        LOCK.get_or_init(|| Mutex::new(()))
937    }
938
939    /// Acquire `env_test_lock`, recovering from poison so that one
940    /// test-thread panic doesn't cascade into every other env-test.
941    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
942        env_test_lock()
943            .lock()
944            .unwrap_or_else(std::sync::PoisonError::into_inner)
945    }
946
947    #[test]
948    fn cassette_round_trip() {
949        let cassette = Cassette {
950            version: CASSETTE_VERSION.to_string(),
951            test_name: "round_trip".to_string(),
952            recorded_at: "2026-02-03T00:00:00.000Z".to_string(),
953            interactions: vec![Interaction {
954                request: RecordedRequest {
955                    method: "POST".to_string(),
956                    url: "https://example.com".to_string(),
957                    headers: vec![("authorization".to_string(), "secret".to_string())],
958                    body: Some(serde_json::json!({"prompt": "hello"})),
959                    body_text: None,
960                },
961                response: RecordedResponse {
962                    status: 200,
963                    headers: vec![("x-api-key".to_string(), "secret".to_string())],
964                    body_chunks: vec!["event: message\n\n".to_string()],
965                    body_chunks_base64: None,
966                },
967            }],
968        };
969
970        let serialized = serde_json::to_string(&cassette).expect("serialize cassette");
971        let parsed: Cassette = serde_json::from_str(&serialized).expect("parse cassette");
972        assert_eq!(parsed.version, CASSETTE_VERSION);
973        assert_eq!(parsed.test_name, "round_trip");
974        assert_eq!(parsed.interactions.len(), 1);
975    }
976
977    #[test]
978    fn matches_interaction_on_method_url_body() {
979        let recorded = RecordedRequest {
980            method: "POST".to_string(),
981            url: "https://example.com".to_string(),
982            headers: vec![],
983            body: Some(serde_json::json!({"a": 1})),
984            body_text: None,
985        };
986        let incoming = RecordedRequest {
987            method: "post".to_string(),
988            url: "https://example.com".to_string(),
989            headers: vec![("x-api-key".to_string(), "secret".to_string())],
990            body: Some(serde_json::json!({"a": 1})),
991            body_text: None,
992        };
993        assert!(request_matches(&recorded, &incoming));
994    }
995
996    #[test]
997    fn oauth_refresh_invalid_matches_after_redaction() {
998        let cassette_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
999            .join("tests/fixtures/vcr/oauth_refresh_invalid.json");
1000        let cassette = load_cassette(&cassette_path).expect("load cassette");
1001        let recorded = &cassette.interactions.first().expect("interaction").request;
1002        let recorded_body = recorded.body.as_ref().expect("recorded body");
1003        let client_id = recorded_body
1004            .get("client_id")
1005            .and_then(serde_json::Value::as_str)
1006            .expect("client_id string");
1007
1008        let incoming = RecordedRequest {
1009            method: "POST".to_string(),
1010            url: recorded.url.clone(),
1011            headers: Vec::new(),
1012            body: Some(serde_json::json!({
1013                "grant_type": "refresh_token",
1014                "client_id": client_id,
1015                "refresh_token": "refresh-invalid",
1016            })),
1017            body_text: None,
1018        };
1019
1020        assert!(request_matches(recorded, &incoming));
1021    }
1022
1023    #[test]
1024    fn redacts_sensitive_headers_and_body_fields() {
1025        let mut cassette = Cassette {
1026            version: CASSETTE_VERSION.to_string(),
1027            test_name: "redact".to_string(),
1028            recorded_at: "2026-02-03T00:00:00.000Z".to_string(),
1029            interactions: vec![Interaction {
1030                request: RecordedRequest {
1031                    method: "POST".to_string(),
1032                    url: "https://example.com".to_string(),
1033                    headers: vec![("Authorization".to_string(), "secret".to_string())],
1034                    body: Some(serde_json::json!({"api_key": "secret", "nested": {"token": "t"}})),
1035                    body_text: None,
1036                },
1037                response: RecordedResponse {
1038                    status: 200,
1039                    headers: vec![("x-api-key".to_string(), "secret".to_string())],
1040                    body_chunks: vec![],
1041                    body_chunks_base64: None,
1042                },
1043            }],
1044        };
1045
1046        let summary = redact_cassette(&mut cassette);
1047
1048        let request = &cassette.interactions[0].request;
1049        assert_eq!(request.headers[0].1, REDACTED);
1050        let body = request.body.as_ref().expect("body exists");
1051        assert_eq!(body["api_key"], REDACTED);
1052        assert_eq!(body["nested"]["token"], REDACTED);
1053        assert_eq!(summary.headers_redacted, 2);
1054        assert_eq!(summary.json_fields_redacted, 2);
1055    }
1056
1057    #[test]
1058    fn record_and_playback_cycle() {
1059        let temp_dir = tempfile::tempdir().expect("temp dir");
1060        let cassette_dir = temp_dir.path().to_path_buf();
1061
1062        let request = RecordedRequest {
1063            method: "POST".to_string(),
1064            url: "https://example.com".to_string(),
1065            headers: vec![("content-type".to_string(), "application/json".to_string())],
1066            body: Some(serde_json::json!({"prompt": "hello"})),
1067            body_text: None,
1068        };
1069
1070        let recorded = run_async({
1071            let cassette_dir = cassette_dir.clone();
1072            let request = request.clone();
1073            async move {
1074                let recorder =
1075                    VcrRecorder::new_with("record_playback", VcrMode::Record, &cassette_dir);
1076                recorder
1077                    .record_streaming_with(request.clone(), || async {
1078                        let recorded = RecordedResponse {
1079                            status: 200,
1080                            headers: vec![(
1081                                "content-type".to_string(),
1082                                "text/event-stream".to_string(),
1083                            )],
1084                            body_chunks: vec!["event: message\ndata: ok\n\n".to_string()],
1085                            body_chunks_base64: None,
1086                        };
1087                        Ok((
1088                            recorded.status,
1089                            recorded.headers.clone(),
1090                            recorded.into_byte_stream(),
1091                        ))
1092                    })
1093                    .await
1094                    .expect("record")
1095            }
1096        });
1097
1098        assert_eq!(recorded.status, 200);
1099        assert_eq!(recorded.body_chunks.len(), 1);
1100
1101        let playback = run_async(async move {
1102            let recorder =
1103                VcrRecorder::new_with("record_playback", VcrMode::Playback, &cassette_dir);
1104            recorder
1105                .request_streaming_with::<_, _, ByteStream>(request, || async {
1106                    Err(Error::config("Unexpected record in playback mode"))
1107                })
1108                .await
1109                .expect("playback")
1110        });
1111
1112        assert_eq!(playback.body_chunks.len(), 1);
1113        assert!(playback.body_chunks[0].contains("event: message"));
1114    }
1115
1116    #[test]
1117    fn vcr_mode_from_env_values_and_invalid() {
1118        let _lock = lock_env();
1119        let previous = set_test_env_var(VCR_ENV_MODE, None);
1120        assert_eq!(VcrMode::from_env().expect("unset mode"), None);
1121        restore_test_env_var(VCR_ENV_MODE, previous);
1122
1123        for (raw, expected) in [
1124            ("record", VcrMode::Record),
1125            ("PLAYBACK", VcrMode::Playback),
1126            ("Auto", VcrMode::Auto),
1127        ] {
1128            let previous = set_test_env_var(VCR_ENV_MODE, Some(raw));
1129            assert_eq!(VcrMode::from_env().expect("valid mode"), Some(expected));
1130            restore_test_env_var(VCR_ENV_MODE, previous);
1131        }
1132
1133        let previous = set_test_env_var(VCR_ENV_MODE, Some("invalid-mode"));
1134        let err = VcrMode::from_env().expect_err("invalid mode should fail");
1135        assert!(
1136            err.to_string()
1137                .contains("Invalid VCR_MODE value: invalid-mode"),
1138            "unexpected error: {err}"
1139        );
1140        restore_test_env_var(VCR_ENV_MODE, previous);
1141    }
1142
1143    #[test]
1144    fn auto_mode_records_missing_cassette_then_replays_existing() {
1145        let temp_dir = tempfile::tempdir().expect("temp dir");
1146        let cassette_dir = temp_dir.path().to_path_buf();
1147        let cassette_path = cassette_dir.join("auto_mode_cycle.json");
1148
1149        let request = RecordedRequest {
1150            method: "POST".to_string(),
1151            url: "https://example.com/auto".to_string(),
1152            headers: vec![("content-type".to_string(), "application/json".to_string())],
1153            body: Some(serde_json::json!({"prompt": "first"})),
1154            body_text: None,
1155        };
1156
1157        let first = run_async({
1158            let request = request.clone();
1159            let cassette_dir = cassette_dir.clone();
1160            async move {
1161                let recorder =
1162                    VcrRecorder::new_with("auto_mode_cycle", VcrMode::Auto, cassette_dir);
1163                recorder
1164                    .request_streaming_with(request, || async {
1165                        let recorded = RecordedResponse {
1166                            status: 201,
1167                            headers: vec![("x-source".to_string(), "record".to_string())],
1168                            body_chunks: vec!["chunk-one".to_string()],
1169                            body_chunks_base64: None,
1170                        };
1171                        Ok((
1172                            recorded.status,
1173                            recorded.headers.clone(),
1174                            recorded.into_byte_stream(),
1175                        ))
1176                    })
1177                    .await
1178                    .expect("auto record")
1179            }
1180        });
1181
1182        assert_eq!(first.status, 201);
1183        assert!(
1184            cassette_path.exists(),
1185            "cassette should be written in auto mode"
1186        );
1187
1188        let replay = run_async({
1189            async move {
1190                let recorder =
1191                    VcrRecorder::new_with("auto_mode_cycle", VcrMode::Auto, cassette_dir);
1192                recorder
1193                    .request_streaming_with::<_, _, ByteStream>(request, || async {
1194                        Err(Error::config(
1195                            "send callback should not run during auto playback",
1196                        ))
1197                    })
1198                    .await
1199                    .expect("auto playback")
1200            }
1201        });
1202
1203        assert_eq!(replay.status, 201);
1204        assert_eq!(replay.body_chunks, vec!["chunk-one".to_string()]);
1205    }
1206
1207    #[test]
1208    fn playback_mismatch_returns_strict_error_with_debug_hashes() {
1209        let temp_dir = tempfile::tempdir().expect("temp dir");
1210        let cassette_dir = temp_dir.path().to_path_buf();
1211
1212        let recorded_request = RecordedRequest {
1213            method: "POST".to_string(),
1214            url: "https://example.com/strict".to_string(),
1215            headers: vec![("content-type".to_string(), "application/json".to_string())],
1216            body: Some(serde_json::json!({"prompt": "expected"})),
1217            body_text: Some("expected-body".to_string()),
1218        };
1219
1220        run_async({
1221            let cassette_dir = cassette_dir.clone();
1222            async move {
1223                let recorder =
1224                    VcrRecorder::new_with("strict_mismatch", VcrMode::Record, cassette_dir);
1225                recorder
1226                    .request_streaming_with(recorded_request, || async {
1227                        let recorded = RecordedResponse {
1228                            status: 200,
1229                            headers: vec![("content-type".to_string(), "text/plain".to_string())],
1230                            body_chunks: vec!["ok".to_string()],
1231                            body_chunks_base64: None,
1232                        };
1233                        Ok((
1234                            recorded.status,
1235                            recorded.headers.clone(),
1236                            recorded.into_byte_stream(),
1237                        ))
1238                    })
1239                    .await
1240                    .expect("record strict cassette")
1241            }
1242        });
1243
1244        let mismatched_request = RecordedRequest {
1245            method: "POST".to_string(),
1246            url: "https://example.com/strict".to_string(),
1247            headers: vec![],
1248            body: Some(serde_json::json!({"prompt": "different"})),
1249            body_text: Some("different-body".to_string()),
1250        };
1251
1252        let err = run_async({
1253            async move {
1254                let recorder =
1255                    VcrRecorder::new_with("strict_mismatch", VcrMode::Playback, cassette_dir);
1256                recorder
1257                    .request_streaming_with::<_, _, ByteStream>(mismatched_request, || async {
1258                        Err(Error::config(
1259                            "send callback should not execute during playback mismatch",
1260                        ))
1261                    })
1262                    .await
1263                    .expect_err("mismatch should fail in playback mode")
1264            }
1265        });
1266
1267        let msg = err.to_string();
1268        assert!(
1269            msg.contains("No matching interaction found in cassette"),
1270            "unexpected error message: {msg}"
1271        );
1272        assert!(msg.contains("Incoming: POST https://example.com/strict"));
1273        assert!(msg.contains("body_sha256="));
1274        assert!(msg.contains("body_text_sha256="));
1275        assert!(msg.contains("Match criteria: method + url + body + body_text"));
1276    }
1277
1278    #[test]
1279    fn test_env_override_helpers_set_and_restore_values() {
1280        const TEST_VAR: &str = "PI_AGENT_VCR_TEST_ENV_OVERRIDE";
1281        let _lock = lock_env();
1282
1283        let original = set_test_env_var(TEST_VAR, None);
1284        assert_eq!(env_var(TEST_VAR), None);
1285
1286        let previous = set_test_env_var(TEST_VAR, Some("override-value"));
1287        assert_eq!(previous, TestEnvOverrideSnapshot::Unset);
1288        assert_eq!(env_var(TEST_VAR).as_deref(), Some("override-value"));
1289
1290        restore_test_env_var(TEST_VAR, previous);
1291        assert_eq!(env_var(TEST_VAR), None);
1292
1293        restore_test_env_var(TEST_VAR, original);
1294    }
1295
1296    #[test]
1297    fn test_env_override_helpers_restore_nested_tombstone_state() {
1298        const TEST_VAR: &str = "PI_AGENT_VCR_TEST_ENV_TOMBSTONE";
1299        let _lock = lock_env();
1300
1301        let original = set_test_env_var(TEST_VAR, None);
1302        let previous = set_test_env_var(TEST_VAR, Some("override-value"));
1303        restore_test_env_var(TEST_VAR, previous);
1304
1305        let guard = test_env_overrides()
1306            .lock()
1307            .unwrap_or_else(std::sync::PoisonError::into_inner);
1308        assert_eq!(guard.get(TEST_VAR), Some(&None));
1309        drop(guard);
1310
1311        restore_test_env_var(TEST_VAR, original);
1312    }
1313
1314    fn poison_overrides_entry(
1315        overrides: &Mutex<HashMap<String, Option<String>>>,
1316        name: &str,
1317        value: Option<&str>,
1318    ) {
1319        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1320            let mut guard = overrides
1321                .lock()
1322                .unwrap_or_else(std::sync::PoisonError::into_inner);
1323            guard.insert(name.to_string(), value.map(str::to_string));
1324            resume_unwind_while_holding(guard);
1325        }));
1326    }
1327
1328    fn resume_unwind_while_holding<T>(_guard: T) -> ! {
1329        std::panic::resume_unwind(Box::new("poison override mutex".to_string()))
1330    }
1331
1332    #[test]
1333    fn test_env_var_with_recovers_poisoned_override_value() {
1334        const TEST_VAR: &str = "PI_AGENT_VCR_TEST_POISON_VALUE";
1335        let overrides = Mutex::new(HashMap::new());
1336
1337        poison_overrides_entry(&overrides, TEST_VAR, Some("override-value"));
1338
1339        assert_eq!(
1340            test_env_var_with(&overrides, TEST_VAR, || Some("host-value".to_string())).as_deref(),
1341            Some("override-value")
1342        );
1343    }
1344
1345    #[test]
1346    fn test_env_var_with_recovers_poisoned_tombstone() {
1347        const TEST_VAR: &str = "PI_AGENT_VCR_TEST_POISON_TOMBSTONE";
1348        let overrides = Mutex::new(HashMap::new());
1349
1350        poison_overrides_entry(&overrides, TEST_VAR, None);
1351
1352        assert_eq!(
1353            test_env_var_with(&overrides, TEST_VAR, || Some("host-value".to_string())),
1354            None
1355        );
1356    }
1357
1358    #[test]
1359    fn test_env_var_with_drops_lock_before_running_fallback() {
1360        const TEST_VAR: &str = "PI_AGENT_VCR_TEST_FALLBACK_LOCK";
1361        let overrides = Mutex::new(HashMap::new());
1362
1363        assert_eq!(
1364            test_env_var_with(&overrides, TEST_VAR, || {
1365                let guard = overrides
1366                    .try_lock()
1367                    .expect("fallback should reacquire lock");
1368                drop(guard);
1369                Some("host-value".to_string())
1370            })
1371            .as_deref(),
1372            Some("host-value")
1373        );
1374    }
1375
1376    fn run_async<T>(future: impl Future<Output = T> + Send + 'static) -> T
1377    where
1378        T: Send + 'static,
1379    {
1380        let runtime = asupersync::runtime::RuntimeBuilder::new()
1381            .blocking_threads(1, 2)
1382            .build()
1383            .expect("build runtime");
1384        let join = runtime.handle().spawn(future);
1385        runtime.block_on(join)
1386    }
1387
1388    // ─── sanitize_test_name ──────────────────────────────────────────
1389
1390    #[test]
1391    fn sanitize_preserves_alphanumeric_and_dash_underscore() {
1392        assert_eq!(sanitize_test_name("hello-world_123"), "hello-world_123");
1393    }
1394
1395    #[test]
1396    fn sanitize_replaces_special_chars() {
1397        assert_eq!(sanitize_test_name("a/b::c d.e"), "a_b__c_d_e");
1398    }
1399
1400    #[test]
1401    fn sanitize_empty_returns_vcr() {
1402        assert_eq!(sanitize_test_name(""), "vcr");
1403    }
1404
1405    #[test]
1406    fn sanitize_all_special_returns_underscores() {
1407        assert_eq!(sanitize_test_name("..."), "___");
1408    }
1409
1410    #[test]
1411    fn sanitize_unicode_replaced() {
1412        assert_eq!(sanitize_test_name("café"), "caf_");
1413    }
1414
1415    // ─── short_sha256 ────────────────────────────────────────────────
1416
1417    #[test]
1418    fn short_sha256_deterministic() {
1419        let a = short_sha256(b"hello");
1420        let b = short_sha256(b"hello");
1421        assert_eq!(a, b);
1422    }
1423
1424    #[test]
1425    fn short_sha256_length() {
1426        let hash = short_sha256(b"test data");
1427        assert_eq!(hash.len(), 12, "6 bytes = 12 hex chars");
1428    }
1429
1430    #[test]
1431    fn short_sha256_different_inputs() {
1432        let a = short_sha256(b"alpha");
1433        let b = short_sha256(b"beta");
1434        assert_ne!(a, b);
1435    }
1436
1437    #[test]
1438    fn short_sha256_empty_input() {
1439        let hash = short_sha256(b"");
1440        assert_eq!(hash.len(), 12);
1441        // SHA-256 of empty is well-known: e3b0c44298fc...
1442        assert_eq!(&hash[..6], "e3b0c4");
1443    }
1444
1445    // ─── is_sensitive_key ────────────────────────────────────────────
1446
1447    #[test]
1448    fn sensitive_key_api_key() {
1449        assert!(is_sensitive_key("api_key"));
1450        assert!(is_sensitive_key("x_api_key"));
1451        assert!(is_sensitive_key("MY_APIKEY"));
1452    }
1453
1454    #[test]
1455    fn sensitive_key_authorization() {
1456        assert!(is_sensitive_key("authorization"));
1457        assert!(is_sensitive_key("Authorization"));
1458    }
1459
1460    #[test]
1461    fn sensitive_key_token_but_not_tokens() {
1462        // "token" (singular) is sensitive
1463        assert!(is_sensitive_key("access_token"));
1464        assert!(is_sensitive_key("id_token"));
1465        assert!(is_sensitive_key("refresh_token"));
1466        // "tokens" (count) is NOT sensitive...
1467        assert!(!is_sensitive_key("max_tokens"));
1468        assert!(!is_sensitive_key("prompt_tokens"));
1469        assert!(!is_sensitive_key("completion_tokens"));
1470        // ...except the plural versions of auth token names
1471        assert!(is_sensitive_key("access_tokens"));
1472        assert!(is_sensitive_key("refresh_tokens"));
1473    }
1474
1475    #[test]
1476    fn sensitive_key_secret_and_password() {
1477        assert!(is_sensitive_key("client_secret"));
1478        assert!(is_sensitive_key("password"));
1479        assert!(is_sensitive_key("db_password_hash"));
1480    }
1481
1482    #[test]
1483    fn sensitive_key_safe_keys() {
1484        assert!(!is_sensitive_key("model"));
1485        assert!(!is_sensitive_key("content"));
1486        assert!(!is_sensitive_key("messages"));
1487        assert!(!is_sensitive_key("temperature"));
1488    }
1489
1490    // ─── redact_json ─────────────────────────────────────────────────
1491
1492    #[test]
1493    fn redact_json_flat_object() {
1494        let mut val = serde_json::json!({"api_key": "sk-123", "model": "gpt-4"});
1495        let count = redact_json(&mut val);
1496        assert_eq!(count, 1);
1497        assert_eq!(val["api_key"], REDACTED);
1498        assert_eq!(val["model"], "gpt-4");
1499    }
1500
1501    #[test]
1502    fn redact_json_nested() {
1503        let mut val = serde_json::json!({
1504            "config": {
1505                "secret": "hidden",
1506                "name": "test"
1507            }
1508        });
1509        let count = redact_json(&mut val);
1510        assert_eq!(count, 1);
1511        assert_eq!(val["config"]["secret"], REDACTED);
1512        assert_eq!(val["config"]["name"], "test");
1513    }
1514
1515    #[test]
1516    fn redact_json_array_of_objects() {
1517        let mut val = serde_json::json!([
1518            {"api_key": "a"},
1519            {"api_key": "b"},
1520            {"safe": "c"}
1521        ]);
1522        let count = redact_json(&mut val);
1523        assert_eq!(count, 2);
1524        assert_eq!(val[0]["api_key"], REDACTED);
1525        assert_eq!(val[1]["api_key"], REDACTED);
1526        assert_eq!(val[2]["safe"], "c");
1527    }
1528
1529    #[test]
1530    fn redact_json_scalar_returns_zero() {
1531        let mut val = serde_json::json!("just a string");
1532        assert_eq!(redact_json(&mut val), 0);
1533        let mut val = serde_json::json!(42);
1534        assert_eq!(redact_json(&mut val), 0);
1535        let mut val = serde_json::json!(null);
1536        assert_eq!(redact_json(&mut val), 0);
1537    }
1538
1539    #[test]
1540    fn redact_json_empty_object() {
1541        let mut val = serde_json::json!({});
1542        assert_eq!(redact_json(&mut val), 0);
1543    }
1544
1545    #[test]
1546    fn normalize_body_text_redacts_json_payloads() {
1547        let body = r#"{"api_key":"sk-secret","model":"gpt-4"}"#;
1548        let normalized = normalize_body_text_for_matching(&[], body);
1549        let parsed: Value = serde_json::from_str(&normalized).expect("normalized json");
1550        assert_eq!(parsed["api_key"], REDACTED);
1551        assert_eq!(parsed["model"], "gpt-4");
1552    }
1553
1554    #[test]
1555    fn normalize_body_text_redacts_form_payloads() {
1556        let headers = vec![(
1557            "content-type".to_string(),
1558            "application/x-www-form-urlencoded".to_string(),
1559        )];
1560        let normalized = normalize_body_text_for_matching(
1561            &headers,
1562            "grant_type=refresh_token&client_secret=s3cr3t&scope=repo",
1563        );
1564        let params: std::collections::HashMap<String, String> =
1565            url::form_urlencoded::parse(normalized.as_bytes())
1566                .map(|(key, value)| (key.into_owned(), value.into_owned()))
1567                .collect();
1568        assert_eq!(
1569            params.get("grant_type").map(String::as_str),
1570            Some("refresh_token")
1571        );
1572        assert_eq!(
1573            params.get("client_secret").map(String::as_str),
1574            Some(REDACTED)
1575        );
1576        assert_eq!(params.get("scope").map(String::as_str), Some("repo"));
1577    }
1578
1579    #[test]
1580    fn normalize_body_text_redacts_single_form_pair_without_content_type() {
1581        let normalized = normalize_body_text_for_matching(&[], "client_secret=s3cr3t");
1582        let params: std::collections::HashMap<String, String> =
1583            url::form_urlencoded::parse(normalized.as_bytes())
1584                .map(|(key, value)| (key.into_owned(), value.into_owned()))
1585                .collect();
1586        assert_eq!(
1587            params.get("client_secret").map(String::as_str),
1588            Some(REDACTED)
1589        );
1590    }
1591
1592    #[test]
1593    fn normalize_body_text_redacts_single_encoded_sensitive_form_pair_without_content_type() {
1594        let normalized = normalize_body_text_for_matching(&[], "client%5Fsecret=s3cr3t");
1595        let params: std::collections::HashMap<String, String> =
1596            url::form_urlencoded::parse(normalized.as_bytes())
1597                .map(|(key, value)| (key.into_owned(), value.into_owned()))
1598                .collect();
1599        assert_eq!(
1600            params.get("client_secret").map(String::as_str),
1601            Some(REDACTED)
1602        );
1603    }
1604
1605    #[test]
1606    fn normalize_body_text_keeps_non_sensitive_single_pair_without_content_type_verbatim() {
1607        let body = "note=a=b";
1608        assert_eq!(normalize_body_text_for_matching(&[], body), body);
1609    }
1610
1611    // ─── redact_headers ──────────────────────────────────────────────
1612
1613    #[test]
1614    fn redact_headers_case_insensitive() {
1615        let sensitive = sensitive_header_keys();
1616        let mut headers = vec![
1617            ("Authorization".to_string(), "Bearer tok".to_string()),
1618            ("X-Api-Key".to_string(), "key".to_string()),
1619            ("Content-Type".to_string(), "application/json".to_string()),
1620        ];
1621        let count = redact_headers(&mut headers, &sensitive);
1622        assert_eq!(count, 2);
1623        assert_eq!(headers[0].1, REDACTED);
1624        assert_eq!(headers[1].1, REDACTED);
1625        assert_eq!(headers[2].1, "application/json");
1626    }
1627
1628    #[test]
1629    fn redact_headers_empty() {
1630        let sensitive = sensitive_header_keys();
1631        let mut headers = vec![];
1632        assert_eq!(redact_headers(&mut headers, &sensitive), 0);
1633    }
1634
1635    #[test]
1636    fn redact_headers_all_sensitive_keys() {
1637        let sensitive = sensitive_header_keys();
1638        let keys = [
1639            "authorization",
1640            "x-api-key",
1641            "api-key",
1642            "x-goog-api-key",
1643            "x-azure-api-key",
1644            "proxy-authorization",
1645        ];
1646        let mut headers: Vec<(String, String)> = keys
1647            .iter()
1648            .map(|k| (k.to_string(), "secret".to_string()))
1649            .collect();
1650        let count = redact_headers(&mut headers, &sensitive);
1651        assert_eq!(count, 6);
1652        for (_, val) in &headers {
1653            assert_eq!(val, REDACTED);
1654        }
1655    }
1656
1657    // ─── request_debug_key ───────────────────────────────────────────
1658
1659    #[test]
1660    fn request_debug_key_with_body() {
1661        let req = RecordedRequest {
1662            method: "post".to_string(),
1663            url: "https://api.example.com/v1/chat".to_string(),
1664            headers: vec![],
1665            body: Some(serde_json::json!({"prompt": "hello"})),
1666            body_text: None,
1667        };
1668        let key = request_debug_key(&req);
1669        assert!(key.starts_with("POST https://api.example.com/v1/chat"));
1670        assert!(key.contains("body_sha256="));
1671        assert!(key.contains("body_text_sha256=<none>"));
1672    }
1673
1674    #[test]
1675    fn request_debug_key_no_body() {
1676        let req = RecordedRequest {
1677            method: "GET".to_string(),
1678            url: "https://example.com".to_string(),
1679            headers: vec![],
1680            body: None,
1681            body_text: None,
1682        };
1683        let key = request_debug_key(&req);
1684        assert!(key.contains("body_sha256=<none>"));
1685        assert!(key.contains("body_text_sha256=<none>"));
1686    }
1687
1688    #[test]
1689    fn request_debug_key_with_body_text() {
1690        let req = RecordedRequest {
1691            method: "POST".to_string(),
1692            url: "https://example.com".to_string(),
1693            headers: vec![],
1694            body: None,
1695            body_text: Some("raw text body".to_string()),
1696        };
1697        let key = request_debug_key(&req);
1698        assert!(key.contains("body_text_sha256="));
1699        assert!(key.contains("body_text_len=13"));
1700        assert!(!key.contains("body_text_sha256=<none>"));
1701    }
1702
1703    // ─── match_json_template ─────────────────────────────────────────
1704
1705    #[test]
1706    fn json_template_exact_scalar_match() {
1707        let a = serde_json::json!("hello");
1708        let b = serde_json::json!("hello");
1709        assert!(match_json_template(&a, &b));
1710    }
1711
1712    #[test]
1713    fn json_template_scalar_mismatch() {
1714        let a = serde_json::json!("hello");
1715        let b = serde_json::json!("world");
1716        assert!(!match_json_template(&a, &b));
1717    }
1718
1719    #[test]
1720    fn json_template_number_match() {
1721        let a = serde_json::json!(42);
1722        let b = serde_json::json!(42);
1723        assert!(match_json_template(&a, &b));
1724    }
1725
1726    #[test]
1727    fn json_template_object_extra_incoming_keys_ok() {
1728        let recorded = serde_json::json!({"model": "gpt-4"});
1729        let incoming = serde_json::json!({"model": "gpt-4", "extra": "ignored"});
1730        assert!(match_json_template(&recorded, &incoming));
1731    }
1732
1733    #[test]
1734    fn json_template_object_missing_incoming_key_fails() {
1735        let recorded = serde_json::json!({"model": "gpt-4", "required": true});
1736        let incoming = serde_json::json!({"model": "gpt-4"});
1737        assert!(!match_json_template(&recorded, &incoming));
1738    }
1739
1740    #[test]
1741    fn json_template_null_matches_missing_key() {
1742        let recorded = serde_json::json!({"model": "gpt-4", "optional": null});
1743        let incoming = serde_json::json!({"model": "gpt-4"});
1744        assert!(match_json_template(&recorded, &incoming));
1745    }
1746
1747    #[test]
1748    fn json_template_null_matches_null() {
1749        let recorded = serde_json::json!({"field": null});
1750        let incoming = serde_json::json!({"field": null});
1751        assert!(match_json_template(&recorded, &incoming));
1752    }
1753
1754    #[test]
1755    fn json_template_array_same_length_matches() {
1756        let recorded = serde_json::json!([1, 2, 3]);
1757        let incoming = serde_json::json!([1, 2, 3]);
1758        assert!(match_json_template(&recorded, &incoming));
1759    }
1760
1761    #[test]
1762    fn json_template_array_different_length_fails() {
1763        let recorded = serde_json::json!([1, 2]);
1764        let incoming = serde_json::json!([1, 2, 3]);
1765        assert!(!match_json_template(&recorded, &incoming));
1766    }
1767
1768    #[test]
1769    fn json_template_array_element_mismatch_fails() {
1770        let recorded = serde_json::json!([1, 2, 3]);
1771        let incoming = serde_json::json!([1, 99, 3]);
1772        assert!(!match_json_template(&recorded, &incoming));
1773    }
1774
1775    #[test]
1776    fn json_template_nested_object_in_array() {
1777        let recorded = serde_json::json!([{"role": "user"}, {"role": "assistant"}]);
1778        let incoming = serde_json::json!([
1779            {"role": "user", "id": "1"},
1780            {"role": "assistant", "id": "2"}
1781        ]);
1782        assert!(match_json_template(&recorded, &incoming));
1783    }
1784
1785    #[test]
1786    fn json_template_type_mismatch() {
1787        let recorded = serde_json::json!({"a": "string"});
1788        let incoming = serde_json::json!({"a": 42});
1789        assert!(!match_json_template(&recorded, &incoming));
1790    }
1791
1792    // ─── match_optional_json ─────────────────────────────────────────
1793
1794    #[test]
1795    fn optional_json_none_recorded_matches_anything() {
1796        assert!(match_optional_json(None, None));
1797        assert!(match_optional_json(
1798            None,
1799            Some(&serde_json::json!({"anything": true}))
1800        ));
1801    }
1802
1803    #[test]
1804    fn optional_json_some_recorded_none_incoming_fails() {
1805        let recorded = serde_json::json!({"a": 1});
1806        assert!(!match_optional_json(Some(&recorded), None));
1807    }
1808
1809    // ─── request_matches ─────────────────────────────────────────────
1810
1811    #[test]
1812    fn request_matches_method_case_insensitive() {
1813        let recorded = RecordedRequest {
1814            method: "POST".to_string(),
1815            url: "https://x.com".to_string(),
1816            headers: vec![],
1817            body: None,
1818            body_text: None,
1819        };
1820        let incoming = RecordedRequest {
1821            method: "post".to_string(),
1822            url: "https://x.com".to_string(),
1823            headers: vec![],
1824            body: None,
1825            body_text: None,
1826        };
1827        assert!(request_matches(&recorded, &incoming));
1828    }
1829
1830    #[test]
1831    fn request_matches_url_mismatch() {
1832        let recorded = RecordedRequest {
1833            method: "GET".to_string(),
1834            url: "https://a.com".to_string(),
1835            headers: vec![],
1836            body: None,
1837            body_text: None,
1838        };
1839        let incoming = RecordedRequest {
1840            method: "GET".to_string(),
1841            url: "https://b.com".to_string(),
1842            headers: vec![],
1843            body: None,
1844            body_text: None,
1845        };
1846        assert!(!request_matches(&recorded, &incoming));
1847    }
1848
1849    #[test]
1850    fn request_matches_body_text_constraint() {
1851        let recorded = RecordedRequest {
1852            method: "POST".to_string(),
1853            url: "https://x.com".to_string(),
1854            headers: vec![],
1855            body: None,
1856            body_text: Some("expected".to_string()),
1857        };
1858        let mut incoming = recorded.clone();
1859        incoming.body_text = Some("expected".to_string());
1860        assert!(request_matches(&recorded, &incoming));
1861
1862        incoming.body_text = Some("different".to_string());
1863        assert!(!request_matches(&recorded, &incoming));
1864    }
1865
1866    #[test]
1867    fn request_matches_redacts_form_body_text() {
1868        let headers = vec![(
1869            "content-type".to_string(),
1870            "application/x-www-form-urlencoded".to_string(),
1871        )];
1872        let recorded = RecordedRequest {
1873            method: "POST".to_string(),
1874            url: "https://x.com".to_string(),
1875            headers: headers.clone(),
1876            body: None,
1877            body_text: Some(
1878                "grant_type=refresh_token&client_secret=%5BREDACTED%5D&scope=repo".to_string(),
1879            ),
1880        };
1881        let incoming = RecordedRequest {
1882            method: "POST".to_string(),
1883            url: "https://x.com".to_string(),
1884            headers,
1885            body: None,
1886            body_text: Some(
1887                "grant_type=refresh_token&client_secret=real-secret&scope=repo".to_string(),
1888            ),
1889        };
1890        assert!(request_matches(&recorded, &incoming));
1891    }
1892
1893    #[test]
1894    fn request_matches_redacts_single_form_body_text_without_content_type() {
1895        let recorded = RecordedRequest {
1896            method: "POST".to_string(),
1897            url: "https://x.com".to_string(),
1898            headers: vec![],
1899            body: None,
1900            body_text: Some("refresh_token=%5BREDACTED%5D".to_string()),
1901        };
1902        let incoming = RecordedRequest {
1903            method: "POST".to_string(),
1904            url: "https://x.com".to_string(),
1905            headers: vec![],
1906            body: None,
1907            body_text: Some("refresh_token=real-secret".to_string()),
1908        };
1909        assert!(request_matches(&recorded, &incoming));
1910    }
1911
1912    #[test]
1913    fn request_matches_redacts_single_encoded_sensitive_form_body_text_without_content_type() {
1914        let recorded = RecordedRequest {
1915            method: "POST".to_string(),
1916            url: "https://x.com".to_string(),
1917            headers: vec![],
1918            body: None,
1919            body_text: Some("client%5Fsecret=%5BREDACTED%5D".to_string()),
1920        };
1921        let incoming = RecordedRequest {
1922            method: "POST".to_string(),
1923            url: "https://x.com".to_string(),
1924            headers: vec![],
1925            body: None,
1926            body_text: Some("client_secret=real-secret".to_string()),
1927        };
1928        assert!(request_matches(&recorded, &incoming));
1929    }
1930
1931    #[test]
1932    fn request_matches_does_not_treat_non_sensitive_single_pair_without_content_type_as_form() {
1933        let recorded = RecordedRequest {
1934            method: "POST".to_string(),
1935            url: "https://x.com".to_string(),
1936            headers: vec![],
1937            body: None,
1938            body_text: Some("note=a=b".to_string()),
1939        };
1940        let incoming = RecordedRequest {
1941            method: "POST".to_string(),
1942            url: "https://x.com".to_string(),
1943            headers: vec![],
1944            body: None,
1945            body_text: Some("note=a%3Db".to_string()),
1946        };
1947        assert!(!request_matches(&recorded, &incoming));
1948    }
1949
1950    #[test]
1951    fn request_matches_redacts_json_body_text() {
1952        let recorded = RecordedRequest {
1953            method: "POST".to_string(),
1954            url: "https://x.com".to_string(),
1955            headers: vec![],
1956            body: None,
1957            body_text: Some(r#"{"api_key":"[REDACTED]","model":"gpt-4"}"#.to_string()),
1958        };
1959        let incoming = RecordedRequest {
1960            method: "POST".to_string(),
1961            url: "https://x.com".to_string(),
1962            headers: vec![],
1963            body: None,
1964            body_text: Some(r#"{"api_key":"sk-secret","model":"gpt-4"}"#.to_string()),
1965        };
1966        assert!(request_matches(&recorded, &incoming));
1967    }
1968
1969    #[test]
1970    fn request_matches_missing_recorded_body_text_is_wildcard() {
1971        let recorded = RecordedRequest {
1972            method: "POST".to_string(),
1973            url: "https://x.com".to_string(),
1974            headers: vec![],
1975            body: None,
1976            body_text: None,
1977        };
1978        let incoming = RecordedRequest {
1979            method: "POST".to_string(),
1980            url: "https://x.com".to_string(),
1981            headers: vec![],
1982            body: None,
1983            body_text: Some("anything".to_string()),
1984        };
1985        assert!(request_matches(&recorded, &incoming));
1986    }
1987
1988    #[test]
1989    fn request_matches_redacts_incoming_body() {
1990        // The cassette body is already redacted; incoming body has real secrets.
1991        let recorded = RecordedRequest {
1992            method: "POST".to_string(),
1993            url: "https://x.com".to_string(),
1994            headers: vec![],
1995            body: Some(serde_json::json!({"api_key": REDACTED, "model": "gpt-4"})),
1996            body_text: None,
1997        };
1998        let incoming = RecordedRequest {
1999            method: "POST".to_string(),
2000            url: "https://x.com".to_string(),
2001            headers: vec![],
2002            body: Some(serde_json::json!({"api_key": "sk-real-secret", "model": "gpt-4"})),
2003            body_text: None,
2004        };
2005        assert!(request_matches(&recorded, &incoming));
2006    }
2007
2008    // ─── find_interaction_from ───────────────────────────────────────
2009
2010    #[test]
2011    fn find_interaction_from_start() {
2012        let cassette = Cassette {
2013            version: "1.0".to_string(),
2014            test_name: "test".to_string(),
2015            recorded_at: "2026-01-01".to_string(),
2016            interactions: vec![
2017                Interaction {
2018                    request: RecordedRequest {
2019                        method: "GET".to_string(),
2020                        url: "https://a.com".to_string(),
2021                        headers: vec![],
2022                        body: None,
2023                        body_text: None,
2024                    },
2025                    response: RecordedResponse {
2026                        status: 200,
2027                        headers: vec![],
2028                        body_chunks: vec!["a".to_string()],
2029                        body_chunks_base64: None,
2030                    },
2031                },
2032                Interaction {
2033                    request: RecordedRequest {
2034                        method: "GET".to_string(),
2035                        url: "https://b.com".to_string(),
2036                        headers: vec![],
2037                        body: None,
2038                        body_text: None,
2039                    },
2040                    response: RecordedResponse {
2041                        status: 201,
2042                        headers: vec![],
2043                        body_chunks: vec!["b".to_string()],
2044                        body_chunks_base64: None,
2045                    },
2046                },
2047            ],
2048        };
2049
2050        let req_b = RecordedRequest {
2051            method: "GET".to_string(),
2052            url: "https://b.com".to_string(),
2053            headers: vec![],
2054            body: None,
2055            body_text: None,
2056        };
2057
2058        let result = find_interaction_from(&cassette, &req_b, 0);
2059        assert!(result.is_some());
2060        let (idx, interaction) = result.unwrap();
2061        assert_eq!(idx, 1);
2062        assert_eq!(interaction.response.status, 201);
2063    }
2064
2065    #[test]
2066    fn find_interaction_from_with_cursor_skip() {
2067        let make_interaction = |url: &str, status: u16| Interaction {
2068            request: RecordedRequest {
2069                method: "POST".to_string(),
2070                url: url.to_string(),
2071                headers: vec![],
2072                body: None,
2073                body_text: None,
2074            },
2075            response: RecordedResponse {
2076                status,
2077                headers: vec![],
2078                body_chunks: vec![],
2079                body_chunks_base64: None,
2080            },
2081        };
2082
2083        let cassette = Cassette {
2084            version: "1.0".to_string(),
2085            test_name: "cursor".to_string(),
2086            recorded_at: "2026-01-01".to_string(),
2087            interactions: vec![
2088                make_interaction("https://x.com", 200),
2089                make_interaction("https://x.com", 201),
2090                make_interaction("https://x.com", 202),
2091            ],
2092        };
2093
2094        let req = RecordedRequest {
2095            method: "POST".to_string(),
2096            url: "https://x.com".to_string(),
2097            headers: vec![],
2098            body: None,
2099            body_text: None,
2100        };
2101
2102        // Start at 0 → finds index 0
2103        let (idx, _) = find_interaction_from(&cassette, &req, 0).unwrap();
2104        assert_eq!(idx, 0);
2105
2106        // Start at 1 → skips index 0, finds index 1
2107        let (idx, interaction) = find_interaction_from(&cassette, &req, 1).unwrap();
2108        assert_eq!(idx, 1);
2109        assert_eq!(interaction.response.status, 201);
2110
2111        // Start at 3 → past end, nothing found
2112        assert!(find_interaction_from(&cassette, &req, 3).is_none());
2113    }
2114
2115    #[test]
2116    fn find_interaction_no_match() {
2117        let cassette = Cassette {
2118            version: "1.0".to_string(),
2119            test_name: "empty".to_string(),
2120            recorded_at: "2026-01-01".to_string(),
2121            interactions: vec![],
2122        };
2123        let req = RecordedRequest {
2124            method: "GET".to_string(),
2125            url: "https://x.com".to_string(),
2126            headers: vec![],
2127            body: None,
2128            body_text: None,
2129        };
2130        assert!(find_interaction_from(&cassette, &req, 0).is_none());
2131    }
2132
2133    // ─── env_truthy ──────────────────────────────────────────────────
2134
2135    #[test]
2136    fn env_truthy_values() {
2137        let _lock = lock_env();
2138        let key = "PI_VCR_TEST_TRUTHY";
2139
2140        for val in ["1", "true", "TRUE", "yes", "YES"] {
2141            let prev = set_test_env_var(key, Some(val));
2142            assert!(env_truthy(key), "expected truthy for '{val}'");
2143            restore_test_env_var(key, prev);
2144        }
2145
2146        for val in ["0", "false", "no", ""] {
2147            let prev = set_test_env_var(key, Some(val));
2148            assert!(!env_truthy(key), "expected falsy for '{val}'");
2149            restore_test_env_var(key, prev);
2150        }
2151
2152        let prev = set_test_env_var(key, None);
2153        assert!(!env_truthy(key), "expected falsy for unset");
2154        restore_test_env_var(key, prev);
2155    }
2156
2157    // ─── default_mode ────────────────────────────────────────────────
2158
2159    #[test]
2160    fn default_mode_ci_is_playback() {
2161        let _lock = lock_env();
2162        let prev = set_test_env_var("CI", Some("true"));
2163        assert_eq!(default_mode(), VcrMode::Playback);
2164        restore_test_env_var("CI", prev);
2165    }
2166
2167    #[test]
2168    fn default_mode_no_ci_is_auto() {
2169        let _lock = lock_env();
2170        let prev = set_test_env_var("CI", None);
2171        assert_eq!(default_mode(), VcrMode::Auto);
2172        restore_test_env_var("CI", prev);
2173    }
2174
2175    // ─── RecordedResponse::into_byte_stream ──────────────────────────
2176
2177    #[test]
2178    fn into_byte_stream_text_chunks() {
2179        let resp = RecordedResponse {
2180            status: 200,
2181            headers: vec![],
2182            body_chunks: vec!["hello ".to_string(), "world".to_string()],
2183            body_chunks_base64: None,
2184        };
2185        let chunks: Vec<Vec<u8>> = run_async(async move {
2186            use futures::StreamExt;
2187            resp.into_byte_stream()
2188                .map(|r| r.expect("chunk"))
2189                .collect()
2190                .await
2191        });
2192        assert_eq!(chunks.len(), 2);
2193        assert_eq!(chunks[0], b"hello ");
2194        assert_eq!(chunks[1], b"world");
2195    }
2196
2197    #[test]
2198    fn into_byte_stream_base64_chunks() {
2199        let chunk1 = STANDARD.encode(b"binary\x00data");
2200        let chunk2 = STANDARD.encode(b"\xff\xfe");
2201        let resp = RecordedResponse {
2202            status: 200,
2203            headers: vec![],
2204            body_chunks: vec![],
2205            body_chunks_base64: Some(vec![chunk1, chunk2]),
2206        };
2207        let chunks: Vec<Vec<u8>> = run_async(async move {
2208            use futures::StreamExt;
2209            resp.into_byte_stream()
2210                .map(|r| r.expect("chunk"))
2211                .collect()
2212                .await
2213        });
2214        assert_eq!(chunks.len(), 2);
2215        assert_eq!(chunks[0], b"binary\x00data");
2216        assert_eq!(chunks[1], b"\xff\xfe");
2217    }
2218
2219    #[test]
2220    fn into_byte_stream_base64_takes_precedence() {
2221        let resp = RecordedResponse {
2222            status: 200,
2223            headers: vec![],
2224            body_chunks: vec!["ignored".to_string()],
2225            body_chunks_base64: Some(vec![STANDARD.encode(b"used")]),
2226        };
2227        let chunks: Vec<Vec<u8>> = run_async(async move {
2228            use futures::StreamExt;
2229            resp.into_byte_stream()
2230                .map(|r| r.expect("chunk"))
2231                .collect()
2232                .await
2233        });
2234        assert_eq!(chunks.len(), 1);
2235        assert_eq!(chunks[0], b"used");
2236    }
2237
2238    #[test]
2239    fn into_byte_stream_empty() {
2240        let resp = RecordedResponse {
2241            status: 200,
2242            headers: vec![],
2243            body_chunks: vec![],
2244            body_chunks_base64: None,
2245        };
2246        let chunks: Vec<Vec<u8>> = run_async(async move {
2247            use futures::StreamExt;
2248            resp.into_byte_stream()
2249                .map(|r| r.expect("chunk"))
2250                .collect()
2251                .await
2252        });
2253        assert!(chunks.is_empty());
2254    }
2255
2256    #[test]
2257    fn into_byte_stream_invalid_base64_errors() {
2258        let resp = RecordedResponse {
2259            status: 200,
2260            headers: vec![],
2261            body_chunks: vec![],
2262            body_chunks_base64: Some(vec!["not-valid-base64!!!".to_string()]),
2263        };
2264        let results: Vec<std::result::Result<Vec<u8>, std::io::Error>> = run_async(async move {
2265            use futures::StreamExt;
2266            resp.into_byte_stream().collect().await
2267        });
2268        assert_eq!(results.len(), 1);
2269        assert!(results[0].is_err());
2270    }
2271
2272    // ─── Cassette serialization ──────────────────────────────────────
2273
2274    #[test]
2275    fn cassette_serde_body_text_omitted_when_none() {
2276        let req = RecordedRequest {
2277            method: "GET".to_string(),
2278            url: "https://x.com".to_string(),
2279            headers: vec![],
2280            body: None,
2281            body_text: None,
2282        };
2283        let json = serde_json::to_string(&req).unwrap();
2284        assert!(!json.contains("body_text"));
2285        assert!(!json.contains("body"));
2286    }
2287
2288    #[test]
2289    fn cassette_serde_body_text_present_when_some() {
2290        let req = RecordedRequest {
2291            method: "GET".to_string(),
2292            url: "https://x.com".to_string(),
2293            headers: vec![],
2294            body: None,
2295            body_text: Some("hello".to_string()),
2296        };
2297        let json = serde_json::to_string(&req).unwrap();
2298        assert!(json.contains("body_text"));
2299        assert!(json.contains("hello"));
2300    }
2301
2302    #[test]
2303    fn cassette_response_base64_omitted_when_none() {
2304        let resp = RecordedResponse {
2305            status: 200,
2306            headers: vec![],
2307            body_chunks: vec!["data".to_string()],
2308            body_chunks_base64: None,
2309        };
2310        let json = serde_json::to_string(&resp).unwrap();
2311        assert!(!json.contains("body_chunks_base64"));
2312    }
2313
2314    #[test]
2315    fn cassette_save_load_round_trip() {
2316        let temp_dir = tempfile::tempdir().expect("temp dir");
2317        let path = temp_dir.path().join("subdir/test.json");
2318        let cassette = Cassette {
2319            version: CASSETTE_VERSION.to_string(),
2320            test_name: "save_load".to_string(),
2321            recorded_at: "2026-02-06T00:00:00.000Z".to_string(),
2322            interactions: vec![Interaction {
2323                request: RecordedRequest {
2324                    method: "POST".to_string(),
2325                    url: "https://api.example.com".to_string(),
2326                    headers: vec![("content-type".to_string(), "application/json".to_string())],
2327                    body: Some(serde_json::json!({"key": "value"})),
2328                    body_text: None,
2329                },
2330                response: RecordedResponse {
2331                    status: 200,
2332                    headers: vec![],
2333                    body_chunks: vec!["ok".to_string()],
2334                    body_chunks_base64: None,
2335                },
2336            }],
2337        };
2338
2339        save_cassette(&path, &cassette).expect("save");
2340        assert!(path.exists());
2341
2342        let loaded = load_cassette(&path).expect("load");
2343        assert_eq!(loaded.version, CASSETTE_VERSION);
2344        assert_eq!(loaded.test_name, "save_load");
2345        assert_eq!(loaded.interactions.len(), 1);
2346        assert_eq!(loaded.interactions[0].request.method, "POST");
2347    }
2348
2349    #[test]
2350    fn load_cassette_missing_file_errors() {
2351        let result = load_cassette(Path::new("/nonexistent/cassette.json"));
2352        assert!(result.is_err());
2353        assert!(result.unwrap_err().to_string().contains("Failed to read"));
2354    }
2355
2356    // ─── redact_cassette integration ─────────────────────────────────
2357
2358    #[test]
2359    fn redact_cassette_multiple_interactions() {
2360        let mut cassette = Cassette {
2361            version: "1.0".to_string(),
2362            test_name: "multi".to_string(),
2363            recorded_at: "now".to_string(),
2364            interactions: vec![
2365                Interaction {
2366                    request: RecordedRequest {
2367                        method: "POST".to_string(),
2368                        url: "https://a.com".to_string(),
2369                        headers: vec![("Authorization".to_string(), "Bearer tok".to_string())],
2370                        body: Some(serde_json::json!({"password": "p1"})),
2371                        body_text: None,
2372                    },
2373                    response: RecordedResponse {
2374                        status: 200,
2375                        headers: vec![("x-api-key".to_string(), "key1".to_string())],
2376                        body_chunks: vec![],
2377                        body_chunks_base64: None,
2378                    },
2379                },
2380                Interaction {
2381                    request: RecordedRequest {
2382                        method: "POST".to_string(),
2383                        url: "https://b.com".to_string(),
2384                        headers: vec![],
2385                        body: Some(serde_json::json!({"client_secret": "s1"})),
2386                        body_text: None,
2387                    },
2388                    response: RecordedResponse {
2389                        status: 200,
2390                        headers: vec![],
2391                        body_chunks: vec![],
2392                        body_chunks_base64: None,
2393                    },
2394                },
2395            ],
2396        };
2397
2398        let summary = redact_cassette(&mut cassette);
2399        assert_eq!(summary.headers_redacted, 2);
2400        assert_eq!(summary.json_fields_redacted, 2);
2401    }
2402
2403    #[test]
2404    fn redact_cassette_redacts_request_body_text() {
2405        let mut cassette = Cassette {
2406            version: "1.0".to_string(),
2407            test_name: "body_text".to_string(),
2408            recorded_at: "now".to_string(),
2409            interactions: vec![Interaction {
2410                request: RecordedRequest {
2411                    method: "POST".to_string(),
2412                    url: "https://example.com/token".to_string(),
2413                    headers: vec![(
2414                        "content-type".to_string(),
2415                        "application/x-www-form-urlencoded".to_string(),
2416                    )],
2417                    body: None,
2418                    body_text: Some(
2419                        "grant_type=refresh_token&client_secret=s3cr3t&scope=repo".to_string(),
2420                    ),
2421                },
2422                response: RecordedResponse {
2423                    status: 200,
2424                    headers: vec![],
2425                    body_chunks: vec![],
2426                    body_chunks_base64: None,
2427                },
2428            }],
2429        };
2430
2431        let summary = redact_cassette(&mut cassette);
2432        assert_eq!(summary.headers_redacted, 0);
2433        assert_eq!(summary.json_fields_redacted, 0);
2434
2435        let redacted = cassette.interactions[0]
2436            .request
2437            .body_text
2438            .as_deref()
2439            .expect("redacted body_text");
2440        let params: std::collections::HashMap<String, String> =
2441            url::form_urlencoded::parse(redacted.as_bytes())
2442                .map(|(key, value)| (key.into_owned(), value.into_owned()))
2443                .collect();
2444        assert_eq!(
2445            params.get("client_secret").map(String::as_str),
2446            Some(REDACTED)
2447        );
2448        assert_eq!(params.get("scope").map(String::as_str), Some("repo"));
2449    }
2450
2451    #[test]
2452    fn redact_cassette_redacts_single_field_request_body_text_without_content_type() {
2453        let mut cassette = Cassette {
2454            version: "1.0".to_string(),
2455            test_name: "single_field_body_text".to_string(),
2456            recorded_at: "now".to_string(),
2457            interactions: vec![Interaction {
2458                request: RecordedRequest {
2459                    method: "POST".to_string(),
2460                    url: "https://example.com/token".to_string(),
2461                    headers: vec![],
2462                    body: None,
2463                    body_text: Some("refresh_token=s3cr3t".to_string()),
2464                },
2465                response: RecordedResponse {
2466                    status: 200,
2467                    headers: vec![],
2468                    body_chunks: vec![],
2469                    body_chunks_base64: None,
2470                },
2471            }],
2472        };
2473
2474        let summary = redact_cassette(&mut cassette);
2475        assert_eq!(summary.headers_redacted, 0);
2476        assert_eq!(summary.json_fields_redacted, 0);
2477        assert_eq!(
2478            cassette.interactions[0].request.body_text.as_deref(),
2479            Some("refresh_token=%5BREDACTED%5D")
2480        );
2481    }
2482
2483    #[test]
2484    fn redact_cassette_redacts_single_encoded_sensitive_request_body_text_without_content_type() {
2485        let mut cassette = Cassette {
2486            version: "1.0".to_string(),
2487            test_name: "single_encoded_field_body_text".to_string(),
2488            recorded_at: "now".to_string(),
2489            interactions: vec![Interaction {
2490                request: RecordedRequest {
2491                    method: "POST".to_string(),
2492                    url: "https://example.com/token".to_string(),
2493                    headers: vec![],
2494                    body: None,
2495                    body_text: Some("client%5Fsecret=s3cr3t".to_string()),
2496                },
2497                response: RecordedResponse {
2498                    status: 200,
2499                    headers: vec![],
2500                    body_chunks: vec![],
2501                    body_chunks_base64: None,
2502                },
2503            }],
2504        };
2505
2506        let summary = redact_cassette(&mut cassette);
2507        assert_eq!(summary.headers_redacted, 0);
2508        assert_eq!(summary.json_fields_redacted, 0);
2509        assert_eq!(
2510            cassette.interactions[0].request.body_text.as_deref(),
2511            Some("client_secret=%5BREDACTED%5D")
2512        );
2513    }
2514
2515    // ─── VcrRecorder accessors ───────────────────────────────────────
2516
2517    #[test]
2518    fn recorder_new_with_sets_mode_and_path() {
2519        let temp_dir = tempfile::tempdir().expect("temp dir");
2520        let recorder = VcrRecorder::new_with("my::test_name", VcrMode::Playback, temp_dir.path());
2521        assert_eq!(recorder.mode(), VcrMode::Playback);
2522        assert!(
2523            recorder
2524                .cassette_path()
2525                .to_string_lossy()
2526                .contains("my__test_name.json")
2527        );
2528    }
2529
2530    // ========================================================================
2531    // Proptest — VCR cassette parser fuzz coverage (FUZZ-P1.7)
2532    // ========================================================================
2533
2534    mod proptest_vcr {
2535        use super::*;
2536        use proptest::prelude::*;
2537
2538        // ── Strategies ──────────────────────────────────────────────────
2539
2540        fn small_string() -> impl Strategy<Value = String> {
2541            prop_oneof![Just(String::new()), "[a-zA-Z0-9_]{1,16}", "[ -~]{0,32}",]
2542        }
2543
2544        fn url_string() -> impl Strategy<Value = String> {
2545            prop_oneof![
2546                Just("https://api.example.com/v1/messages".to_string()),
2547                Just(String::new()),
2548                Just("not-a-url".to_string()),
2549                Just("http://localhost:8080/test?q=1&b=2".to_string()),
2550                "https?://[a-z.]{1,20}/[a-z/]{0,20}",
2551                "[ -~]{0,64}",
2552            ]
2553        }
2554
2555        fn http_method() -> impl Strategy<Value = String> {
2556            prop_oneof![
2557                Just("GET".to_string()),
2558                Just("POST".to_string()),
2559                Just("PUT".to_string()),
2560                Just("DELETE".to_string()),
2561                Just("get".to_string()),
2562                Just("post".to_string()),
2563                "[A-Z]{1,8}",
2564                small_string(),
2565            ]
2566        }
2567
2568        fn header_pair() -> impl Strategy<Value = (String, String)> {
2569            let key = prop_oneof![
2570                Just("Content-Type".to_string()),
2571                Just("Authorization".to_string()),
2572                Just("x-api-key".to_string()),
2573                Just("X-Custom-Header".to_string()),
2574                "[a-zA-Z][a-zA-Z0-9-]{0,20}",
2575            ];
2576            let value = prop_oneof![
2577                Just("application/json".to_string()),
2578                Just("Bearer sk-test-123".to_string()),
2579                small_string(),
2580                // CRLF injection attempt
2581                Just("value\r\nInjected: header".to_string()),
2582            ];
2583            (key, value)
2584        }
2585
2586        fn json_value() -> impl Strategy<Value = Value> {
2587            let leaf = prop_oneof![
2588                Just(Value::Null),
2589                any::<bool>().prop_map(Value::Bool),
2590                any::<i64>().prop_map(|n| Value::Number(n.into())),
2591                small_string().prop_map(Value::String),
2592            ];
2593            leaf.prop_recursive(3, 32, 4, |inner| {
2594                prop_oneof![
2595                    prop::collection::vec(inner.clone(), 0..4).prop_map(Value::Array),
2596                    prop::collection::hash_map("[a-z_]{1,10}", inner, 0..4)
2597                        .prop_map(|m| Value::Object(m.into_iter().collect())),
2598                ]
2599            })
2600        }
2601
2602        fn recorded_request() -> impl Strategy<Value = RecordedRequest> {
2603            (
2604                http_method(),
2605                url_string(),
2606                prop::collection::vec(header_pair(), 0..4),
2607                prop::option::of(json_value()),
2608                prop::option::of(small_string()),
2609            )
2610                .prop_map(|(method, url, headers, body, body_text)| RecordedRequest {
2611                    method,
2612                    url,
2613                    headers,
2614                    body,
2615                    body_text,
2616                })
2617        }
2618
2619        fn base64_chunk() -> impl Strategy<Value = String> {
2620            prop_oneof![
2621                // Valid base64
2622                prop::collection::vec(any::<u8>(), 0..64)
2623                    .prop_map(|bytes| base64::engine::general_purpose::STANDARD.encode(&bytes)),
2624                // Invalid base64
2625                Just("not-valid-base64!!!".to_string()),
2626                Just("====".to_string()),
2627                Just(String::new()),
2628                "[ -~]{0,32}",
2629            ]
2630        }
2631
2632        fn recorded_response() -> impl Strategy<Value = RecordedResponse> {
2633            (
2634                any::<u16>(),
2635                prop::collection::vec(header_pair(), 0..4),
2636                prop::collection::vec(small_string(), 0..4),
2637                prop::option::of(prop::collection::vec(base64_chunk(), 0..4)),
2638            )
2639                .prop_map(|(status, headers, body_chunks, body_chunks_base64)| {
2640                    RecordedResponse {
2641                        status,
2642                        headers,
2643                        body_chunks,
2644                        body_chunks_base64,
2645                    }
2646                })
2647        }
2648
2649        // ── Property tests ──────────────────────────────────────────────
2650
2651        proptest! {
2652            #![proptest_config(ProptestConfig {
2653                cases: 256,
2654                max_shrink_iters: 100,
2655                .. ProptestConfig::default()
2656            })]
2657
2658            /// redact_json is idempotent: redacting twice yields the same result.
2659            #[test]
2660            fn redact_json_is_idempotent(value in json_value()) {
2661                let mut first = value;
2662                redact_json(&mut first);
2663                let mut second = first.clone();
2664                redact_json(&mut second);
2665                assert_eq!(first, second);
2666            }
2667
2668            /// redact_json never panics on arbitrary JSON values.
2669            #[test]
2670            fn redact_json_never_panics(mut value in json_value()) {
2671                let _ = redact_json(&mut value);
2672            }
2673
2674            /// request_matches is reflexive: a request matches itself.
2675            #[test]
2676            fn request_matches_is_reflexive(req in recorded_request()) {
2677                // Redact the request body to simulate cassette state
2678                // (cassettes always store redacted bodies).
2679                let mut cassette_req = req.clone();
2680                if let Some(body) = &mut cassette_req.body {
2681                    redact_json(body);
2682                }
2683                assert!(request_matches(&cassette_req, &req));
2684            }
2685
2686            /// request_matches never panics on arbitrary request pairs.
2687            #[test]
2688            fn request_matches_never_panics(
2689                a in recorded_request(),
2690                b in recorded_request()
2691            ) {
2692                let _ = request_matches(&a, &b);
2693            }
2694
2695            /// match_json_template never panics on arbitrary JSON value pairs.
2696            #[test]
2697            fn match_json_template_never_panics(
2698                a in json_value(),
2699                b in json_value()
2700            ) {
2701                let _ = match_json_template(&a, &b);
2702            }
2703
2704            /// match_json_template is reflexive: a value matches itself.
2705            #[test]
2706            fn match_json_template_is_reflexive(v in json_value()) {
2707                assert!(match_json_template(&v, &v));
2708            }
2709
2710            /// into_byte_stream never panics on arbitrary responses.
2711            #[test]
2712            fn into_byte_stream_never_panics(resp in recorded_response()) {
2713                let stream = resp.into_byte_stream();
2714                run_async(async move {
2715                    use futures::StreamExt;
2716                    let _results: Vec<_> = stream.collect().await;
2717                });
2718            }
2719
2720            /// Cassette serde round-trip: serialize then deserialize preserves
2721            /// the structure.
2722            #[test]
2723            fn cassette_serde_round_trip(
2724                version in small_string(),
2725                test_name in small_string(),
2726                recorded_at in small_string(),
2727                req in recorded_request(),
2728                resp in recorded_response()
2729            ) {
2730                let cassette = Cassette {
2731                    version,
2732                    test_name,
2733                    recorded_at,
2734                    interactions: vec![Interaction {
2735                        request: req,
2736                        response: resp,
2737                    }],
2738                };
2739                let json = serde_json::to_string(&cassette).expect("serialize");
2740                let reparsed: Cassette = serde_json::from_str(&json).expect("deserialize");
2741                assert_eq!(cassette.version, reparsed.version);
2742                assert_eq!(cassette.test_name, reparsed.test_name);
2743                assert_eq!(cassette.recorded_at, reparsed.recorded_at);
2744                assert_eq!(cassette.interactions.len(), reparsed.interactions.len());
2745            }
2746
2747            /// is_sensitive_key never panics on arbitrary strings.
2748            #[test]
2749            fn is_sensitive_key_never_panics(key in "[ -~]{0,64}") {
2750                let _ = is_sensitive_key(&key);
2751            }
2752
2753            /// base64 body_chunks_base64 takes precedence over body_chunks when
2754            /// both are present.
2755            #[test]
2756            fn base64_takes_precedence_over_text(
2757                text_chunks in prop::collection::vec(small_string(), 1..4),
2758                base64_chunks in prop::collection::vec(
2759                    prop::collection::vec(any::<u8>(), 0..32)
2760                        .prop_map(|b| base64::engine::general_purpose::STANDARD.encode(&b)),
2761                    1..4
2762                )
2763            ) {
2764                let expected_bytes: Vec<Vec<u8>> = base64_chunks.iter().map(|c| {
2765                    base64::engine::general_purpose::STANDARD.decode(c).unwrap()
2766                }).collect();
2767
2768                let resp = RecordedResponse {
2769                    status: 200,
2770                    headers: vec![],
2771                    body_chunks: text_chunks,
2772                    body_chunks_base64: Some(base64_chunks),
2773                };
2774                let results: Vec<std::result::Result<Vec<u8>, std::io::Error>> =
2775                    run_async(async move {
2776                        use futures::StreamExt;
2777                        resp.into_byte_stream().collect().await
2778                    });
2779                assert_eq!(results.len(), expected_bytes.len());
2780                for (result, expected) in results.iter().zip(&expected_bytes) {
2781                    assert_eq!(result.as_ref().unwrap(), expected);
2782                }
2783            }
2784        }
2785    }
2786}