Skip to main content

scope/http/replay/
client.rs

1//! Record/replay transport for the TUI test harness (design section 3).
2
3use super::key::{SECRET_HEADERS, body_sha256, clean_url, fixture_path};
4use crate::error::ScopeError;
5use crate::http::{HttpClient, Request, Response};
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::path::PathBuf;
10use std::sync::{Arc, Mutex};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Mode {
14    Replay,
15    Record,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct FixtureRequest {
20    pub method: String,
21    pub url: String,
22    pub body_sha256: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct RecordedResponse {
27    pub status: u16,
28    pub headers: BTreeMap<String, String>,
29    pub body: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Fixture {
34    pub recorded: String,
35    pub request: FixtureRequest,
36    pub responses: Vec<RecordedResponse>,
37}
38
39pub struct ReplayHttpClient {
40    mode: Mode,
41    dir: PathBuf,
42    inner: Option<Arc<dyn HttpClient>>,
43    secrets: Vec<String>,
44    /// Replay: how many responses of each fixture were served.
45    served: Mutex<HashMap<PathBuf, usize>>,
46    /// Record: fixtures written in this run (later calls append).
47    written: Mutex<HashSet<PathBuf>>,
48}
49
50impl ReplayHttpClient {
51    pub fn replay(dir: PathBuf) -> Self {
52        Self {
53            mode: Mode::Replay,
54            dir,
55            inner: None,
56            secrets: vec![],
57            served: Default::default(),
58            written: Default::default(),
59        }
60    }
61
62    pub fn record(dir: PathBuf, inner: Arc<dyn HttpClient>, secrets: Vec<String>) -> Self {
63        let secrets = secrets.into_iter().filter(|s| !s.is_empty()).collect();
64        Self {
65            mode: Mode::Record,
66            dir,
67            inner: Some(inner),
68            secrets,
69            served: Default::default(),
70            written: Default::default(),
71        }
72    }
73
74    fn miss(req: &Request) -> ScopeError {
75        ScopeError::Network(format!(
76            "no fixture for {} {}; run `just tui-record <scenario>`",
77            req.method,
78            clean_url(&req.url)
79        ))
80    }
81
82    /// True when the secret appears in the text raw or in its JSON-escaped form.
83    fn leaks(text: &str, secret: &str) -> bool {
84        let quoted = serde_json::to_string(secret).unwrap_or_default();
85        let escaped = quoted.trim_matches('"');
86        text.contains(secret) || (!escaped.is_empty() && text.contains(escaped))
87    }
88
89    fn read(path: &std::path::Path) -> Option<Fixture> {
90        serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
91    }
92}
93
94#[async_trait]
95impl HttpClient for ReplayHttpClient {
96    async fn send(&self, req: Request) -> Result<Response, ScopeError> {
97        let path = fixture_path(&self.dir, &req.method, &req.url, req.body.as_deref());
98        match self.mode {
99            Mode::Replay => {
100                let fixture = Self::read(&path).ok_or_else(|| Self::miss(&req))?;
101                let mut served = self.served.lock().expect("served lock");
102                let n = served.entry(path).or_insert(0);
103                let idx = (*n).min(fixture.responses.len().saturating_sub(1));
104                *n += 1;
105                let r = fixture.responses.get(idx).ok_or_else(|| Self::miss(&req))?;
106                Ok(Response {
107                    status_code: r.status,
108                    headers: r.headers.clone().into_iter().collect(),
109                    body: r.body.clone(),
110                })
111            }
112            Mode::Record => {
113                let inner = self
114                    .inner
115                    .as_ref()
116                    .expect("record mode has an inner transport");
117                let resp = inner.send(req.clone()).await?;
118                let recorded = RecordedResponse {
119                    status: resp.status_code,
120                    headers: resp
121                        .headers
122                        .iter()
123                        .filter(|(k, _)| !SECRET_HEADERS.contains(&k.to_lowercase().as_str()))
124                        .map(|(k, v)| (k.clone(), v.clone()))
125                        .collect(),
126                    body: resp.body.clone(),
127                };
128                let mut written = self.written.lock().expect("written lock");
129                let mut fixture = if written.contains(&path) {
130                    Some(Self::read(&path).ok_or_else(|| {
131                        ScopeError::Network(format!(
132                            "fixture {} was written in this run but cannot be read back",
133                            path.display()
134                        ))
135                    })?)
136                } else {
137                    None
138                }
139                .unwrap_or_else(|| Fixture {
140                    recorded: chrono::Utc::now().to_rfc3339(),
141                    request: FixtureRequest {
142                        method: req.method.to_uppercase(),
143                        url: clean_url(&req.url),
144                        body_sha256: body_sha256(req.body.as_deref()),
145                    },
146                    responses: vec![],
147                });
148                fixture.responses.push(recorded);
149                let text = serde_json::to_string_pretty(&fixture)
150                    .map_err(|e| ScopeError::Network(format!("cannot serialize fixture: {e}")))?;
151                if self.secrets.iter().any(|s| Self::leaks(&text, s)) {
152                    return Err(ScopeError::Network(format!(
153                        "fixture for {} {} would contain a configured secret; not written",
154                        req.method,
155                        clean_url(&req.url)
156                    )));
157                }
158                if let Some(parent) = path.parent() {
159                    std::fs::create_dir_all(parent).map_err(|e| {
160                        ScopeError::io_context(e, format!("cannot create {}", parent.display()))
161                    })?;
162                }
163                std::fs::write(&path, text).map_err(|e| {
164                    ScopeError::io_context(e, format!("cannot write {}", path.display()))
165                })?;
166                written.insert(path);
167                Ok(resp)
168            }
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use std::sync::Mutex;
177
178    /// Answers with the next body in order.
179    struct Script(Mutex<Vec<&'static str>>);
180
181    #[async_trait::async_trait]
182    impl HttpClient for Script {
183        async fn send(&self, _r: Request) -> Result<Response, ScopeError> {
184            let body = self.0.lock().unwrap().remove(0);
185            Ok(Response {
186                status_code: 200,
187                headers: [
188                    ("content-type".into(), "application/json".into()),
189                    ("set-cookie".into(), "s=1".into()),
190                ]
191                .into(),
192                body: body.into(),
193            })
194        }
195    }
196
197    #[tokio::test]
198    async fn test_record_then_replay_returns_responses_in_order_then_repeats_the_last() {
199        let dir = tempfile::tempdir().unwrap();
200        let inner = Arc::new(Script(Mutex::new(vec!["one", "two"])));
201        let rec = ReplayHttpClient::record(dir.path().into(), inner, vec![]);
202        let url = "https://api.example.com/x?apikey=SECRET&a=1";
203        assert_eq!(rec.send(Request::get(url)).await.unwrap().body, "one");
204        assert_eq!(rec.send(Request::get(url)).await.unwrap().body, "two");
205
206        let rep = ReplayHttpClient::replay(dir.path().into());
207        let r1 = rep.send(Request::get(url)).await.unwrap();
208        assert_eq!(r1.body, "one");
209        assert!(!r1.headers.contains_key("set-cookie"));
210        assert_eq!(rep.send(Request::get(url)).await.unwrap().body, "two");
211        // Repeat mode outlasts the recording: keep serving the last response.
212        assert_eq!(rep.send(Request::get(url)).await.unwrap().body, "two");
213    }
214
215    #[tokio::test]
216    async fn test_replay_miss_names_the_request_and_never_uses_the_network() {
217        let dir = tempfile::tempdir().unwrap();
218        let rep = ReplayHttpClient::replay(dir.path().into());
219        let err = rep
220            .send(Request::get("https://api.example.com/missing"))
221            .await
222            .unwrap_err()
223            .to_string();
224        assert!(
225            err.contains("no fixture for GET https://api.example.com/missing"),
226            "{err}"
227        );
228    }
229
230    #[tokio::test]
231    async fn test_record_refuses_a_fixture_that_contains_a_configured_secret() {
232        let dir = tempfile::tempdir().unwrap();
233        let inner = Arc::new(Script(Mutex::new(vec![r#"{"echo":"MYKEY-0123456789"}"#])));
234        let rec =
235            ReplayHttpClient::record(dir.path().into(), inner, vec!["MYKEY-0123456789".into()]);
236        let err = rec
237            .send(Request::get("https://api.example.com/echo"))
238            .await
239            .unwrap_err()
240            .to_string();
241        assert!(err.contains("configured secret"), "{err}");
242        assert!(
243            walk(dir.path()).is_empty(),
244            "no fixture file may be written"
245        );
246    }
247
248    #[tokio::test]
249    async fn test_stored_url_has_no_secret() {
250        let dir = tempfile::tempdir().unwrap();
251        let inner = Arc::new(Script(Mutex::new(vec!["{}"])));
252        let rec = ReplayHttpClient::record(dir.path().into(), inner, vec![]);
253        rec.send(Request::get("https://api.example.com/x?Token=abc123"))
254            .await
255            .unwrap();
256        let text: String = walk(dir.path())
257            .iter()
258            .map(|p| std::fs::read_to_string(p).unwrap())
259            .collect();
260        assert!(!text.contains("abc123"), "{text}");
261    }
262
263    #[tokio::test]
264    async fn test_short_secret_blocks_the_write_and_is_not_echoed() {
265        let dir = tempfile::tempdir().unwrap();
266        let inner = Arc::new(Script(Mutex::new(vec![r#"{"echo":"abc"}"#])));
267        let rec = ReplayHttpClient::record(dir.path().into(), inner, vec!["abc".into()]);
268        let err = rec
269            .send(Request::get("https://api.example.com/e"))
270            .await
271            .unwrap_err()
272            .to_string();
273        assert!(err.contains("configured secret"), "{err}");
274        assert!(!err.contains("abc"), "{err}");
275        assert!(walk(dir.path()).is_empty());
276    }
277
278    #[tokio::test]
279    async fn test_secret_with_a_quote_blocks_the_write_in_escaped_form() {
280        let dir = tempfile::tempdir().unwrap();
281        let inner = Arc::new(Script(Mutex::new(vec![r#"echo pa"ss123 end"#])));
282        let rec = ReplayHttpClient::record(dir.path().into(), inner, vec![r#"pa"ss123"#.into()]);
283        let err = rec
284            .send(Request::get("https://api.example.com/q"))
285            .await
286            .unwrap_err()
287            .to_string();
288        assert!(err.contains("configured secret"), "{err}");
289        assert!(walk(dir.path()).is_empty());
290    }
291
292    fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
293        let mut out = vec![];
294        for e in std::fs::read_dir(dir).unwrap() {
295            let p = e.unwrap().path();
296            if p.is_dir() {
297                out.extend(walk(&p));
298            } else {
299                out.push(p);
300            }
301        }
302        out
303    }
304}