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
48fn env_var(name: &str) -> Option<String> {
49 #[cfg(test)]
50 {
51 if let Ok(guard) = test_env_overrides().lock() {
52 if let Some(maybe_value) = guard.get(name) {
53 return maybe_value.clone();
55 }
56 }
57 }
58 std::env::var(name).ok()
59}
60
61#[cfg(test)]
62fn set_test_env_var(name: &str, value: Option<&str>) -> Option<String> {
63 let mut guard = test_env_overrides()
64 .lock()
65 .unwrap_or_else(std::sync::PoisonError::into_inner);
66 let previous = guard.get(name).and_then(Clone::clone);
67 guard.insert(name.to_string(), value.map(String::from));
69 previous
70}
71
72#[cfg(test)]
73fn restore_test_env_var(name: &str, previous: Option<String>) {
74 let mut guard = test_env_overrides()
75 .lock()
76 .unwrap_or_else(std::sync::PoisonError::into_inner);
77 match previous {
78 Some(value) => {
79 guard.insert(name.to_string(), Some(value));
80 }
81 None => {
82 guard.remove(name);
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum VcrMode {
91 Record,
92 Playback,
93 Auto,
94}
95
96impl VcrMode {
97 pub fn from_env() -> Result<Option<Self>> {
98 let Some(value) = env_var(VCR_ENV_MODE) else {
99 return Ok(None);
100 };
101 let mode = match value.to_ascii_lowercase().as_str() {
102 "record" => Self::Record,
103 "playback" => Self::Playback,
104 "auto" => Self::Auto,
105 _ => {
106 return Err(Error::config(format!(
107 "Invalid {VCR_ENV_MODE} value: {value}"
108 )));
109 }
110 };
111 Ok(Some(mode))
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Cassette {
117 pub version: String,
118 pub test_name: String,
119 pub recorded_at: String,
120 pub interactions: Vec<Interaction>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Interaction {
125 pub request: RecordedRequest,
126 pub response: RecordedResponse,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct RecordedRequest {
131 pub method: String,
132 pub url: String,
133 pub headers: Vec<(String, String)>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub body: Option<Value>,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub body_text: Option<String>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct RecordedResponse {
142 pub status: u16,
143 pub headers: Vec<(String, String)>,
144 pub body_chunks: Vec<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub body_chunks_base64: Option<Vec<String>>,
147}
148
149impl RecordedResponse {
150 pub fn into_byte_stream(
151 self,
152 ) -> BoxStream<'static, std::result::Result<Vec<u8>, std::io::Error>> {
153 if let Some(chunks) = self.body_chunks_base64 {
154 stream::iter(chunks.into_iter().map(|chunk| {
155 STANDARD
156 .decode(chunk)
157 .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
158 }))
159 .boxed()
160 } else {
161 stream::iter(
162 self.body_chunks
163 .into_iter()
164 .map(|chunk| Ok(chunk.into_bytes())),
165 )
166 .boxed()
167 }
168 }
169}
170
171#[derive(Debug, Clone)]
172pub struct VcrRecorder {
173 cassette_path: PathBuf,
174 mode: VcrMode,
175 test_name: String,
176 playback_cursor: Arc<AtomicUsize>,
177}
178
179impl VcrRecorder {
180 pub fn new(test_name: &str) -> Result<Self> {
181 let mode = VcrMode::from_env()?.unwrap_or_else(default_mode);
182 let cassette_dir =
183 env_var(VCR_ENV_DIR).map_or_else(|| PathBuf::from(DEFAULT_CASSETTE_DIR), PathBuf::from);
184 let cassette_name = sanitize_test_name(test_name);
185 let cassette_path = cassette_dir.join(format!("{cassette_name}.json"));
186 let recorder = Self {
187 cassette_path,
188 mode,
189 test_name: test_name.to_string(),
190 playback_cursor: Arc::new(AtomicUsize::new(0)),
191 };
192 info!(
193 mode = ?recorder.mode,
194 cassette_path = %recorder.cassette_path.display(),
195 test_name = %recorder.test_name,
196 "VCR recorder initialized"
197 );
198 Ok(recorder)
199 }
200
201 pub fn new_with(test_name: &str, mode: VcrMode, cassette_dir: impl AsRef<Path>) -> Self {
202 let cassette_name = sanitize_test_name(test_name);
203 let cassette_path = cassette_dir.as_ref().join(format!("{cassette_name}.json"));
204 Self {
205 cassette_path,
206 mode,
207 test_name: test_name.to_string(),
208 playback_cursor: Arc::new(AtomicUsize::new(0)),
209 }
210 }
211
212 pub const fn mode(&self) -> VcrMode {
213 self.mode
214 }
215
216 pub fn cassette_path(&self) -> &Path {
217 &self.cassette_path
218 }
219
220 pub async fn request_streaming_with<F, Fut, S>(
221 &self,
222 request: RecordedRequest,
223 send: F,
224 ) -> Result<RecordedResponse>
225 where
226 F: FnOnce() -> Fut,
227 Fut: std::future::Future<Output = Result<(u16, Vec<(String, String)>, S)>>,
228 S: futures::Stream<Item = std::result::Result<Vec<u8>, std::io::Error>> + Unpin,
229 {
230 let request_key = request_debug_key(&request);
231
232 match self.mode {
233 VcrMode::Playback => {
234 info!(
235 cassette_path = %self.cassette_path.display(),
236 request = %request_key,
237 "VCR playback request"
238 );
239 self.playback(&request)
240 }
241 VcrMode::Record => {
242 info!(
243 cassette_path = %self.cassette_path.display(),
244 request = %request_key,
245 "VCR recording request"
246 );
247 self.record_streaming_with(request, send).await
248 }
249 VcrMode::Auto => {
250 if self.cassette_path.exists() {
251 info!(
252 cassette_path = %self.cassette_path.display(),
253 request = %request_key,
254 "VCR auto mode: cassette exists, using playback"
255 );
256 self.playback(&request)
257 } else {
258 info!(
259 cassette_path = %self.cassette_path.display(),
260 request = %request_key,
261 "VCR auto mode: cassette missing, recording"
262 );
263 self.record_streaming_with(request, send).await
264 }
265 }
266 }
267 }
268
269 pub async fn record_streaming_with<F, Fut, S>(
270 &self,
271 request: RecordedRequest,
272 send: F,
273 ) -> Result<RecordedResponse>
274 where
275 F: FnOnce() -> Fut,
276 Fut: std::future::Future<Output = Result<(u16, Vec<(String, String)>, S)>>,
277 S: futures::Stream<Item = std::result::Result<Vec<u8>, std::io::Error>> + Unpin,
278 {
279 debug!(
280 cassette_path = %self.cassette_path.display(),
281 request = %request_debug_key(&request),
282 "VCR record: sending streaming HTTP request"
283 );
284 let (status, headers, mut stream) = send().await?;
285
286 let mut body_chunks = Vec::new();
287 let mut body_chunks_base64: Option<Vec<String>> = None;
288 let mut body_bytes = 0usize;
289 while let Some(chunk) = stream.next().await {
290 let chunk = chunk.map_err(|e| Error::api(format!("HTTP stream read failed: {e}")))?;
291 if chunk.is_empty() {
292 continue;
293 }
294 body_bytes = body_bytes.saturating_add(chunk.len());
295 if let Some(encoded) = body_chunks_base64.as_mut() {
296 encoded.push(STANDARD.encode(&chunk));
297 } else if let Ok(text) = std::str::from_utf8(&chunk) {
298 body_chunks.push(text.to_string());
299 } else {
300 let mut encoded = Vec::with_capacity(body_chunks.len() + 1);
301 for existing in &body_chunks {
302 encoded.push(STANDARD.encode(existing.as_bytes()));
303 }
304 encoded.push(STANDARD.encode(&chunk));
305 body_chunks.clear();
306 body_chunks_base64 = Some(encoded);
307 }
308 }
309
310 let recorded = RecordedResponse {
311 status,
312 headers,
313 body_chunks,
314 body_chunks_base64,
315 };
316 let chunk_count = recorded
317 .body_chunks_base64
318 .as_ref()
319 .map_or(recorded.body_chunks.len(), Vec::len);
320
321 info!(
322 cassette_path = %self.cassette_path.display(),
323 status = recorded.status,
324 header_count = recorded.headers.len(),
325 chunk_count,
326 body_bytes,
327 "VCR record: captured streaming response"
328 );
329
330 let mut cassette = if self.cassette_path.exists() {
331 load_cassette(&self.cassette_path)?
332 } else {
333 Cassette {
334 version: CASSETTE_VERSION.to_string(),
335 test_name: self.test_name.clone(),
336 recorded_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
337 interactions: Vec::new(),
338 }
339 };
340 cassette.test_name.clone_from(&self.test_name);
341 cassette.recorded_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
342 cassette.interactions.push(Interaction {
343 request,
344 response: recorded.clone(),
345 });
346
347 let redaction = redact_cassette(&mut cassette);
348 info!(
349 cassette_path = %self.cassette_path.display(),
350 headers_redacted = redaction.headers_redacted,
351 json_fields_redacted = redaction.json_fields_redacted,
352 "VCR record: redacted sensitive data"
353 );
354 save_cassette(&self.cassette_path, &cassette)?;
355 info!(
356 cassette_path = %self.cassette_path.display(),
357 "VCR record: saved cassette"
358 );
359
360 Ok(recorded)
361 }
362
363 fn playback(&self, request: &RecordedRequest) -> Result<RecordedResponse> {
364 let cassette = load_cassette(&self.cassette_path)?;
365 let start_index = self.playback_cursor.load(Ordering::SeqCst);
366 let Some((matched_index, interaction)) =
367 find_interaction_from(&cassette, request, start_index)
368 else {
369 let incoming_key = request_debug_key(request);
370 let recorded_keys: Vec<String> = cassette
371 .interactions
372 .iter()
373 .enumerate()
374 .map(|(idx, interaction)| {
375 format!("[{idx}] {}", request_debug_key(&interaction.request))
376 })
377 .collect();
378
379 warn!(
380 cassette_path = %self.cassette_path.display(),
381 request = %incoming_key,
382 recorded_count = recorded_keys.len(),
383 start_index,
384 "VCR playback: no matching interaction"
385 );
386
387 let mut message = format!(
388 "No matching interaction found in cassette {}.\nIncoming: {incoming_key}\nRecorded interactions ({}):\n",
389 self.cassette_path.display(),
390 recorded_keys.len()
391 );
392 for key in recorded_keys {
393 message.push_str(" ");
394 message.push_str(&key);
395 message.push('\n');
396 }
397
398 if let Ok(debug_path) = std::env::var("VCR_DEBUG_BODY_FILE") {
400 use std::fmt::Write as _;
401
402 let mut debug = String::new();
403 if let Some(body) = &request.body {
404 let mut redacted = body.clone();
405 redact_json(&mut redacted);
406 if let Ok(pretty) = serde_json::to_string_pretty(&redacted) {
407 debug.push_str("=== INCOMING (redacted) ===\n");
408 debug.push_str(&pretty);
409 debug.push('\n');
410 }
411 }
412 for (idx, interaction) in cassette.interactions.iter().enumerate() {
413 if let Some(body) = &interaction.request.body {
414 if let Ok(pretty) = serde_json::to_string_pretty(body) {
415 let _ = writeln!(debug, "=== RECORDED [{idx}] ===");
416 debug.push_str(&pretty);
417 debug.push('\n');
418 }
419 }
420 }
421 let _ = std::fs::write(&debug_path, &debug);
422 }
423
424 if env_truthy("VCR_DEBUG_BODY") {
425 use std::fmt::Write as _;
426
427 let mut incoming_body = request.body.clone();
428 if let Some(body) = &mut incoming_body {
429 redact_json(body);
430 }
431
432 if let Some(body) = &incoming_body {
433 if let Ok(pretty) = serde_json::to_string_pretty(body) {
434 message.push_str("\nIncoming JSON body (redacted):\n");
435 message.push_str(&pretty);
436 message.push('\n');
437 }
438 }
439
440 if let Some(body_text) = &request.body_text {
441 message.push_str("\nIncoming text body:\n");
442 message.push_str(body_text);
443 message.push('\n');
444 }
445
446 for (idx, interaction) in cassette.interactions.iter().enumerate() {
447 if let Some(body) = &interaction.request.body {
448 if let Ok(pretty) = serde_json::to_string_pretty(body) {
449 let _ = write!(message, "\nRecorded JSON body [{idx}]:\n");
450 message.push_str(&pretty);
451 message.push('\n');
452 }
453 }
454
455 if let Some(body_text) = &interaction.request.body_text {
456 let _ = write!(message, "\nRecorded text body [{idx}]:\n");
457 message.push_str(body_text);
458 message.push('\n');
459 }
460 }
461 }
462 message.push_str(
463 "Match criteria: method + url + body + body_text (headers ignored). If the request changed, re-record with VCR_MODE=record.",
464 );
465 return Err(Error::config(message));
466 };
467
468 info!(
469 cassette_path = %self.cassette_path.display(),
470 request = %request_debug_key(request),
471 "VCR playback: matched interaction"
472 );
473 self.playback_cursor
474 .store(matched_index + 1, Ordering::SeqCst);
475 Ok(interaction.response.clone())
476 }
477}
478
479fn default_mode() -> VcrMode {
480 if env_truthy("CI") {
481 VcrMode::Playback
482 } else {
483 VcrMode::Auto
484 }
485}
486
487fn env_truthy(name: &str) -> bool {
488 env_var(name).is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
489}
490
491fn sanitize_test_name(value: &str) -> String {
492 let mut out = String::with_capacity(value.len());
493 for ch in value.chars() {
494 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
495 out.push(ch);
496 } else {
497 out.push('_');
498 }
499 }
500 if out.is_empty() {
501 "vcr".to_string()
502 } else {
503 out
504 }
505}
506
507fn load_cassette(path: &Path) -> Result<Cassette> {
508 let content = std::fs::read_to_string(path)
509 .map_err(|e| Error::config(format!("Failed to read cassette {}: {e}", path.display())))?;
510 serde_json::from_str(&content)
511 .map_err(|e| Error::config(format!("Failed to parse cassette {}: {e}", path.display())))
512}
513
514fn save_cassette(path: &Path, cassette: &Cassette) -> Result<()> {
515 if let Some(parent) = path.parent() {
516 std::fs::create_dir_all(parent).map_err(|e| {
517 Error::config(format!(
518 "Failed to create cassette dir {}: {e}",
519 parent.display()
520 ))
521 })?;
522 }
523 let content = serde_json::to_string_pretty(cassette)
524 .map_err(|e| Error::config(format!("Failed to serialize cassette: {e}")))?;
525 std::fs::write(path, content)
526 .map_err(|e| Error::config(format!("Failed to write cassette {}: {e}", path.display())))?;
527 Ok(())
528}
529
530fn find_interaction_from<'a>(
531 cassette: &'a Cassette,
532 request: &RecordedRequest,
533 start: usize,
534) -> Option<(usize, &'a Interaction)> {
535 cassette
536 .interactions
537 .iter()
538 .enumerate()
539 .skip(start)
540 .find(|(_, interaction)| request_matches(&interaction.request, request))
541}
542
543fn request_debug_key(request: &RecordedRequest) -> String {
544 use std::fmt::Write as _;
545
546 let method = request.method.to_ascii_uppercase();
547 let mut out = format!("{method} {}", request.url);
548
549 if let Some(body) = &request.body {
550 let body_bytes = serde_json::to_vec(body).unwrap_or_default();
551 let hash = short_sha256(&body_bytes);
552 let _ = write!(out, " body_sha256={hash}");
553 } else {
554 out.push_str(" body_sha256=<none>");
555 }
556
557 if let Some(body_text) = &request.body_text {
558 let hash = short_sha256(body_text.as_bytes());
559 let _ = write!(
560 out,
561 " body_text_sha256={hash} body_text_len={}",
562 body_text.len()
563 );
564 } else {
565 out.push_str(" body_text_sha256=<none>");
566 }
567
568 out
569}
570
571fn short_sha256(bytes: &[u8]) -> String {
572 use std::fmt::Write as _;
573
574 let digest = Sha256::digest(bytes);
575 let mut out = String::with_capacity(12);
576 for b in &digest[..6] {
577 let _ = write!(out, "{b:02x}");
578 }
579 out
580}
581
582fn request_matches(recorded: &RecordedRequest, incoming: &RecordedRequest) -> bool {
583 if !recorded.method.eq_ignore_ascii_case(&incoming.method) {
584 return false;
585 }
586 if recorded.url != incoming.url {
587 return false;
588 }
589
590 let mut incoming_body = incoming.body.clone();
592 if let Some(body) = &mut incoming_body {
593 redact_json(body);
594 }
595
596 if !match_optional_json(recorded.body.as_ref(), incoming_body.as_ref()) {
597 return false;
598 }
599
600 if let Some(recorded_text) = recorded.body_text.as_ref() {
604 if incoming.body_text.as_deref() != Some(recorded_text) {
605 return false;
606 }
607 }
608
609 true
610}
611
612fn match_optional_json(recorded: Option<&Value>, incoming: Option<&Value>) -> bool {
613 let Some(recorded) = recorded else {
614 return true;
616 };
617 let Some(incoming) = incoming else {
618 return false;
619 };
620 match_json_template(recorded, incoming)
621}
622
623fn match_json_template(recorded: &Value, incoming: &Value) -> bool {
631 match (recorded, incoming) {
632 (Value::Object(recorded_obj), Value::Object(incoming_obj)) => {
633 for (key, recorded_value) in recorded_obj {
634 match incoming_obj.get(key) {
635 Some(incoming_value) => {
636 if !match_json_template(recorded_value, incoming_value) {
637 return false;
638 }
639 }
640 None => {
641 if !recorded_value.is_null() {
642 return false;
643 }
644 }
645 }
646 }
647 true
648 }
649 (Value::Array(recorded_items), Value::Array(incoming_items)) => {
650 if recorded_items.len() != incoming_items.len() {
651 return false;
652 }
653 recorded_items
654 .iter()
655 .zip(incoming_items)
656 .all(|(left, right)| match_json_template(left, right))
657 }
658 _ => recorded == incoming,
659 }
660}
661
662pub fn redact_cassette(cassette: &mut Cassette) -> RedactionSummary {
663 let sensitive_headers = sensitive_header_keys();
664 let mut summary = RedactionSummary::default();
665 for interaction in &mut cassette.interactions {
666 summary.headers_redacted +=
667 redact_headers(&mut interaction.request.headers, &sensitive_headers);
668 summary.headers_redacted +=
669 redact_headers(&mut interaction.response.headers, &sensitive_headers);
670 if let Some(body) = &mut interaction.request.body {
671 summary.json_fields_redacted += redact_json(body);
672 }
673 }
674 summary
675}
676
677fn sensitive_header_keys() -> HashSet<String> {
678 [
679 "authorization",
680 "x-api-key",
681 "api-key",
682 "x-goog-api-key",
683 "x-azure-api-key",
684 "proxy-authorization",
685 ]
686 .iter()
687 .map(ToString::to_string)
688 .collect()
689}
690
691fn redact_headers(headers: &mut Vec<(String, String)>, sensitive: &HashSet<String>) -> usize {
692 let mut count = 0usize;
693 for (name, value) in headers {
694 if sensitive.contains(&name.to_ascii_lowercase()) {
695 count += 1;
696 *value = REDACTED.to_string();
697 }
698 }
699 count
700}
701
702fn redact_json(value: &mut Value) -> usize {
703 match value {
704 Value::Object(map) => {
705 let mut count = 0usize;
706 for (key, entry) in map.iter_mut() {
707 if is_sensitive_key(key) {
708 *entry = Value::String(REDACTED.to_string());
709 count += 1;
710 } else {
711 count += redact_json(entry);
712 }
713 }
714 count
715 }
716 Value::Array(items) => {
717 let mut count = 0usize;
718 for item in items {
719 count += redact_json(item);
720 }
721 count
722 }
723 _ => 0usize,
724 }
725}
726
727fn is_sensitive_key(key: &str) -> bool {
728 let key = key.to_ascii_lowercase();
729 key.contains("api_key")
730 || key.contains("apikey")
731 || key.contains("authorization")
732 || ((key.contains("token") && !key.contains("tokens"))
736 || key.contains("access_tokens")
737 || key.contains("refresh_tokens")
738 || key.contains("id_tokens"))
739 || key.contains("secret")
740 || key.contains("password")
741}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
746 use std::future::Future;
747 use std::sync::{Mutex, OnceLock};
748
749 type ByteStream = BoxStream<'static, std::result::Result<Vec<u8>, std::io::Error>>;
750
751 fn env_test_lock() -> &'static Mutex<()> {
752 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
753 LOCK.get_or_init(|| Mutex::new(()))
754 }
755
756 fn lock_env() -> std::sync::MutexGuard<'static, ()> {
759 env_test_lock()
760 .lock()
761 .unwrap_or_else(std::sync::PoisonError::into_inner)
762 }
763
764 #[test]
765 fn cassette_round_trip() {
766 let cassette = Cassette {
767 version: CASSETTE_VERSION.to_string(),
768 test_name: "round_trip".to_string(),
769 recorded_at: "2026-02-03T00:00:00.000Z".to_string(),
770 interactions: vec![Interaction {
771 request: RecordedRequest {
772 method: "POST".to_string(),
773 url: "https://example.com".to_string(),
774 headers: vec![("authorization".to_string(), "secret".to_string())],
775 body: Some(serde_json::json!({"prompt": "hello"})),
776 body_text: None,
777 },
778 response: RecordedResponse {
779 status: 200,
780 headers: vec![("x-api-key".to_string(), "secret".to_string())],
781 body_chunks: vec!["event: message\n\n".to_string()],
782 body_chunks_base64: None,
783 },
784 }],
785 };
786
787 let serialized = serde_json::to_string(&cassette).expect("serialize cassette");
788 let parsed: Cassette = serde_json::from_str(&serialized).expect("parse cassette");
789 assert_eq!(parsed.version, CASSETTE_VERSION);
790 assert_eq!(parsed.test_name, "round_trip");
791 assert_eq!(parsed.interactions.len(), 1);
792 }
793
794 #[test]
795 fn matches_interaction_on_method_url_body() {
796 let recorded = RecordedRequest {
797 method: "POST".to_string(),
798 url: "https://example.com".to_string(),
799 headers: vec![],
800 body: Some(serde_json::json!({"a": 1})),
801 body_text: None,
802 };
803 let incoming = RecordedRequest {
804 method: "post".to_string(),
805 url: "https://example.com".to_string(),
806 headers: vec![("x-api-key".to_string(), "secret".to_string())],
807 body: Some(serde_json::json!({"a": 1})),
808 body_text: None,
809 };
810 assert!(request_matches(&recorded, &incoming));
811 }
812
813 #[test]
814 fn oauth_refresh_invalid_matches_after_redaction() {
815 let cassette_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
816 .join("tests/fixtures/vcr/oauth_refresh_invalid.json");
817 let cassette = load_cassette(&cassette_path).expect("load cassette");
818 let recorded = &cassette.interactions.first().expect("interaction").request;
819 let recorded_body = recorded.body.as_ref().expect("recorded body");
820 let client_id = recorded_body
821 .get("client_id")
822 .and_then(serde_json::Value::as_str)
823 .expect("client_id string");
824
825 let incoming = RecordedRequest {
826 method: "POST".to_string(),
827 url: recorded.url.clone(),
828 headers: Vec::new(),
829 body: Some(serde_json::json!({
830 "grant_type": "refresh_token",
831 "client_id": client_id,
832 "refresh_token": "refresh-invalid",
833 })),
834 body_text: None,
835 };
836
837 assert!(request_matches(recorded, &incoming));
838 }
839
840 #[test]
841 fn redacts_sensitive_headers_and_body_fields() {
842 let mut cassette = Cassette {
843 version: CASSETTE_VERSION.to_string(),
844 test_name: "redact".to_string(),
845 recorded_at: "2026-02-03T00:00:00.000Z".to_string(),
846 interactions: vec![Interaction {
847 request: RecordedRequest {
848 method: "POST".to_string(),
849 url: "https://example.com".to_string(),
850 headers: vec![("Authorization".to_string(), "secret".to_string())],
851 body: Some(serde_json::json!({"api_key": "secret", "nested": {"token": "t"}})),
852 body_text: None,
853 },
854 response: RecordedResponse {
855 status: 200,
856 headers: vec![("x-api-key".to_string(), "secret".to_string())],
857 body_chunks: vec![],
858 body_chunks_base64: None,
859 },
860 }],
861 };
862
863 let summary = redact_cassette(&mut cassette);
864
865 let request = &cassette.interactions[0].request;
866 assert_eq!(request.headers[0].1, REDACTED);
867 let body = request.body.as_ref().expect("body exists");
868 assert_eq!(body["api_key"], REDACTED);
869 assert_eq!(body["nested"]["token"], REDACTED);
870 assert_eq!(summary.headers_redacted, 2);
871 assert_eq!(summary.json_fields_redacted, 2);
872 }
873
874 #[test]
875 fn record_and_playback_cycle() {
876 let temp_dir = tempfile::tempdir().expect("temp dir");
877 let cassette_dir = temp_dir.path().to_path_buf();
878
879 let request = RecordedRequest {
880 method: "POST".to_string(),
881 url: "https://example.com".to_string(),
882 headers: vec![("content-type".to_string(), "application/json".to_string())],
883 body: Some(serde_json::json!({"prompt": "hello"})),
884 body_text: None,
885 };
886
887 let recorded = run_async({
888 let cassette_dir = cassette_dir.clone();
889 let request = request.clone();
890 async move {
891 let recorder =
892 VcrRecorder::new_with("record_playback", VcrMode::Record, &cassette_dir);
893 recorder
894 .record_streaming_with(request.clone(), || async {
895 let recorded = RecordedResponse {
896 status: 200,
897 headers: vec![(
898 "content-type".to_string(),
899 "text/event-stream".to_string(),
900 )],
901 body_chunks: vec!["event: message\ndata: ok\n\n".to_string()],
902 body_chunks_base64: None,
903 };
904 Ok((
905 recorded.status,
906 recorded.headers.clone(),
907 recorded.into_byte_stream(),
908 ))
909 })
910 .await
911 .expect("record")
912 }
913 });
914
915 assert_eq!(recorded.status, 200);
916 assert_eq!(recorded.body_chunks.len(), 1);
917
918 let playback = run_async(async move {
919 let recorder =
920 VcrRecorder::new_with("record_playback", VcrMode::Playback, &cassette_dir);
921 recorder
922 .request_streaming_with::<_, _, ByteStream>(request, || async {
923 Err(Error::config("Unexpected record in playback mode"))
924 })
925 .await
926 .expect("playback")
927 });
928
929 assert_eq!(playback.body_chunks.len(), 1);
930 assert!(playback.body_chunks[0].contains("event: message"));
931 }
932
933 #[test]
934 fn vcr_mode_from_env_values_and_invalid() {
935 let _lock = lock_env();
936 let previous = set_test_env_var(VCR_ENV_MODE, None);
937 assert_eq!(VcrMode::from_env().expect("unset mode"), None);
938 restore_test_env_var(VCR_ENV_MODE, previous);
939
940 for (raw, expected) in [
941 ("record", VcrMode::Record),
942 ("PLAYBACK", VcrMode::Playback),
943 ("Auto", VcrMode::Auto),
944 ] {
945 let previous = set_test_env_var(VCR_ENV_MODE, Some(raw));
946 assert_eq!(VcrMode::from_env().expect("valid mode"), Some(expected));
947 restore_test_env_var(VCR_ENV_MODE, previous);
948 }
949
950 let previous = set_test_env_var(VCR_ENV_MODE, Some("invalid-mode"));
951 let err = VcrMode::from_env().expect_err("invalid mode should fail");
952 assert!(
953 err.to_string()
954 .contains("Invalid VCR_MODE value: invalid-mode"),
955 "unexpected error: {err}"
956 );
957 restore_test_env_var(VCR_ENV_MODE, previous);
958 }
959
960 #[test]
961 fn auto_mode_records_missing_cassette_then_replays_existing() {
962 let temp_dir = tempfile::tempdir().expect("temp dir");
963 let cassette_dir = temp_dir.path().to_path_buf();
964 let cassette_path = cassette_dir.join("auto_mode_cycle.json");
965
966 let request = RecordedRequest {
967 method: "POST".to_string(),
968 url: "https://example.com/auto".to_string(),
969 headers: vec![("content-type".to_string(), "application/json".to_string())],
970 body: Some(serde_json::json!({"prompt": "first"})),
971 body_text: None,
972 };
973
974 let first = run_async({
975 let request = request.clone();
976 let cassette_dir = cassette_dir.clone();
977 async move {
978 let recorder =
979 VcrRecorder::new_with("auto_mode_cycle", VcrMode::Auto, cassette_dir);
980 recorder
981 .request_streaming_with(request, || async {
982 let recorded = RecordedResponse {
983 status: 201,
984 headers: vec![("x-source".to_string(), "record".to_string())],
985 body_chunks: vec!["chunk-one".to_string()],
986 body_chunks_base64: None,
987 };
988 Ok((
989 recorded.status,
990 recorded.headers.clone(),
991 recorded.into_byte_stream(),
992 ))
993 })
994 .await
995 .expect("auto record")
996 }
997 });
998
999 assert_eq!(first.status, 201);
1000 assert!(
1001 cassette_path.exists(),
1002 "cassette should be written in auto mode"
1003 );
1004
1005 let replay = run_async({
1006 async move {
1007 let recorder =
1008 VcrRecorder::new_with("auto_mode_cycle", VcrMode::Auto, cassette_dir);
1009 recorder
1010 .request_streaming_with::<_, _, ByteStream>(request, || async {
1011 Err(Error::config(
1012 "send callback should not run during auto playback",
1013 ))
1014 })
1015 .await
1016 .expect("auto playback")
1017 }
1018 });
1019
1020 assert_eq!(replay.status, 201);
1021 assert_eq!(replay.body_chunks, vec!["chunk-one".to_string()]);
1022 }
1023
1024 #[test]
1025 fn playback_mismatch_returns_strict_error_with_debug_hashes() {
1026 let temp_dir = tempfile::tempdir().expect("temp dir");
1027 let cassette_dir = temp_dir.path().to_path_buf();
1028
1029 let recorded_request = RecordedRequest {
1030 method: "POST".to_string(),
1031 url: "https://example.com/strict".to_string(),
1032 headers: vec![("content-type".to_string(), "application/json".to_string())],
1033 body: Some(serde_json::json!({"prompt": "expected"})),
1034 body_text: Some("expected-body".to_string()),
1035 };
1036
1037 run_async({
1038 let cassette_dir = cassette_dir.clone();
1039 async move {
1040 let recorder =
1041 VcrRecorder::new_with("strict_mismatch", VcrMode::Record, cassette_dir);
1042 recorder
1043 .request_streaming_with(recorded_request, || async {
1044 let recorded = RecordedResponse {
1045 status: 200,
1046 headers: vec![("content-type".to_string(), "text/plain".to_string())],
1047 body_chunks: vec!["ok".to_string()],
1048 body_chunks_base64: None,
1049 };
1050 Ok((
1051 recorded.status,
1052 recorded.headers.clone(),
1053 recorded.into_byte_stream(),
1054 ))
1055 })
1056 .await
1057 .expect("record strict cassette")
1058 }
1059 });
1060
1061 let mismatched_request = RecordedRequest {
1062 method: "POST".to_string(),
1063 url: "https://example.com/strict".to_string(),
1064 headers: vec![],
1065 body: Some(serde_json::json!({"prompt": "different"})),
1066 body_text: Some("different-body".to_string()),
1067 };
1068
1069 let err = run_async({
1070 async move {
1071 let recorder =
1072 VcrRecorder::new_with("strict_mismatch", VcrMode::Playback, cassette_dir);
1073 recorder
1074 .request_streaming_with::<_, _, ByteStream>(mismatched_request, || async {
1075 Err(Error::config(
1076 "send callback should not execute during playback mismatch",
1077 ))
1078 })
1079 .await
1080 .expect_err("mismatch should fail in playback mode")
1081 }
1082 });
1083
1084 let msg = err.to_string();
1085 assert!(
1086 msg.contains("No matching interaction found in cassette"),
1087 "unexpected error message: {msg}"
1088 );
1089 assert!(msg.contains("Incoming: POST https://example.com/strict"));
1090 assert!(msg.contains("body_sha256="));
1091 assert!(msg.contains("body_text_sha256="));
1092 assert!(msg.contains("Match criteria: method + url + body + body_text"));
1093 }
1094
1095 #[test]
1096 fn test_env_override_helpers_set_and_restore_values() {
1097 const TEST_VAR: &str = "PI_AGENT_VCR_TEST_ENV_OVERRIDE";
1098 let _lock = lock_env();
1099
1100 let original = set_test_env_var(TEST_VAR, None);
1101 assert_eq!(env_var(TEST_VAR), None);
1102
1103 let previous = set_test_env_var(TEST_VAR, Some("override-value"));
1104 assert_eq!(previous, None);
1105 assert_eq!(env_var(TEST_VAR).as_deref(), Some("override-value"));
1106
1107 restore_test_env_var(TEST_VAR, previous);
1108 assert_eq!(env_var(TEST_VAR), None);
1109
1110 restore_test_env_var(TEST_VAR, original);
1111 }
1112
1113 fn run_async<T>(future: impl Future<Output = T> + Send + 'static) -> T
1114 where
1115 T: Send + 'static,
1116 {
1117 let runtime = asupersync::runtime::RuntimeBuilder::new()
1118 .blocking_threads(1, 2)
1119 .build()
1120 .expect("build runtime");
1121 let join = runtime.handle().spawn(future);
1122 runtime.block_on(join)
1123 }
1124
1125 #[test]
1128 fn sanitize_preserves_alphanumeric_and_dash_underscore() {
1129 assert_eq!(sanitize_test_name("hello-world_123"), "hello-world_123");
1130 }
1131
1132 #[test]
1133 fn sanitize_replaces_special_chars() {
1134 assert_eq!(sanitize_test_name("a/b::c d.e"), "a_b__c_d_e");
1135 }
1136
1137 #[test]
1138 fn sanitize_empty_returns_vcr() {
1139 assert_eq!(sanitize_test_name(""), "vcr");
1140 }
1141
1142 #[test]
1143 fn sanitize_all_special_returns_underscores() {
1144 assert_eq!(sanitize_test_name("..."), "___");
1145 }
1146
1147 #[test]
1148 fn sanitize_unicode_replaced() {
1149 assert_eq!(sanitize_test_name("café"), "caf_");
1150 }
1151
1152 #[test]
1155 fn short_sha256_deterministic() {
1156 let a = short_sha256(b"hello");
1157 let b = short_sha256(b"hello");
1158 assert_eq!(a, b);
1159 }
1160
1161 #[test]
1162 fn short_sha256_length() {
1163 let hash = short_sha256(b"test data");
1164 assert_eq!(hash.len(), 12, "6 bytes = 12 hex chars");
1165 }
1166
1167 #[test]
1168 fn short_sha256_different_inputs() {
1169 let a = short_sha256(b"alpha");
1170 let b = short_sha256(b"beta");
1171 assert_ne!(a, b);
1172 }
1173
1174 #[test]
1175 fn short_sha256_empty_input() {
1176 let hash = short_sha256(b"");
1177 assert_eq!(hash.len(), 12);
1178 assert_eq!(&hash[..6], "e3b0c4");
1180 }
1181
1182 #[test]
1185 fn sensitive_key_api_key() {
1186 assert!(is_sensitive_key("api_key"));
1187 assert!(is_sensitive_key("x_api_key"));
1188 assert!(is_sensitive_key("MY_APIKEY"));
1189 }
1190
1191 #[test]
1192 fn sensitive_key_authorization() {
1193 assert!(is_sensitive_key("authorization"));
1194 assert!(is_sensitive_key("Authorization"));
1195 }
1196
1197 #[test]
1198 fn sensitive_key_token_but_not_tokens() {
1199 assert!(is_sensitive_key("access_token"));
1201 assert!(is_sensitive_key("id_token"));
1202 assert!(is_sensitive_key("refresh_token"));
1203 assert!(!is_sensitive_key("max_tokens"));
1205 assert!(!is_sensitive_key("prompt_tokens"));
1206 assert!(!is_sensitive_key("completion_tokens"));
1207 assert!(is_sensitive_key("access_tokens"));
1209 assert!(is_sensitive_key("refresh_tokens"));
1210 }
1211
1212 #[test]
1213 fn sensitive_key_secret_and_password() {
1214 assert!(is_sensitive_key("client_secret"));
1215 assert!(is_sensitive_key("password"));
1216 assert!(is_sensitive_key("db_password_hash"));
1217 }
1218
1219 #[test]
1220 fn sensitive_key_safe_keys() {
1221 assert!(!is_sensitive_key("model"));
1222 assert!(!is_sensitive_key("content"));
1223 assert!(!is_sensitive_key("messages"));
1224 assert!(!is_sensitive_key("temperature"));
1225 }
1226
1227 #[test]
1230 fn redact_json_flat_object() {
1231 let mut val = serde_json::json!({"api_key": "sk-123", "model": "gpt-4"});
1232 let count = redact_json(&mut val);
1233 assert_eq!(count, 1);
1234 assert_eq!(val["api_key"], REDACTED);
1235 assert_eq!(val["model"], "gpt-4");
1236 }
1237
1238 #[test]
1239 fn redact_json_nested() {
1240 let mut val = serde_json::json!({
1241 "config": {
1242 "secret": "hidden",
1243 "name": "test"
1244 }
1245 });
1246 let count = redact_json(&mut val);
1247 assert_eq!(count, 1);
1248 assert_eq!(val["config"]["secret"], REDACTED);
1249 assert_eq!(val["config"]["name"], "test");
1250 }
1251
1252 #[test]
1253 fn redact_json_array_of_objects() {
1254 let mut val = serde_json::json!([
1255 {"api_key": "a"},
1256 {"api_key": "b"},
1257 {"safe": "c"}
1258 ]);
1259 let count = redact_json(&mut val);
1260 assert_eq!(count, 2);
1261 assert_eq!(val[0]["api_key"], REDACTED);
1262 assert_eq!(val[1]["api_key"], REDACTED);
1263 assert_eq!(val[2]["safe"], "c");
1264 }
1265
1266 #[test]
1267 fn redact_json_scalar_returns_zero() {
1268 let mut val = serde_json::json!("just a string");
1269 assert_eq!(redact_json(&mut val), 0);
1270 let mut val = serde_json::json!(42);
1271 assert_eq!(redact_json(&mut val), 0);
1272 let mut val = serde_json::json!(null);
1273 assert_eq!(redact_json(&mut val), 0);
1274 }
1275
1276 #[test]
1277 fn redact_json_empty_object() {
1278 let mut val = serde_json::json!({});
1279 assert_eq!(redact_json(&mut val), 0);
1280 }
1281
1282 #[test]
1285 fn redact_headers_case_insensitive() {
1286 let sensitive = sensitive_header_keys();
1287 let mut headers = vec![
1288 ("Authorization".to_string(), "Bearer tok".to_string()),
1289 ("X-Api-Key".to_string(), "key".to_string()),
1290 ("Content-Type".to_string(), "application/json".to_string()),
1291 ];
1292 let count = redact_headers(&mut headers, &sensitive);
1293 assert_eq!(count, 2);
1294 assert_eq!(headers[0].1, REDACTED);
1295 assert_eq!(headers[1].1, REDACTED);
1296 assert_eq!(headers[2].1, "application/json");
1297 }
1298
1299 #[test]
1300 fn redact_headers_empty() {
1301 let sensitive = sensitive_header_keys();
1302 let mut headers = vec![];
1303 assert_eq!(redact_headers(&mut headers, &sensitive), 0);
1304 }
1305
1306 #[test]
1307 fn redact_headers_all_sensitive_keys() {
1308 let sensitive = sensitive_header_keys();
1309 let keys = [
1310 "authorization",
1311 "x-api-key",
1312 "api-key",
1313 "x-goog-api-key",
1314 "x-azure-api-key",
1315 "proxy-authorization",
1316 ];
1317 let mut headers: Vec<(String, String)> = keys
1318 .iter()
1319 .map(|k| (k.to_string(), "secret".to_string()))
1320 .collect();
1321 let count = redact_headers(&mut headers, &sensitive);
1322 assert_eq!(count, 6);
1323 for (_, val) in &headers {
1324 assert_eq!(val, REDACTED);
1325 }
1326 }
1327
1328 #[test]
1331 fn request_debug_key_with_body() {
1332 let req = RecordedRequest {
1333 method: "post".to_string(),
1334 url: "https://api.example.com/v1/chat".to_string(),
1335 headers: vec![],
1336 body: Some(serde_json::json!({"prompt": "hello"})),
1337 body_text: None,
1338 };
1339 let key = request_debug_key(&req);
1340 assert!(key.starts_with("POST https://api.example.com/v1/chat"));
1341 assert!(key.contains("body_sha256="));
1342 assert!(key.contains("body_text_sha256=<none>"));
1343 }
1344
1345 #[test]
1346 fn request_debug_key_no_body() {
1347 let req = RecordedRequest {
1348 method: "GET".to_string(),
1349 url: "https://example.com".to_string(),
1350 headers: vec![],
1351 body: None,
1352 body_text: None,
1353 };
1354 let key = request_debug_key(&req);
1355 assert!(key.contains("body_sha256=<none>"));
1356 assert!(key.contains("body_text_sha256=<none>"));
1357 }
1358
1359 #[test]
1360 fn request_debug_key_with_body_text() {
1361 let req = RecordedRequest {
1362 method: "POST".to_string(),
1363 url: "https://example.com".to_string(),
1364 headers: vec![],
1365 body: None,
1366 body_text: Some("raw text body".to_string()),
1367 };
1368 let key = request_debug_key(&req);
1369 assert!(key.contains("body_text_sha256="));
1370 assert!(key.contains("body_text_len=13"));
1371 assert!(!key.contains("body_text_sha256=<none>"));
1372 }
1373
1374 #[test]
1377 fn json_template_exact_scalar_match() {
1378 let a = serde_json::json!("hello");
1379 let b = serde_json::json!("hello");
1380 assert!(match_json_template(&a, &b));
1381 }
1382
1383 #[test]
1384 fn json_template_scalar_mismatch() {
1385 let a = serde_json::json!("hello");
1386 let b = serde_json::json!("world");
1387 assert!(!match_json_template(&a, &b));
1388 }
1389
1390 #[test]
1391 fn json_template_number_match() {
1392 let a = serde_json::json!(42);
1393 let b = serde_json::json!(42);
1394 assert!(match_json_template(&a, &b));
1395 }
1396
1397 #[test]
1398 fn json_template_object_extra_incoming_keys_ok() {
1399 let recorded = serde_json::json!({"model": "gpt-4"});
1400 let incoming = serde_json::json!({"model": "gpt-4", "extra": "ignored"});
1401 assert!(match_json_template(&recorded, &incoming));
1402 }
1403
1404 #[test]
1405 fn json_template_object_missing_incoming_key_fails() {
1406 let recorded = serde_json::json!({"model": "gpt-4", "required": true});
1407 let incoming = serde_json::json!({"model": "gpt-4"});
1408 assert!(!match_json_template(&recorded, &incoming));
1409 }
1410
1411 #[test]
1412 fn json_template_null_matches_missing_key() {
1413 let recorded = serde_json::json!({"model": "gpt-4", "optional": null});
1414 let incoming = serde_json::json!({"model": "gpt-4"});
1415 assert!(match_json_template(&recorded, &incoming));
1416 }
1417
1418 #[test]
1419 fn json_template_null_matches_null() {
1420 let recorded = serde_json::json!({"field": null});
1421 let incoming = serde_json::json!({"field": null});
1422 assert!(match_json_template(&recorded, &incoming));
1423 }
1424
1425 #[test]
1426 fn json_template_array_same_length_matches() {
1427 let recorded = serde_json::json!([1, 2, 3]);
1428 let incoming = serde_json::json!([1, 2, 3]);
1429 assert!(match_json_template(&recorded, &incoming));
1430 }
1431
1432 #[test]
1433 fn json_template_array_different_length_fails() {
1434 let recorded = serde_json::json!([1, 2]);
1435 let incoming = serde_json::json!([1, 2, 3]);
1436 assert!(!match_json_template(&recorded, &incoming));
1437 }
1438
1439 #[test]
1440 fn json_template_array_element_mismatch_fails() {
1441 let recorded = serde_json::json!([1, 2, 3]);
1442 let incoming = serde_json::json!([1, 99, 3]);
1443 assert!(!match_json_template(&recorded, &incoming));
1444 }
1445
1446 #[test]
1447 fn json_template_nested_object_in_array() {
1448 let recorded = serde_json::json!([{"role": "user"}, {"role": "assistant"}]);
1449 let incoming = serde_json::json!([
1450 {"role": "user", "id": "1"},
1451 {"role": "assistant", "id": "2"}
1452 ]);
1453 assert!(match_json_template(&recorded, &incoming));
1454 }
1455
1456 #[test]
1457 fn json_template_type_mismatch() {
1458 let recorded = serde_json::json!({"a": "string"});
1459 let incoming = serde_json::json!({"a": 42});
1460 assert!(!match_json_template(&recorded, &incoming));
1461 }
1462
1463 #[test]
1466 fn optional_json_none_recorded_matches_anything() {
1467 assert!(match_optional_json(None, None));
1468 assert!(match_optional_json(
1469 None,
1470 Some(&serde_json::json!({"anything": true}))
1471 ));
1472 }
1473
1474 #[test]
1475 fn optional_json_some_recorded_none_incoming_fails() {
1476 let recorded = serde_json::json!({"a": 1});
1477 assert!(!match_optional_json(Some(&recorded), None));
1478 }
1479
1480 #[test]
1483 fn request_matches_method_case_insensitive() {
1484 let recorded = RecordedRequest {
1485 method: "POST".to_string(),
1486 url: "https://x.com".to_string(),
1487 headers: vec![],
1488 body: None,
1489 body_text: None,
1490 };
1491 let incoming = RecordedRequest {
1492 method: "post".to_string(),
1493 url: "https://x.com".to_string(),
1494 headers: vec![],
1495 body: None,
1496 body_text: None,
1497 };
1498 assert!(request_matches(&recorded, &incoming));
1499 }
1500
1501 #[test]
1502 fn request_matches_url_mismatch() {
1503 let recorded = RecordedRequest {
1504 method: "GET".to_string(),
1505 url: "https://a.com".to_string(),
1506 headers: vec![],
1507 body: None,
1508 body_text: None,
1509 };
1510 let incoming = RecordedRequest {
1511 method: "GET".to_string(),
1512 url: "https://b.com".to_string(),
1513 headers: vec![],
1514 body: None,
1515 body_text: None,
1516 };
1517 assert!(!request_matches(&recorded, &incoming));
1518 }
1519
1520 #[test]
1521 fn request_matches_body_text_constraint() {
1522 let recorded = RecordedRequest {
1523 method: "POST".to_string(),
1524 url: "https://x.com".to_string(),
1525 headers: vec![],
1526 body: None,
1527 body_text: Some("expected".to_string()),
1528 };
1529 let mut incoming = recorded.clone();
1530 incoming.body_text = Some("expected".to_string());
1531 assert!(request_matches(&recorded, &incoming));
1532
1533 incoming.body_text = Some("different".to_string());
1534 assert!(!request_matches(&recorded, &incoming));
1535 }
1536
1537 #[test]
1538 fn request_matches_missing_recorded_body_text_is_wildcard() {
1539 let recorded = RecordedRequest {
1540 method: "POST".to_string(),
1541 url: "https://x.com".to_string(),
1542 headers: vec![],
1543 body: None,
1544 body_text: None,
1545 };
1546 let incoming = RecordedRequest {
1547 method: "POST".to_string(),
1548 url: "https://x.com".to_string(),
1549 headers: vec![],
1550 body: None,
1551 body_text: Some("anything".to_string()),
1552 };
1553 assert!(request_matches(&recorded, &incoming));
1554 }
1555
1556 #[test]
1557 fn request_matches_redacts_incoming_body() {
1558 let recorded = RecordedRequest {
1560 method: "POST".to_string(),
1561 url: "https://x.com".to_string(),
1562 headers: vec![],
1563 body: Some(serde_json::json!({"api_key": REDACTED, "model": "gpt-4"})),
1564 body_text: None,
1565 };
1566 let incoming = RecordedRequest {
1567 method: "POST".to_string(),
1568 url: "https://x.com".to_string(),
1569 headers: vec![],
1570 body: Some(serde_json::json!({"api_key": "sk-real-secret", "model": "gpt-4"})),
1571 body_text: None,
1572 };
1573 assert!(request_matches(&recorded, &incoming));
1574 }
1575
1576 #[test]
1579 fn find_interaction_from_start() {
1580 let cassette = Cassette {
1581 version: "1.0".to_string(),
1582 test_name: "test".to_string(),
1583 recorded_at: "2026-01-01".to_string(),
1584 interactions: vec![
1585 Interaction {
1586 request: RecordedRequest {
1587 method: "GET".to_string(),
1588 url: "https://a.com".to_string(),
1589 headers: vec![],
1590 body: None,
1591 body_text: None,
1592 },
1593 response: RecordedResponse {
1594 status: 200,
1595 headers: vec![],
1596 body_chunks: vec!["a".to_string()],
1597 body_chunks_base64: None,
1598 },
1599 },
1600 Interaction {
1601 request: RecordedRequest {
1602 method: "GET".to_string(),
1603 url: "https://b.com".to_string(),
1604 headers: vec![],
1605 body: None,
1606 body_text: None,
1607 },
1608 response: RecordedResponse {
1609 status: 201,
1610 headers: vec![],
1611 body_chunks: vec!["b".to_string()],
1612 body_chunks_base64: None,
1613 },
1614 },
1615 ],
1616 };
1617
1618 let req_b = RecordedRequest {
1619 method: "GET".to_string(),
1620 url: "https://b.com".to_string(),
1621 headers: vec![],
1622 body: None,
1623 body_text: None,
1624 };
1625
1626 let result = find_interaction_from(&cassette, &req_b, 0);
1627 assert!(result.is_some());
1628 let (idx, interaction) = result.unwrap();
1629 assert_eq!(idx, 1);
1630 assert_eq!(interaction.response.status, 201);
1631 }
1632
1633 #[test]
1634 fn find_interaction_from_with_cursor_skip() {
1635 let make_interaction = |url: &str, status: u16| Interaction {
1636 request: RecordedRequest {
1637 method: "POST".to_string(),
1638 url: url.to_string(),
1639 headers: vec![],
1640 body: None,
1641 body_text: None,
1642 },
1643 response: RecordedResponse {
1644 status,
1645 headers: vec![],
1646 body_chunks: vec![],
1647 body_chunks_base64: None,
1648 },
1649 };
1650
1651 let cassette = Cassette {
1652 version: "1.0".to_string(),
1653 test_name: "cursor".to_string(),
1654 recorded_at: "2026-01-01".to_string(),
1655 interactions: vec![
1656 make_interaction("https://x.com", 200),
1657 make_interaction("https://x.com", 201),
1658 make_interaction("https://x.com", 202),
1659 ],
1660 };
1661
1662 let req = RecordedRequest {
1663 method: "POST".to_string(),
1664 url: "https://x.com".to_string(),
1665 headers: vec![],
1666 body: None,
1667 body_text: None,
1668 };
1669
1670 let (idx, _) = find_interaction_from(&cassette, &req, 0).unwrap();
1672 assert_eq!(idx, 0);
1673
1674 let (idx, interaction) = find_interaction_from(&cassette, &req, 1).unwrap();
1676 assert_eq!(idx, 1);
1677 assert_eq!(interaction.response.status, 201);
1678
1679 assert!(find_interaction_from(&cassette, &req, 3).is_none());
1681 }
1682
1683 #[test]
1684 fn find_interaction_no_match() {
1685 let cassette = Cassette {
1686 version: "1.0".to_string(),
1687 test_name: "empty".to_string(),
1688 recorded_at: "2026-01-01".to_string(),
1689 interactions: vec![],
1690 };
1691 let req = RecordedRequest {
1692 method: "GET".to_string(),
1693 url: "https://x.com".to_string(),
1694 headers: vec![],
1695 body: None,
1696 body_text: None,
1697 };
1698 assert!(find_interaction_from(&cassette, &req, 0).is_none());
1699 }
1700
1701 #[test]
1704 fn env_truthy_values() {
1705 let _lock = lock_env();
1706 let key = "PI_VCR_TEST_TRUTHY";
1707
1708 for val in ["1", "true", "TRUE", "yes", "YES"] {
1709 let prev = set_test_env_var(key, Some(val));
1710 assert!(env_truthy(key), "expected truthy for '{val}'");
1711 restore_test_env_var(key, prev);
1712 }
1713
1714 for val in ["0", "false", "no", ""] {
1715 let prev = set_test_env_var(key, Some(val));
1716 assert!(!env_truthy(key), "expected falsy for '{val}'");
1717 restore_test_env_var(key, prev);
1718 }
1719
1720 let prev = set_test_env_var(key, None);
1721 assert!(!env_truthy(key), "expected falsy for unset");
1722 restore_test_env_var(key, prev);
1723 }
1724
1725 #[test]
1728 fn default_mode_ci_is_playback() {
1729 let _lock = lock_env();
1730 let prev = set_test_env_var("CI", Some("true"));
1731 assert_eq!(default_mode(), VcrMode::Playback);
1732 restore_test_env_var("CI", prev);
1733 }
1734
1735 #[test]
1736 fn default_mode_no_ci_is_auto() {
1737 let _lock = lock_env();
1738 let prev = set_test_env_var("CI", None);
1739 assert_eq!(default_mode(), VcrMode::Auto);
1740 restore_test_env_var("CI", prev);
1741 }
1742
1743 #[test]
1746 fn into_byte_stream_text_chunks() {
1747 let resp = RecordedResponse {
1748 status: 200,
1749 headers: vec![],
1750 body_chunks: vec!["hello ".to_string(), "world".to_string()],
1751 body_chunks_base64: None,
1752 };
1753 let chunks: Vec<Vec<u8>> = run_async(async move {
1754 use futures::StreamExt;
1755 resp.into_byte_stream()
1756 .map(|r| r.expect("chunk"))
1757 .collect()
1758 .await
1759 });
1760 assert_eq!(chunks.len(), 2);
1761 assert_eq!(chunks[0], b"hello ");
1762 assert_eq!(chunks[1], b"world");
1763 }
1764
1765 #[test]
1766 fn into_byte_stream_base64_chunks() {
1767 let chunk1 = STANDARD.encode(b"binary\x00data");
1768 let chunk2 = STANDARD.encode(b"\xff\xfe");
1769 let resp = RecordedResponse {
1770 status: 200,
1771 headers: vec![],
1772 body_chunks: vec![],
1773 body_chunks_base64: Some(vec![chunk1, chunk2]),
1774 };
1775 let chunks: Vec<Vec<u8>> = run_async(async move {
1776 use futures::StreamExt;
1777 resp.into_byte_stream()
1778 .map(|r| r.expect("chunk"))
1779 .collect()
1780 .await
1781 });
1782 assert_eq!(chunks.len(), 2);
1783 assert_eq!(chunks[0], b"binary\x00data");
1784 assert_eq!(chunks[1], b"\xff\xfe");
1785 }
1786
1787 #[test]
1788 fn into_byte_stream_base64_takes_precedence() {
1789 let resp = RecordedResponse {
1790 status: 200,
1791 headers: vec![],
1792 body_chunks: vec!["ignored".to_string()],
1793 body_chunks_base64: Some(vec![STANDARD.encode(b"used")]),
1794 };
1795 let chunks: Vec<Vec<u8>> = run_async(async move {
1796 use futures::StreamExt;
1797 resp.into_byte_stream()
1798 .map(|r| r.expect("chunk"))
1799 .collect()
1800 .await
1801 });
1802 assert_eq!(chunks.len(), 1);
1803 assert_eq!(chunks[0], b"used");
1804 }
1805
1806 #[test]
1807 fn into_byte_stream_empty() {
1808 let resp = RecordedResponse {
1809 status: 200,
1810 headers: vec![],
1811 body_chunks: vec![],
1812 body_chunks_base64: None,
1813 };
1814 let chunks: Vec<Vec<u8>> = run_async(async move {
1815 use futures::StreamExt;
1816 resp.into_byte_stream()
1817 .map(|r| r.expect("chunk"))
1818 .collect()
1819 .await
1820 });
1821 assert!(chunks.is_empty());
1822 }
1823
1824 #[test]
1825 fn into_byte_stream_invalid_base64_errors() {
1826 let resp = RecordedResponse {
1827 status: 200,
1828 headers: vec![],
1829 body_chunks: vec![],
1830 body_chunks_base64: Some(vec!["not-valid-base64!!!".to_string()]),
1831 };
1832 let results: Vec<std::result::Result<Vec<u8>, std::io::Error>> = run_async(async move {
1833 use futures::StreamExt;
1834 resp.into_byte_stream().collect().await
1835 });
1836 assert_eq!(results.len(), 1);
1837 assert!(results[0].is_err());
1838 }
1839
1840 #[test]
1843 fn cassette_serde_body_text_omitted_when_none() {
1844 let req = RecordedRequest {
1845 method: "GET".to_string(),
1846 url: "https://x.com".to_string(),
1847 headers: vec![],
1848 body: None,
1849 body_text: None,
1850 };
1851 let json = serde_json::to_string(&req).unwrap();
1852 assert!(!json.contains("body_text"));
1853 assert!(!json.contains("body"));
1854 }
1855
1856 #[test]
1857 fn cassette_serde_body_text_present_when_some() {
1858 let req = RecordedRequest {
1859 method: "GET".to_string(),
1860 url: "https://x.com".to_string(),
1861 headers: vec![],
1862 body: None,
1863 body_text: Some("hello".to_string()),
1864 };
1865 let json = serde_json::to_string(&req).unwrap();
1866 assert!(json.contains("body_text"));
1867 assert!(json.contains("hello"));
1868 }
1869
1870 #[test]
1871 fn cassette_response_base64_omitted_when_none() {
1872 let resp = RecordedResponse {
1873 status: 200,
1874 headers: vec![],
1875 body_chunks: vec!["data".to_string()],
1876 body_chunks_base64: None,
1877 };
1878 let json = serde_json::to_string(&resp).unwrap();
1879 assert!(!json.contains("body_chunks_base64"));
1880 }
1881
1882 #[test]
1883 fn cassette_save_load_round_trip() {
1884 let temp_dir = tempfile::tempdir().expect("temp dir");
1885 let path = temp_dir.path().join("subdir/test.json");
1886 let cassette = Cassette {
1887 version: CASSETTE_VERSION.to_string(),
1888 test_name: "save_load".to_string(),
1889 recorded_at: "2026-02-06T00:00:00.000Z".to_string(),
1890 interactions: vec![Interaction {
1891 request: RecordedRequest {
1892 method: "POST".to_string(),
1893 url: "https://api.example.com".to_string(),
1894 headers: vec![("content-type".to_string(), "application/json".to_string())],
1895 body: Some(serde_json::json!({"key": "value"})),
1896 body_text: None,
1897 },
1898 response: RecordedResponse {
1899 status: 200,
1900 headers: vec![],
1901 body_chunks: vec!["ok".to_string()],
1902 body_chunks_base64: None,
1903 },
1904 }],
1905 };
1906
1907 save_cassette(&path, &cassette).expect("save");
1908 assert!(path.exists());
1909
1910 let loaded = load_cassette(&path).expect("load");
1911 assert_eq!(loaded.version, CASSETTE_VERSION);
1912 assert_eq!(loaded.test_name, "save_load");
1913 assert_eq!(loaded.interactions.len(), 1);
1914 assert_eq!(loaded.interactions[0].request.method, "POST");
1915 }
1916
1917 #[test]
1918 fn load_cassette_missing_file_errors() {
1919 let result = load_cassette(Path::new("/nonexistent/cassette.json"));
1920 assert!(result.is_err());
1921 assert!(result.unwrap_err().to_string().contains("Failed to read"));
1922 }
1923
1924 #[test]
1927 fn redact_cassette_multiple_interactions() {
1928 let mut cassette = Cassette {
1929 version: "1.0".to_string(),
1930 test_name: "multi".to_string(),
1931 recorded_at: "now".to_string(),
1932 interactions: vec![
1933 Interaction {
1934 request: RecordedRequest {
1935 method: "POST".to_string(),
1936 url: "https://a.com".to_string(),
1937 headers: vec![("Authorization".to_string(), "Bearer tok".to_string())],
1938 body: Some(serde_json::json!({"password": "p1"})),
1939 body_text: None,
1940 },
1941 response: RecordedResponse {
1942 status: 200,
1943 headers: vec![("x-api-key".to_string(), "key1".to_string())],
1944 body_chunks: vec![],
1945 body_chunks_base64: None,
1946 },
1947 },
1948 Interaction {
1949 request: RecordedRequest {
1950 method: "POST".to_string(),
1951 url: "https://b.com".to_string(),
1952 headers: vec![],
1953 body: Some(serde_json::json!({"client_secret": "s1"})),
1954 body_text: None,
1955 },
1956 response: RecordedResponse {
1957 status: 200,
1958 headers: vec![],
1959 body_chunks: vec![],
1960 body_chunks_base64: None,
1961 },
1962 },
1963 ],
1964 };
1965
1966 let summary = redact_cassette(&mut cassette);
1967 assert_eq!(summary.headers_redacted, 2);
1968 assert_eq!(summary.json_fields_redacted, 2);
1969 }
1970
1971 #[test]
1974 fn recorder_new_with_sets_mode_and_path() {
1975 let temp_dir = tempfile::tempdir().expect("temp dir");
1976 let recorder = VcrRecorder::new_with("my::test_name", VcrMode::Playback, temp_dir.path());
1977 assert_eq!(recorder.mode(), VcrMode::Playback);
1978 assert!(
1979 recorder
1980 .cassette_path()
1981 .to_string_lossy()
1982 .contains("my__test_name.json")
1983 );
1984 }
1985
1986 mod proptest_vcr {
1991 use super::*;
1992 use proptest::prelude::*;
1993
1994 fn small_string() -> impl Strategy<Value = String> {
1997 prop_oneof![Just(String::new()), "[a-zA-Z0-9_]{1,16}", "[ -~]{0,32}",]
1998 }
1999
2000 fn url_string() -> impl Strategy<Value = String> {
2001 prop_oneof![
2002 Just("https://api.example.com/v1/messages".to_string()),
2003 Just(String::new()),
2004 Just("not-a-url".to_string()),
2005 Just("http://localhost:8080/test?q=1&b=2".to_string()),
2006 "https?://[a-z.]{1,20}/[a-z/]{0,20}",
2007 "[ -~]{0,64}",
2008 ]
2009 }
2010
2011 fn http_method() -> impl Strategy<Value = String> {
2012 prop_oneof![
2013 Just("GET".to_string()),
2014 Just("POST".to_string()),
2015 Just("PUT".to_string()),
2016 Just("DELETE".to_string()),
2017 Just("get".to_string()),
2018 Just("post".to_string()),
2019 "[A-Z]{1,8}",
2020 small_string(),
2021 ]
2022 }
2023
2024 fn header_pair() -> impl Strategy<Value = (String, String)> {
2025 let key = prop_oneof![
2026 Just("Content-Type".to_string()),
2027 Just("Authorization".to_string()),
2028 Just("x-api-key".to_string()),
2029 Just("X-Custom-Header".to_string()),
2030 "[a-zA-Z][a-zA-Z0-9-]{0,20}",
2031 ];
2032 let value = prop_oneof![
2033 Just("application/json".to_string()),
2034 Just("Bearer sk-test-123".to_string()),
2035 small_string(),
2036 Just("value\r\nInjected: header".to_string()),
2038 ];
2039 (key, value)
2040 }
2041
2042 fn json_value() -> impl Strategy<Value = Value> {
2043 let leaf = prop_oneof![
2044 Just(Value::Null),
2045 any::<bool>().prop_map(Value::Bool),
2046 any::<i64>().prop_map(|n| Value::Number(n.into())),
2047 small_string().prop_map(Value::String),
2048 ];
2049 leaf.prop_recursive(3, 32, 4, |inner| {
2050 prop_oneof![
2051 prop::collection::vec(inner.clone(), 0..4).prop_map(Value::Array),
2052 prop::collection::hash_map("[a-z_]{1,10}", inner, 0..4)
2053 .prop_map(|m| Value::Object(m.into_iter().collect())),
2054 ]
2055 })
2056 }
2057
2058 fn recorded_request() -> impl Strategy<Value = RecordedRequest> {
2059 (
2060 http_method(),
2061 url_string(),
2062 prop::collection::vec(header_pair(), 0..4),
2063 prop::option::of(json_value()),
2064 prop::option::of(small_string()),
2065 )
2066 .prop_map(|(method, url, headers, body, body_text)| RecordedRequest {
2067 method,
2068 url,
2069 headers,
2070 body,
2071 body_text,
2072 })
2073 }
2074
2075 fn base64_chunk() -> impl Strategy<Value = String> {
2076 prop_oneof![
2077 prop::collection::vec(any::<u8>(), 0..64)
2079 .prop_map(|bytes| base64::engine::general_purpose::STANDARD.encode(&bytes)),
2080 Just("not-valid-base64!!!".to_string()),
2082 Just("====".to_string()),
2083 Just(String::new()),
2084 "[ -~]{0,32}",
2085 ]
2086 }
2087
2088 fn recorded_response() -> impl Strategy<Value = RecordedResponse> {
2089 (
2090 any::<u16>(),
2091 prop::collection::vec(header_pair(), 0..4),
2092 prop::collection::vec(small_string(), 0..4),
2093 prop::option::of(prop::collection::vec(base64_chunk(), 0..4)),
2094 )
2095 .prop_map(|(status, headers, body_chunks, body_chunks_base64)| {
2096 RecordedResponse {
2097 status,
2098 headers,
2099 body_chunks,
2100 body_chunks_base64,
2101 }
2102 })
2103 }
2104
2105 proptest! {
2108 #![proptest_config(ProptestConfig {
2109 cases: 256,
2110 max_shrink_iters: 100,
2111 .. ProptestConfig::default()
2112 })]
2113
2114 #[test]
2116 fn redact_json_is_idempotent(value in json_value()) {
2117 let mut first = value;
2118 redact_json(&mut first);
2119 let mut second = first.clone();
2120 redact_json(&mut second);
2121 assert_eq!(first, second);
2122 }
2123
2124 #[test]
2126 fn redact_json_never_panics(mut value in json_value()) {
2127 let _ = redact_json(&mut value);
2128 }
2129
2130 #[test]
2132 fn request_matches_is_reflexive(req in recorded_request()) {
2133 let mut cassette_req = req.clone();
2136 if let Some(body) = &mut cassette_req.body {
2137 redact_json(body);
2138 }
2139 assert!(request_matches(&cassette_req, &req));
2140 }
2141
2142 #[test]
2144 fn request_matches_never_panics(
2145 a in recorded_request(),
2146 b in recorded_request()
2147 ) {
2148 let _ = request_matches(&a, &b);
2149 }
2150
2151 #[test]
2153 fn match_json_template_never_panics(
2154 a in json_value(),
2155 b in json_value()
2156 ) {
2157 let _ = match_json_template(&a, &b);
2158 }
2159
2160 #[test]
2162 fn match_json_template_is_reflexive(v in json_value()) {
2163 assert!(match_json_template(&v, &v));
2164 }
2165
2166 #[test]
2168 fn into_byte_stream_never_panics(resp in recorded_response()) {
2169 let stream = resp.into_byte_stream();
2170 run_async(async move {
2171 use futures::StreamExt;
2172 let _results: Vec<_> = stream.collect().await;
2173 });
2174 }
2175
2176 #[test]
2179 fn cassette_serde_round_trip(
2180 version in small_string(),
2181 test_name in small_string(),
2182 recorded_at in small_string(),
2183 req in recorded_request(),
2184 resp in recorded_response()
2185 ) {
2186 let cassette = Cassette {
2187 version,
2188 test_name,
2189 recorded_at,
2190 interactions: vec![Interaction {
2191 request: req,
2192 response: resp,
2193 }],
2194 };
2195 let json = serde_json::to_string(&cassette).expect("serialize");
2196 let reparsed: Cassette = serde_json::from_str(&json).expect("deserialize");
2197 assert_eq!(cassette.version, reparsed.version);
2198 assert_eq!(cassette.test_name, reparsed.test_name);
2199 assert_eq!(cassette.recorded_at, reparsed.recorded_at);
2200 assert_eq!(cassette.interactions.len(), reparsed.interactions.len());
2201 }
2202
2203 #[test]
2205 fn is_sensitive_key_never_panics(key in "[ -~]{0,64}") {
2206 let _ = is_sensitive_key(&key);
2207 }
2208
2209 #[test]
2212 fn base64_takes_precedence_over_text(
2213 text_chunks in prop::collection::vec(small_string(), 1..4),
2214 base64_chunks in prop::collection::vec(
2215 prop::collection::vec(any::<u8>(), 0..32)
2216 .prop_map(|b| base64::engine::general_purpose::STANDARD.encode(&b)),
2217 1..4
2218 )
2219 ) {
2220 let expected_bytes: Vec<Vec<u8>> = base64_chunks.iter().map(|c| {
2221 base64::engine::general_purpose::STANDARD.decode(c).unwrap()
2222 }).collect();
2223
2224 let resp = RecordedResponse {
2225 status: 200,
2226 headers: vec![],
2227 body_chunks: text_chunks,
2228 body_chunks_base64: Some(base64_chunks),
2229 };
2230 let results: Vec<std::result::Result<Vec<u8>, std::io::Error>> =
2231 run_async(async move {
2232 use futures::StreamExt;
2233 resp.into_byte_stream().collect().await
2234 });
2235 assert_eq!(results.len(), expected_bytes.len());
2236 for (result, expected) in results.iter().zip(&expected_bytes) {
2237 assert_eq!(result.as_ref().unwrap(), expected);
2238 }
2239 }
2240 }
2241 }
2242}