Skip to main content

cratefield_testing/
sidecar.rs

1//! A sidecar without a network (issue #64): a [`Dispatcher`] whose bound
2//! "Worker" is a second in-process harness. What a Cloudflare service
3//! binding does at runtime (ADR 0009), in a test.
4//!
5//! [`Fault`] deliberately breaks the hop. It exists so the parity axis
6//! can be shown to have teeth: a suite that passes against a forwarder
7//! which drops the request id proves nothing.
8
9use std::sync::Arc;
10use std::sync::atomic::{AtomicUsize, Ordering};
11
12use async_trait::async_trait;
13use bytes::Bytes;
14use cratefield_core::{DispatchError, Dispatcher};
15use tower::ServiceExt;
16
17/// A way the hop can be wrong. Used by the kit's own tests to prove the
18/// parity checks fail when the forwarder misbehaves; never by a module.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Fault {
21    /// Strips `x-request-id` from the response. The host re-adds its
22    /// own, so this is a resilience case rather than a defect.
23    DropRequestId,
24    /// Answers a second, different `x-request-id`, so the caller cannot
25    /// tell which trail to follow.
26    DuplicateRequestId,
27    /// Answers `500` whatever the sidecar said.
28    RemapStatus,
29    /// Rewrites the response body, keeping the status.
30    MangleBody,
31    /// Drops the request headers on the way out, so the sidecar sees a
32    /// bare request (the caller's ip, admin bearer and id are lost).
33    StripRequestHeaders,
34    /// Never answers: the binding exists but the Worker does not reply.
35    Unavailable,
36    /// The binding is not in this deployment at all.
37    NotBound,
38}
39
40/// A dispatcher backed by an in-process router.
41pub struct FakeSidecar {
42    binding: String,
43    router: axum::Router,
44    calls: AtomicUsize,
45    fault: Option<Fault>,
46}
47
48impl FakeSidecar {
49    /// Binds `router` (a whole harness serving the module at
50    /// `/v1/<name>`) to `binding`.
51    #[must_use]
52    pub fn new(binding: impl Into<String>, router: axum::Router) -> Self {
53        Self {
54            binding: binding.into(),
55            router,
56            calls: AtomicUsize::new(0),
57            fault: None,
58        }
59    }
60
61    /// The same, with the hop deliberately broken.
62    #[must_use]
63    pub fn faulty(binding: impl Into<String>, router: axum::Router, fault: Fault) -> Self {
64        Self {
65            fault: Some(fault),
66            ..Self::new(binding, router)
67        }
68    }
69
70    /// How many requests reached the dispatcher. `0` proves the host
71    /// answered without forwarding (an oversized body, a missing mount).
72    #[must_use]
73    pub fn calls(&self) -> usize {
74        self.calls.load(Ordering::SeqCst)
75    }
76}
77
78#[async_trait]
79impl Dispatcher for FakeSidecar {
80    fn has(&self, binding: &str) -> bool {
81        self.fault != Some(Fault::NotBound) && binding == self.binding
82    }
83
84    async fn dispatch(
85        &self,
86        binding: &str,
87        request: http::Request<Bytes>,
88    ) -> Result<http::Response<Bytes>, DispatchError> {
89        self.calls.fetch_add(1, Ordering::SeqCst);
90        if binding != self.binding {
91            return Err(DispatchError::NotBound(binding.to_owned()));
92        }
93        if self.fault == Some(Fault::Unavailable) {
94            return Err(DispatchError::Unavailable {
95                binding: binding.to_owned(),
96                reason: "fake sidecar is not answering".to_owned(),
97            });
98        }
99        let (mut parts, body) = request.into_parts();
100        if self.fault == Some(Fault::StripRequestHeaders) {
101            parts.headers.clear();
102        }
103        let inbound = http::Request::from_parts(parts, axum::body::Body::from(body));
104        let response = self
105            .router
106            .clone()
107            .oneshot(inbound)
108            .await
109            .expect("in-process router is infallible");
110        let (mut parts, body) = response.into_parts();
111        let bytes = axum::body::to_bytes(body, 8 * 1024 * 1024)
112            .await
113            .unwrap_or_default();
114        let bytes = match self.fault {
115            Some(Fault::DropRequestId) => {
116                parts.headers.remove(cratefield_core::X_REQUEST_ID);
117                bytes
118            }
119            Some(Fault::RemapStatus) => {
120                parts.status = http::StatusCode::INTERNAL_SERVER_ERROR;
121                bytes
122            }
123            Some(Fault::DuplicateRequestId) => {
124                parts.headers.append(
125                    cratefield_core::X_REQUEST_ID,
126                    http::HeaderValue::from_static("a-second-id-from-the-sidecar"),
127                );
128                bytes
129            }
130            Some(Fault::MangleBody) => Bytes::from_static(b"{\"not\":\"what the module said\"}"),
131            _ => bytes,
132        };
133        Ok(http::Response::from_parts(parts, bytes))
134    }
135}
136
137/// Wraps a [`FakeSidecar`] so the same handle can be given to `Ports` and
138/// still be asked how many calls it saw.
139#[must_use]
140pub fn shared(sidecar: FakeSidecar) -> (Arc<FakeSidecar>, Arc<dyn Dispatcher>) {
141    let sidecar = Arc::new(sidecar);
142    let handle: Arc<dyn Dispatcher> = sidecar.clone();
143    (sidecar, handle)
144}