Skip to main content

harn_vm/http/
mock.rs

1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::BTreeMap;
4use std::sync::Mutex;
5
6use crate::value::VmValue;
7
8#[derive(Clone, Debug)]
9pub(super) struct MockResponse {
10    pub(super) status: i64,
11    pub(super) body: String,
12    pub(super) headers: crate::value::DictMap,
13}
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct HttpMockResponse {
17    pub status: i64,
18    pub body: String,
19    pub headers: BTreeMap<String, String>,
20}
21
22impl HttpMockResponse {
23    pub fn new(status: i64, body: impl Into<String>) -> Self {
24        Self {
25            status,
26            body: body.into(),
27            headers: BTreeMap::new(),
28        }
29    }
30
31    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
32        self.headers.insert(name.into(), value.into());
33        self
34    }
35}
36
37impl From<HttpMockResponse> for MockResponse {
38    fn from(value: HttpMockResponse) -> Self {
39        Self {
40            status: value.status,
41            body: value.body,
42            headers: value
43                .headers
44                .into_iter()
45                .map(|(key, value)| {
46                    (
47                        crate::value::intern_key(&key),
48                        VmValue::String(arcstr::ArcStr::from(value)),
49                    )
50                })
51                .collect(),
52        }
53    }
54}
55
56#[derive(Debug)]
57struct HttpMock {
58    method: String,
59    url_pattern: String,
60    responses: Vec<MockResponse>,
61    next_response: usize,
62}
63
64#[derive(Clone, Debug)]
65struct HttpMockCall {
66    method: String,
67    url: String,
68    headers: crate::value::DictMap,
69    body: Option<String>,
70}
71
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct HttpMockCallSnapshot {
74    pub method: String,
75    pub url: String,
76    pub headers: BTreeMap<String, String>,
77    pub body: Option<String>,
78}
79
80thread_local! {
81    static HTTP_MOCKS: RefCell<Vec<HttpMock>> = const { RefCell::new(Vec::new()) };
82    static HTTP_MOCK_CALLS: RefCell<Vec<HttpMockCall>> = const { RefCell::new(Vec::new()) };
83}
84
85/// HTTP fixture state bound to one typed `Harness`.
86///
87/// The legacy ambient mock registry is thread-local. A typed harness can cross
88/// executor threads between fixture registration and an awaited request, so
89/// its deterministic transport state must travel with the harness instead.
90#[derive(Debug, Default)]
91pub(crate) struct HttpMockRegistry {
92    inner: Mutex<HttpMockRegistryInner>,
93}
94
95#[derive(Debug, Default)]
96struct HttpMockRegistryInner {
97    mocks: Vec<HttpMock>,
98    calls: Vec<HttpMockCall>,
99}
100
101impl HttpMockRegistry {
102    pub(crate) fn clear(&self) {
103        let mut inner = self.inner.lock().expect("harness HTTP mocks poisoned");
104        inner.mocks.clear();
105        inner.calls.clear();
106    }
107
108    pub(crate) fn has_match(&self, method: &str, url: &str) -> bool {
109        self.inner
110            .lock()
111            .expect("harness HTTP mocks poisoned")
112            .mocks
113            .iter()
114            .any(|mock| {
115                !mock.responses.is_empty()
116                    && (mock.method == "*" || mock.method.eq_ignore_ascii_case(method))
117                    && url_matches(&mock.url_pattern, url)
118            })
119    }
120
121    pub(super) fn register(
122        &self,
123        method: impl Into<String>,
124        url_pattern: impl Into<String>,
125        responses: Vec<MockResponse>,
126    ) {
127        let method = method.into();
128        let url_pattern = url_pattern.into();
129        let mut inner = self.inner.lock().expect("harness HTTP mocks poisoned");
130        inner
131            .mocks
132            .retain(|mock| !(mock.method == method && mock.url_pattern == url_pattern));
133        inner.mocks.push(HttpMock {
134            method,
135            url_pattern,
136            responses,
137            next_response: 0,
138        });
139    }
140
141    pub(super) fn consume(
142        &self,
143        method: &str,
144        url: &str,
145        headers: crate::value::DictMap,
146        body: Option<String>,
147    ) -> Option<MockResponse> {
148        let mut inner = self.inner.lock().expect("harness HTTP mocks poisoned");
149        let response = inner.mocks.iter_mut().find_map(|mock| {
150            if (mock.method == "*" || mock.method.eq_ignore_ascii_case(method))
151                && url_matches(&mock.url_pattern, url)
152            {
153                let last_index = mock.responses.len().checked_sub(1)?;
154                let index = mock.next_response.min(last_index);
155                let response = mock.responses[index].clone();
156                if mock.next_response < last_index {
157                    mock.next_response += 1;
158                }
159                Some(response)
160            } else {
161                None
162            }
163        })?;
164        inner.calls.push(HttpMockCall {
165            method: method.to_string(),
166            url: url.to_string(),
167            headers,
168            body,
169        });
170        Some(response)
171    }
172
173    pub(crate) fn calls_value(&self, redact_sensitive: bool) -> Vec<VmValue> {
174        let inner = self.inner.lock().expect("harness HTTP mocks poisoned");
175        http_mock_calls_from(&inner.calls, redact_sensitive)
176    }
177}
178
179pub(super) fn reset_http_mocks() {
180    HTTP_MOCKS.with(|mocks| mocks.borrow_mut().clear());
181    HTTP_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
182}
183
184pub(super) fn clear_http_mocks() {
185    reset_http_mocks();
186}
187
188pub fn push_http_mock(
189    method: impl Into<String>,
190    url_pattern: impl Into<String>,
191    responses: Vec<HttpMockResponse>,
192) {
193    let responses = if responses.is_empty() {
194        vec![MockResponse::from(HttpMockResponse::new(200, ""))]
195    } else {
196        responses.into_iter().map(MockResponse::from).collect()
197    };
198    register_http_mock(method.into(), url_pattern.into(), responses);
199}
200
201pub(super) fn register_http_mock(
202    method: impl Into<String>,
203    url_pattern: impl Into<String>,
204    responses: Vec<MockResponse>,
205) {
206    let method = method.into();
207    let url_pattern = url_pattern.into();
208    HTTP_MOCKS.with(|mocks| {
209        let mut mocks = mocks.borrow_mut();
210        // Re-registering the same (method, url_pattern) replaces the prior
211        // mock so tests can override per-case responses without first calling
212        // http_mock_clear(). Without this, the original mock keeps matching
213        // forever and the new one is dead.
214        mocks.retain(|mock| !(mock.method == method && mock.url_pattern == url_pattern));
215        mocks.push(HttpMock {
216            method,
217            url_pattern,
218            responses,
219            next_response: 0,
220        });
221    });
222}
223
224pub fn http_mock_calls_snapshot() -> Vec<HttpMockCallSnapshot> {
225    HTTP_MOCK_CALLS.with(|calls| {
226        calls
227            .borrow()
228            .iter()
229            .map(|call| HttpMockCallSnapshot {
230                method: call.method.clone(),
231                url: call.url.clone(),
232                headers: call
233                    .headers
234                    .iter()
235                    .map(|(key, value)| (key.to_string(), value.display()))
236                    .collect(),
237                body: call.body.clone(),
238            })
239            .collect()
240    })
241}
242
243pub(super) fn http_mock_calls_value(redact_sensitive: bool) -> Vec<VmValue> {
244    HTTP_MOCK_CALLS.with(|calls| http_mock_calls_from(&calls.borrow(), redact_sensitive))
245}
246
247fn http_mock_calls_from(calls: &[HttpMockCall], redact_sensitive: bool) -> Vec<VmValue> {
248    calls
249        .iter()
250        .map(|call| {
251            let mut dict = BTreeMap::new();
252            dict.put_str("method", call.method.as_str());
253            dict.put_str("url", redact_mock_call_url(&call.url, redact_sensitive));
254            dict.insert(
255                "headers".to_string(),
256                VmValue::dict(mock_call_headers_value(&call.headers, redact_sensitive)),
257            );
258            dict.insert(
259                "body".to_string(),
260                match &call.body {
261                    Some(body) => VmValue::String(arcstr::ArcStr::from(body.as_str())),
262                    None => VmValue::Nil,
263                },
264            );
265            VmValue::dict(dict)
266        })
267        .collect()
268}
269
270pub(super) fn parse_mock_responses(response: &crate::value::DictMap) -> Vec<MockResponse> {
271    let scripted = response
272        .get("responses")
273        .and_then(|value| match value {
274            VmValue::List(items) => Some(
275                items
276                    .iter()
277                    .filter_map(|item| item.as_dict().map(parse_mock_response_dict))
278                    .collect::<Vec<_>>(),
279            ),
280            _ => None,
281        })
282        .unwrap_or_default();
283
284    if scripted.is_empty() {
285        vec![parse_mock_response_dict(response)]
286    } else {
287        scripted
288    }
289}
290
291fn parse_mock_response_dict(response: &crate::value::DictMap) -> MockResponse {
292    let status = response
293        .get("status")
294        .and_then(|v| v.as_int())
295        .unwrap_or(200);
296    let body = response
297        .get("body")
298        .map(|v| v.display())
299        .unwrap_or_default();
300    let headers = response
301        .get("headers")
302        .and_then(|v| v.as_dict())
303        .cloned()
304        .unwrap_or_default();
305    MockResponse {
306        status,
307        body,
308        headers,
309    }
310}
311
312pub(super) fn consume_http_mock(
313    method: &str,
314    url: &str,
315    headers: crate::value::DictMap,
316    body: Option<String>,
317) -> Option<MockResponse> {
318    let response = HTTP_MOCKS.with(|mocks| {
319        let mut mocks = mocks.borrow_mut();
320        for mock in mocks.iter_mut() {
321            if (mock.method == "*" || mock.method.eq_ignore_ascii_case(method))
322                && url_matches(&mock.url_pattern, url)
323            {
324                let Some(last_index) = mock.responses.len().checked_sub(1) else {
325                    continue;
326                };
327                let index = mock.next_response.min(last_index);
328                let response = mock.responses[index].clone();
329                if mock.next_response < last_index {
330                    mock.next_response += 1;
331                }
332                return Some(response);
333            }
334        }
335        None
336    })?;
337
338    HTTP_MOCK_CALLS.with(|calls| {
339        calls.borrow_mut().push(HttpMockCall {
340            method: method.to_string(),
341            url: url.to_string(),
342            headers,
343            body,
344        });
345    });
346
347    Some(response)
348}
349
350/// Check if a URL matches a mock pattern (exact or glob with `*`).
351pub(super) fn url_matches(pattern: &str, url: &str) -> bool {
352    if pattern == "*" {
353        return true;
354    }
355    if !pattern.contains('*') {
356        return pattern == url;
357    }
358    // Multi-glob: split on `*` and match segments in order.
359    let parts: Vec<&str> = pattern.split('*').collect();
360    let mut remaining = url;
361    for (i, part) in parts.iter().enumerate() {
362        if part.is_empty() {
363            continue;
364        }
365        if i == 0 {
366            match remaining.strip_prefix(*part) {
367                Some(rest) => remaining = rest,
368                None => return false,
369            }
370        } else if i == parts.len() - 1 {
371            if !remaining.ends_with(part) {
372                return false;
373            }
374            remaining = "";
375        } else {
376            match remaining.split_once(*part) {
377                Some((_, rest)) => remaining = rest,
378                None => return false,
379            }
380        }
381    }
382    true
383}
384
385pub(super) fn redact_mock_call_url(url: &str, redact: bool) -> String {
386    if !redact {
387        return url.to_string();
388    }
389    crate::redact::current_policy().redact_url(url)
390}
391
392pub(super) fn mock_call_headers_value(
393    headers: &crate::value::DictMap,
394    redact_headers: bool,
395) -> crate::value::DictMap {
396    if !redact_headers {
397        return headers.clone();
398    }
399    let policy = crate::redact::current_policy();
400    headers
401        .iter()
402        .map(|(key, value)| {
403            let value = if policy.header_is_sensitive(key) {
404                VmValue::String(arcstr::ArcStr::from(crate::redact::REDACTED_PLACEHOLDER))
405            } else {
406                value.clone()
407            };
408            (key.clone(), value)
409        })
410        .collect()
411}