1use 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#[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 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 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 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 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 return true;
716 };
717 let Some(incoming) = incoming else {
718 return false;
719 };
720 match_json_template(recorded, incoming)
721}
722
723fn 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 || ((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 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 #[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 #[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 assert_eq!(&hash[..6], "e3b0c4");
1443 }
1444
1445 #[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 assert!(is_sensitive_key("access_token"));
1464 assert!(is_sensitive_key("id_token"));
1465 assert!(is_sensitive_key("refresh_token"));
1466 assert!(!is_sensitive_key("max_tokens"));
1468 assert!(!is_sensitive_key("prompt_tokens"));
1469 assert!(!is_sensitive_key("completion_tokens"));
1470 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 let (idx, _) = find_interaction_from(&cassette, &req, 0).unwrap();
2104 assert_eq!(idx, 0);
2105
2106 let (idx, interaction) = find_interaction_from(&cassette, &req, 1).unwrap();
2108 assert_eq!(idx, 1);
2109 assert_eq!(interaction.response.status, 201);
2110
2111 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 #[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 #[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 #[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 #[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 #[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 #[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 mod proptest_vcr {
2535 use super::*;
2536 use proptest::prelude::*;
2537
2538 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 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 prop::collection::vec(any::<u8>(), 0..64)
2623 .prop_map(|bytes| base64::engine::general_purpose::STANDARD.encode(&bytes)),
2624 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 proptest! {
2652 #![proptest_config(ProptestConfig {
2653 cases: 256,
2654 max_shrink_iters: 100,
2655 .. ProptestConfig::default()
2656 })]
2657
2658 #[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 #[test]
2670 fn redact_json_never_panics(mut value in json_value()) {
2671 let _ = redact_json(&mut value);
2672 }
2673
2674 #[test]
2676 fn request_matches_is_reflexive(req in recorded_request()) {
2677 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 #[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 #[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 #[test]
2706 fn match_json_template_is_reflexive(v in json_value()) {
2707 assert!(match_json_template(&v, &v));
2708 }
2709
2710 #[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 #[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 #[test]
2749 fn is_sensitive_key_never_panics(key in "[ -~]{0,64}") {
2750 let _ = is_sensitive_key(&key);
2751 }
2752
2753 #[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}