Skip to main content

cratefield_testing/
privacy_provider.rs

1//! [`FakePrivacyProvider`] (issue #653): an in-memory external subject-data
2//! provider — the `HttpClient` a `Privacy` module calls on export and
3//! erasure, with no network.
4//!
5//! It is the receiving end of the module's contract: it verifies
6//! `Cratefield-Signature` with the core [`WebhookVerifier`] and
7//! [`StripeStyle`], so a test proves the module signs what a real receiver
8//! checks, and it holds per-subject sections an erasure can delete.
9
10#![allow(clippy::disallowed_types)]
11// Every accessor locks an unpoisoned fixture mutex; per-method `# Panics`
12// sections would add noise without information (as `fakes.rs` records).
13#![allow(clippy::missing_panics_doc)]
14
15use async_trait::async_trait;
16use bytes::Bytes;
17use cratefield_core::{HttpClient, HttpError, StripeStyle, WebhookVerifier};
18use http::{Request, Response};
19use serde_json::{Value, json};
20use std::collections::BTreeMap;
21use std::sync::Arc;
22use std::sync::Mutex;
23use std::sync::atomic::{AtomicUsize, Ordering};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26/// The header the module signs with; the fake verifies exactly this.
27const SIGNATURE_HEADER: &str = "Cratefield-Signature";
28
29/// One request a [`FakePrivacyProvider`] accepted, for assertions.
30#[derive(Debug, Clone)]
31pub struct ProviderCall {
32    /// The request path: `/export`, `/erase/plan` or `/erase/apply`.
33    pub path: String,
34    /// The `request_id` the module sent, which a confirm and its retries share.
35    pub request_id: String,
36}
37
38/// An in-memory external provider. Construct with its signing secret, hold
39/// sections per subject ([`with_subject`](Self::with_subject)), and assert on
40/// what it saw ([`calls`](Self::calls), [`applies`](Self::applies),
41/// [`holds`](Self::holds)).
42#[derive(Clone, Default)]
43pub struct FakePrivacyProvider {
44    inner: Arc<Inner>,
45}
46
47#[derive(Default)]
48struct Inner {
49    secret: String,
50    subjects: Mutex<BTreeMap<String, Value>>,
51    retain: Mutex<BTreeMap<String, String>>,
52    failure: Mutex<Option<(u16, String)>>,
53    calls: Mutex<Vec<ProviderCall>>,
54    applied: Mutex<BTreeMap<String, usize>>,
55    signature_failures: AtomicUsize,
56}
57
58impl FakePrivacyProvider {
59    /// A provider that signs with `secret`. A module configured with a
60    /// different one has every call refused with 401.
61    #[must_use]
62    pub fn new(secret: impl Into<String>) -> Self {
63        Self {
64            inner: Arc::new(Inner {
65                secret: secret.into(),
66                ..Inner::default()
67            }),
68        }
69    }
70
71    /// Holds `sections` (the `sections` array a real provider answers
72    /// `/export` with) for `subject`.
73    #[must_use]
74    pub fn with_subject(&self, subject: &str, sections: Value) -> Self {
75        self.inner
76            .subjects
77            .lock()
78            .expect("provider lock")
79            .insert(subject.to_owned(), sections);
80        self.clone()
81    }
82
83    /// Plans `section` as `retain` rather than `delete`, with `reason`.
84    #[must_use]
85    pub fn retain_section(&self, section: &str, reason: &str) -> Self {
86        self.inner
87            .retain
88            .lock()
89            .expect("provider lock")
90            .insert(section.to_owned(), reason.to_owned());
91        self.clone()
92    }
93
94    /// Answers every call with `status` and `body`, after authentication.
95    pub fn set_failure(&self, status: u16, body: &str) {
96        *self.inner.failure.lock().expect("provider lock") = Some((status, body.to_owned()));
97    }
98
99    /// The whole provider is down: every call answers 503.
100    pub fn set_down(&self, down: bool) {
101        *self.inner.failure.lock().expect("provider lock") =
102            down.then(|| (503, "provider down".to_owned()));
103    }
104
105    /// Every authenticated call, in order.
106    #[must_use]
107    pub fn calls(&self) -> Vec<ProviderCall> {
108        self.inner.calls.lock().expect("provider lock").clone()
109    }
110
111    /// How many times `request_id` effectively applied: 1 after the first
112    /// apply, still 1 after a retry or a re-POST of the same confirm.
113    #[must_use]
114    pub fn applies(&self, request_id: &str) -> usize {
115        self.inner
116            .applied
117            .lock()
118            .expect("provider lock")
119            .get(request_id)
120            .copied()
121            .unwrap_or(0)
122    }
123
124    /// Whether it still holds anything for `subject`.
125    #[must_use]
126    pub fn holds(&self, subject: &str) -> bool {
127        self.inner
128            .subjects
129            .lock()
130            .expect("provider lock")
131            .contains_key(subject)
132    }
133
134    /// How many calls arrived with a signature this provider refused.
135    #[must_use]
136    pub fn signature_failures(&self) -> usize {
137        self.inner.signature_failures.load(Ordering::SeqCst)
138    }
139
140    fn sections(&self, subject: &str) -> Value {
141        self.inner
142            .subjects
143            .lock()
144            .expect("provider lock")
145            .get(subject)
146            .cloned()
147            .unwrap_or_else(|| json!([]))
148    }
149
150    fn plan(&self, subject: &str) -> Value {
151        let sections = self.sections(subject);
152        let retain = self.inner.retain.lock().expect("provider lock");
153        let planned = sections
154            .as_array()
155            .into_iter()
156            .flatten()
157            .map(|section| {
158                let name = section["name"].as_str().unwrap_or_default();
159                match retain.get(name) {
160                    Some(reason) => json!({ "name": name, "action": "retain", "reason": reason }),
161                    None => json!({ "name": name, "action": "delete" }),
162                }
163            })
164            .collect();
165        Value::Array(planned)
166    }
167
168    /// Idempotent on `request_id`: a repeat of the same erase does nothing.
169    fn apply(&self, subject: &str, request_id: &str) {
170        let mut applied = self.inner.applied.lock().expect("provider lock");
171        if applied.contains_key(request_id) {
172            return;
173        }
174        self.inner
175            .subjects
176            .lock()
177            .expect("provider lock")
178            .remove(subject);
179        applied.insert(request_id.to_owned(), 1);
180    }
181}
182
183#[async_trait]
184impl HttpClient for FakePrivacyProvider {
185    async fn send(&self, request: Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
186        let (parts, body) = request.into_parts();
187        if !WebhookVerifier::new(StripeStyle {
188            header: SIGNATURE_HEADER,
189        })
190        .verify(&self.inner.secret, &parts.headers, &body, unix_now())
191        {
192            self.inner.signature_failures.fetch_add(1, Ordering::SeqCst);
193            return response(401, "invalid signature");
194        }
195        if let Some((status, body)) = self.inner.failure.lock().expect("provider lock").clone() {
196            return response(status, &body);
197        }
198        let Ok(payload) = serde_json::from_slice::<Value>(&body) else {
199            return response(400, "malformed body");
200        };
201        let subject = payload["subject"].as_str().unwrap_or_default().to_owned();
202        let request_id = payload["request_id"]
203            .as_str()
204            .unwrap_or_default()
205            .to_owned();
206        let path = parts.uri.path().to_owned();
207        self.inner
208            .calls
209            .lock()
210            .expect("provider lock")
211            .push(ProviderCall {
212                path: path.clone(),
213                request_id: request_id.clone(),
214            });
215
216        if path.ends_with("/export") {
217            return json_response(&json!({ "sections": self.sections(&subject) }));
218        }
219        if path.ends_with("/erase/plan") {
220            return json_response(&json!({ "sections": self.plan(&subject) }));
221        }
222        if path.ends_with("/erase/apply") {
223            self.apply(&subject, &request_id);
224            return json_response(&json!({ "applied": true }));
225        }
226        response(404, "no such path")
227    }
228}
229
230fn unix_now() -> i64 {
231    let secs = SystemTime::now()
232        .duration_since(UNIX_EPOCH)
233        .map_or(0, |elapsed| elapsed.as_secs());
234    i64::try_from(secs).unwrap_or(i64::MAX)
235}
236
237fn response(status: u16, body: &str) -> Result<Response<Bytes>, HttpError> {
238    Response::builder()
239        .status(status)
240        .body(Bytes::from(body.to_owned()))
241        .map_err(|err| HttpError::Transport(err.to_string()))
242}
243
244fn json_response(value: &Value) -> Result<Response<Bytes>, HttpError> {
245    response(200, &value.to_string())
246}