Skip to main content

cratefield_testing/
fakes.rs

1//! Fake ports for module tests (issue #9). All fakes are `Clone` handles
2//! over shared interiors so they can be wired into `Ports` and still be
3//! asserted on from the test.
4//!
5//! Interior mutability here records test observations; it is not request
6//! state (ADR 0007) — the scoped `Mutex` allow follows the policy in the
7//! workspace `clippy.toml`.
8
9#![allow(clippy::disallowed_types)]
10// Every accessor locks an unpoisoned fixture mutex; per-method `# Panics`
11// sections would add noise without information.
12#![allow(clippy::missing_panics_doc)]
13
14use async_trait::async_trait;
15use bytes::Bytes;
16use cratefield_core::sigv4::{self, Credentials, SigV4Error, SignableRequest};
17use cratefield_core::{
18    Answer, AnswerValue, Calibration, Captcha, CaptchaError, CertificateStatus, Classifier,
19    ClassifierError, ClassifierProfile, Clock, Completion, Credential, CustomHostname,
20    CustomHostnameError, CustomHostnames, Database, DbError, Decision, Defer, Destination,
21    DnsRecordType, Filed, HostnameClaim, HttpClient, HttpError, KeyValue, KvError, MailError,
22    Mailer, Message, ModelTier, Prompt, ProviderStatus, Question, RateLimitError, RateLimiter, Row,
23    Rows, SendOutcome, Statement, TextModel, TextModelError, TicketComment, TicketDraft,
24    TicketState, TicketStatus, Tracker, TrackerError, Validation, Verdict, check_hostname,
25    validate_questions,
26};
27use futures_core::future::BoxFuture;
28use http::header::{CONTENT_TYPE, RETRY_AFTER};
29use http::{HeaderValue, Request, Response, StatusCode};
30use std::collections::{BTreeMap, HashMap, VecDeque};
31use std::sync::Arc;
32use std::sync::atomic::{AtomicUsize, Ordering};
33use std::time::Duration;
34
35// Recording fixtures, not request state (see module docs).
36#[allow(clippy::disallowed_types)]
37use std::sync::Mutex;
38
39// ---------------------------------------------------------------------------
40// FakeMailer
41
42/// How a [`FakeMailer`] answers.
43///
44/// [`Error`](Self::Error) is what makes the failure arms testable at all
45/// (issue #236): before it the fake could only ever produce
46/// `MailError::Upstream("fake mailer failure")`, so `Invalid { detail }`
47/// and `DomainNotVerified { domain }` — the two variants that carry the
48/// provider's own text, and therefore the two that can carry a recipient
49/// address — could not be driven from a test. Every arm of a caller's
50/// outcome mapping is now reachable, with the text the caller chooses:
51///
52/// ```
53/// # use cratefield_testing::{FakeMailer, MailerMode};
54/// # use cratefield_core::MailError;
55/// let mailer = FakeMailer::new(MailerMode::Error(MailError::Invalid {
56///     detail: "to: alice@example.test is suppressed".to_owned(),
57/// }));
58/// ```
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum MailerMode {
61    /// Accept and record the message.
62    SendOk,
63    /// Report that the adapter has no API key or no verified domain.
64    NotConfigured,
65    /// A generic upstream failure, for a caller that does not care which.
66    Fail,
67    /// Exactly this error, text and all.
68    Error(MailError),
69}
70
71#[derive(Clone)]
72pub struct FakeMailer {
73    inner: Arc<FakeMailerInner>,
74}
75
76struct FakeMailerInner {
77    mode: Mutex<MailerMode>,
78    sent: Mutex<Vec<Message>>,
79}
80
81impl FakeMailer {
82    #[must_use]
83    pub fn new(mode: MailerMode) -> Self {
84        Self {
85            inner: Arc::new(FakeMailerInner {
86                mode: Mutex::new(mode),
87                sent: Mutex::new(Vec::new()),
88            }),
89        }
90    }
91
92    /// Every message recorded so far.
93    #[must_use]
94    pub fn sent(&self) -> Vec<Message> {
95        self.inner.sent.lock().expect("mailer lock").clone()
96    }
97
98    /// The most recent message.
99    #[must_use]
100    pub fn last_message(&self) -> Option<Message> {
101        self.inner.sent.lock().expect("mailer lock").last().cloned()
102    }
103
104    /// Switches the mode (e.g. degrade to `NotConfigured` mid-test).
105    pub fn set_mode(&self, mode: MailerMode) {
106        *self.inner.mode.lock().expect("mailer lock") = mode;
107    }
108}
109
110#[async_trait]
111impl Mailer for FakeMailer {
112    async fn send(&self, message: Message) -> Result<SendOutcome, MailError> {
113        let mode = self.inner.mode.lock().expect("mailer lock").clone();
114        match mode {
115            MailerMode::SendOk => {
116                let id = format!(
117                    "fake-{}",
118                    self.inner.sent.lock().expect("mailer lock").len()
119                );
120                self.inner.sent.lock().expect("mailer lock").push(message);
121                Ok(SendOutcome::Sent { id })
122            }
123            MailerMode::NotConfigured => Ok(SendOutcome::NotConfigured),
124            MailerMode::Fail => Err(MailError::Upstream("fake mailer failure".to_string())),
125            MailerMode::Error(error) => Err(error),
126        }
127    }
128}
129
130// ---------------------------------------------------------------------------
131// FakeCaptcha
132
133#[derive(Clone)]
134pub struct FakeCaptcha {
135    allow_all: bool,
136    allowed_tokens: Arc<Vec<String>>,
137}
138
139impl FakeCaptcha {
140    /// Every token verifies.
141    #[must_use]
142    pub fn allow_all() -> Self {
143        Self {
144            allow_all: true,
145            allowed_tokens: Arc::new(Vec::new()),
146        }
147    }
148
149    /// Only the listed tokens verify.
150    #[must_use]
151    pub fn with_tokens(tokens: impl IntoIterator<Item = impl Into<String>>) -> Self {
152        Self {
153            allow_all: false,
154            allowed_tokens: Arc::new(tokens.into_iter().map(Into::into).collect()),
155        }
156    }
157}
158
159#[async_trait]
160impl Captcha for FakeCaptcha {
161    async fn verify(&self, token: &str, _remote_ip: Option<&str>) -> Result<Verdict, CaptchaError> {
162        let ok = self.allow_all || self.allowed_tokens.iter().any(|t| t == token);
163        Ok(Verdict {
164            ok,
165            reason: (!ok).then(|| "token not allowed".to_string()),
166        })
167    }
168
169    fn binding(&self) -> Option<cratefield_core::CaptchaBinding> {
170        Some(cratefield_core::CaptchaBinding {
171            hostname_bound: true,
172            action_bound: true,
173            fail_open: false,
174        })
175    }
176}
177
178// ---------------------------------------------------------------------------
179// FakeRateLimiter (scripted)
180
181#[derive(Clone)]
182pub struct FakeRateLimiter {
183    inner: Arc<FakeRateLimiterInner>,
184}
185
186struct FakeRateLimiterInner {
187    scripted: Mutex<VecDeque<Decision>>,
188    default: Decision,
189    calls: AtomicUsize,
190}
191
192impl FakeRateLimiter {
193    /// Falls through to `default` once the script is exhausted.
194    #[must_use]
195    pub fn scripted(decisions: Vec<Decision>, default: Decision) -> Self {
196        Self {
197            inner: Arc::new(FakeRateLimiterInner {
198                scripted: Mutex::new(decisions.into_iter().collect()),
199                default,
200                calls: AtomicUsize::new(0),
201            }),
202        }
203    }
204
205    /// Always allows.
206    #[must_use]
207    pub fn always_allow() -> Self {
208        Self::scripted(
209            Vec::new(),
210            Decision {
211                ok: true,
212                retry_after: None,
213                quota: None,
214            },
215        )
216    }
217
218    #[must_use]
219    pub fn calls(&self) -> usize {
220        self.inner.calls.load(Ordering::SeqCst)
221    }
222}
223
224#[async_trait]
225impl RateLimiter for FakeRateLimiter {
226    async fn limit(&self, _key: &str) -> Result<Decision, RateLimitError> {
227        self.inner.calls.fetch_add(1, Ordering::SeqCst);
228        let scripted = self
229            .inner
230            .scripted
231            .lock()
232            .expect("limiter lock")
233            .pop_front();
234        Ok(scripted.unwrap_or_else(|| self.inner.default.clone()))
235    }
236}
237
238// ---------------------------------------------------------------------------
239// FixedClock
240
241#[derive(Debug, Clone)]
242pub struct FixedClock(pub time::OffsetDateTime);
243
244#[async_trait]
245impl Clock for FixedClock {
246    fn now(&self) -> time::OffsetDateTime {
247        self.0
248    }
249}
250
251// ---------------------------------------------------------------------------
252// MemoryKeyValue
253
254#[derive(Clone, Default)]
255pub struct MemoryKeyValue {
256    inner: Arc<MemoryKeyValueInner>,
257}
258
259#[derive(Default)]
260struct MemoryKeyValueInner {
261    entries: Mutex<HashMap<String, String>>,
262}
263
264impl MemoryKeyValue {
265    #[must_use]
266    pub fn new() -> Self {
267        Self::default()
268    }
269}
270
271#[async_trait]
272impl KeyValue for MemoryKeyValue {
273    async fn get(&self, key: &str) -> Result<Option<String>, KvError> {
274        Ok(self
275            .inner
276            .entries
277            .lock()
278            .expect("kv lock")
279            .get(key)
280            .cloned())
281    }
282
283    async fn put(&self, key: &str, value: &str, _ttl: Option<Duration>) -> Result<(), KvError> {
284        self.inner
285            .entries
286            .lock()
287            .expect("kv lock")
288            .insert(key.to_string(), value.to_string());
289        Ok(())
290    }
291
292    async fn delete(&self, key: &str) -> Result<(), KvError> {
293        self.inner.entries.lock().expect("kv lock").remove(key);
294        Ok(())
295    }
296}
297
298// ---------------------------------------------------------------------------
299// FakeHttpClient (scripted responses, captures requests)
300
301#[derive(Clone)]
302pub struct FakeHttpClient {
303    inner: Arc<FakeHttpInner>,
304}
305
306struct FakeHttpInner {
307    responses: Mutex<VecDeque<Result<Response<Bytes>, HttpError>>>,
308    captured: Mutex<Vec<(String, String, String)>>, // method, uri, body
309}
310
311impl FakeHttpClient {
312    /// Responds with `responses` in order, then always with a 500.
313    #[must_use]
314    pub fn scripted(responses: Vec<Result<Response<Bytes>, HttpError>>) -> Self {
315        Self {
316            inner: Arc::new(FakeHttpInner {
317                responses: Mutex::new(responses.into_iter().collect()),
318                captured: Mutex::new(Vec::new()),
319            }),
320        }
321    }
322
323    #[must_use]
324    pub fn ok_json(body: &'static str) -> Self {
325        Self::scripted(vec![
326            Response::builder()
327                .status(200)
328                .body(Bytes::from(body))
329                .map_err(|err| HttpError::Transport(err.to_string())),
330        ])
331    }
332
333    /// One Owlpost-shaped `application/problem+json` error (issue #666).
334    #[must_use]
335    pub fn owlpost_problem(status: StatusCode, title: &str, detail: &str) -> Self {
336        Self::scripted(vec![Ok(problem_json(status, title, detail))])
337    }
338
339    /// Owlpost's 429: a problem body carrying `Retry-After: <secs>`, which
340    /// the adapter maps to `MailError::RateLimited` with that delay.
341    #[must_use]
342    pub fn owlpost_rate_limited(retry_after_secs: u64) -> Self {
343        Self::scripted(vec![Ok(with_retry_after(
344            problem_json(
345                StatusCode::TOO_MANY_REQUESTS,
346                "Too Many Requests",
347                "slow down",
348            ),
349            retry_after_secs,
350        ))])
351    }
352
353    /// One Colonizer-shaped error: `{"error": message}`.
354    #[must_use]
355    pub fn colonizer_error(status: StatusCode, message: &str) -> Self {
356        Self::scripted(vec![Ok(error_json(status, message))])
357    }
358
359    /// Colonizer's 429: an `{"error": …}` body carrying `Retry-After`.
360    #[must_use]
361    pub fn colonizer_rate_limited(retry_after_secs: u64) -> Self {
362        Self::scripted(vec![Ok(with_retry_after(
363            error_json(StatusCode::TOO_MANY_REQUESTS, "rate limited"),
364            retry_after_secs,
365        ))])
366    }
367
368    /// Every captured request as `(method, uri, body)`.
369    #[must_use]
370    pub fn captured(&self) -> Vec<(String, String, String)> {
371        self.inner.captured.lock().expect("http lock").clone()
372    }
373}
374
375/// An RFC 9457 `application/problem+json` response — the shape Owlpost
376/// answers errors with: `type`, `title`, `status` and `detail`, the
377/// `status` both the HTTP status and the body's own field. Build one for
378/// a [`scripted`](FakeHttpClient::scripted) sequence, or take it from a
379/// preset like [`FakeHttpClient::owlpost_problem`].
380#[must_use]
381pub fn problem_json(status: StatusCode, title: &str, detail: &str) -> Response<Bytes> {
382    let body = serde_json::json!({
383        "type": "about:blank",
384        "title": title,
385        "status": status.as_u16(),
386        "detail": detail,
387    })
388    .to_string();
389    Response::builder()
390        .status(status)
391        .header(CONTENT_TYPE, "application/problem+json")
392        .body(Bytes::from(body))
393        .expect("a problem+json response builds")
394}
395
396/// A Colonizer-shaped error body, `{"error": message}` under
397/// `application/json`.
398#[must_use]
399pub fn error_json(status: StatusCode, message: &str) -> Response<Bytes> {
400    let body = serde_json::json!({ "error": message }).to_string();
401    Response::builder()
402        .status(status)
403        .header(CONTENT_TYPE, "application/json")
404        .body(Bytes::from(body))
405        .expect("an error response builds")
406}
407
408/// Adds `Retry-After: <retry_after_secs>` to a response in seconds — the
409/// header a 429 carries and the adapters read as a retry hint.
410#[must_use]
411pub fn with_retry_after(mut response: Response<Bytes>, retry_after_secs: u64) -> Response<Bytes> {
412    response
413        .headers_mut()
414        .insert(RETRY_AFTER, HeaderValue::from(retry_after_secs));
415    response
416}
417
418#[async_trait]
419impl HttpClient for FakeHttpClient {
420    async fn send(&self, request: Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
421        let (parts, body) = request.into_parts();
422        self.inner.captured.lock().expect("http lock").push((
423            parts.method.to_string(),
424            parts.uri.to_string(),
425            String::from_utf8_lossy(&body).to_string(),
426        ));
427        let next = self.inner.responses.lock().expect("http lock").pop_front();
428        next.unwrap_or_else(|| Err(HttpError::Transport("fake http exhausted".to_string())))
429    }
430}
431
432// ---------------------------------------------------------------------------
433// FakeDefer (collects futures; drain runs them)
434
435#[derive(Clone, Default)]
436pub struct FakeDefer {
437    inner: Arc<FakeDeferInner>,
438}
439
440#[derive(Default)]
441struct FakeDeferInner {
442    pending: Mutex<Vec<BoxFuture<'static, ()>>>,
443    deferred: AtomicUsize,
444}
445
446impl FakeDefer {
447    #[must_use]
448    pub fn new() -> Self {
449        Self::default()
450    }
451
452    /// Runs every deferred future to completion, in deferral order.
453    // Async for API symmetry with `drain().await` call sites (the futures
454    // run on a sync block-on inside).
455    #[allow(clippy::unused_async, clippy::unused_async_trait_impl)]
456    pub async fn drain(&self) {
457        while !self.inner.pending.lock().expect("defer lock").is_empty() {
458            let next = self.inner.pending.lock().expect("defer lock").remove(0);
459            pollster::block_on(next);
460        }
461    }
462
463    #[must_use]
464    pub fn deferred_count(&self) -> usize {
465        self.inner.deferred.load(Ordering::SeqCst)
466    }
467}
468
469impl Defer for FakeDefer {
470    fn wait_until(&self, fut: BoxFuture<'static, ()>) {
471        self.inner.deferred.fetch_add(1, Ordering::SeqCst);
472        self.inner.pending.lock().expect("defer lock").push(fut);
473    }
474}
475
476// ---------------------------------------------------------------------------
477// Transparent Database passthrough (re-exported for tests that need a
478// trivial Database without SQLite): an always-empty database.
479
480#[derive(Debug, Clone, Copy, Default)]
481pub struct EmptyDatabase;
482
483#[async_trait]
484impl Database for EmptyDatabase {
485    async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
486        Err(DbError::Execute(format!("empty database: {}", stmt.sql)))
487    }
488
489    async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
490        if stmt.sql.trim() == "SELECT 1" {
491            Ok(Rows::new(vec![Row::new(vec![(
492                "1".to_string(),
493                sea_query::Value::Int(Some(1)),
494            )])]))
495        } else {
496            Err(DbError::Query(format!("empty database: {}", stmt.sql)))
497        }
498    }
499
500    async fn batch_atomic(&self, _stmts: &[Statement]) -> Result<(), DbError> {
501        Err(DbError::Batch("empty database".to_string()))
502    }
503}
504
505/// An in-process [`Dispatcher`](cratefield_core::Dispatcher) that answers from an
506/// axum [`Router`](axum::Router), so a
507/// module can be exercised through a sidecar mount without a network or a
508/// second Worker (ADR 0009). The conformance kit uses it to run the same
509/// assertions against both mounts (#64).
510///
511/// Also the failure fixture: [`unbound`](FakeDispatcher::unbound) has no
512/// binding at all, and [`failing`](FakeDispatcher::failing) accepts the
513/// binding and then refuses to answer.
514#[derive(Clone)]
515pub struct FakeDispatcher {
516    binding: String,
517    behaviour: FakeDispatch,
518    calls: Arc<AtomicUsize>,
519}
520
521#[derive(Clone)]
522enum FakeDispatch {
523    Serve(Arc<Mutex<axum::Router>>),
524    Unbound,
525    Failing(String),
526}
527
528impl FakeDispatcher {
529    /// Serves `router` on `binding`.
530    #[must_use]
531    pub fn serving(binding: impl Into<String>, router: axum::Router) -> Self {
532        Self {
533            binding: binding.into(),
534            behaviour: FakeDispatch::Serve(Arc::new(Mutex::new(router))),
535            calls: Arc::new(AtomicUsize::new(0)),
536        }
537    }
538
539    /// Has no bindings, so `has()` is always false: the "mounted but this
540    /// deployment has no such binding" case.
541    #[must_use]
542    pub fn unbound() -> Self {
543        Self {
544            binding: String::new(),
545            behaviour: FakeDispatch::Unbound,
546            calls: Arc::new(AtomicUsize::new(0)),
547        }
548    }
549
550    /// Accepts `binding` and then fails to answer.
551    #[must_use]
552    pub fn failing(binding: impl Into<String>, reason: impl Into<String>) -> Self {
553        Self {
554            binding: binding.into(),
555            behaviour: FakeDispatch::Failing(reason.into()),
556            calls: Arc::new(AtomicUsize::new(0)),
557        }
558    }
559
560    /// How many dispatches were attempted. A forwarder must not retry, so a
561    /// single request must leave this at one.
562    #[must_use]
563    pub fn calls(&self) -> usize {
564        self.calls.load(Ordering::SeqCst)
565    }
566}
567
568#[async_trait]
569impl cratefield_core::Dispatcher for FakeDispatcher {
570    fn has(&self, binding: &str) -> bool {
571        !matches!(self.behaviour, FakeDispatch::Unbound) && binding == self.binding
572    }
573
574    async fn dispatch(
575        &self,
576        binding: &str,
577        request: Request<Bytes>,
578    ) -> Result<Response<Bytes>, cratefield_core::DispatchError> {
579        self.calls.fetch_add(1, Ordering::SeqCst);
580        match &self.behaviour {
581            FakeDispatch::Unbound => {
582                Err(cratefield_core::DispatchError::NotBound(binding.to_owned()))
583            }
584            FakeDispatch::Failing(reason) => Err(cratefield_core::DispatchError::Unavailable {
585                binding: binding.to_owned(),
586                reason: reason.clone(),
587            }),
588            FakeDispatch::Serve(router) => {
589                let router = router.lock().unwrap().clone();
590                let (parts, body) = request.into_parts();
591                let request = Request::from_parts(parts, axum::body::Body::from(body));
592                let response =
593                    tower::ServiceExt::oneshot(router, request)
594                        .await
595                        .map_err(|err| cratefield_core::DispatchError::Unavailable {
596                            binding: binding.to_owned(),
597                            reason: err.to_string(),
598                        })?;
599                let (parts, body) = response.into_parts();
600                let bytes = axum::body::to_bytes(body, usize::MAX)
601                    .await
602                    .map_err(|err| cratefield_core::DispatchError::Unavailable {
603                        binding: binding.to_owned(),
604                        reason: err.to_string(),
605                    })?;
606                Ok(Response::from_parts(parts, bytes))
607            }
608        }
609    }
610}
611
612/// An in-memory [`cratefield_core::Blob`] store for module tests.
613/// [`MemoryBlob::new`] has no presigned URLs (`signed_url` reports
614/// `Unsupported`, as a directory store does); [`MemoryBlob::with_presign`] signs
615/// real `SigV4` URLs that [`MemoryBlob::verify_presigned`] checks back.
616#[derive(Clone, Default)]
617pub struct MemoryBlob {
618    objects: Arc<std::sync::Mutex<std::collections::HashMap<String, cratefield_core::BlobObject>>>,
619    /// The clock presigning stamps from; `Some` once `with_presign` is called.
620    presign_clock: Option<Arc<dyn Clock>>,
621}
622
623/// The fixed, obviously fake presigning identity, stable so a test can assert
624/// on the URL text. `blob.test` is reserved for documentation (RFC 2606).
625const PRESIGN_ACCESS_KEY_ID: &str = "AKIATESTACCESSKEY000000";
626const PRESIGN_SECRET_ACCESS_KEY: &str = "memory-blob-presign-test-secret";
627const PRESIGN_HOST: &str = "blob.test";
628
629impl MemoryBlob {
630    #[must_use]
631    pub fn new() -> Self {
632        Self::default()
633    }
634
635    /// Opts the store into presigning: `signed_url` and `signed_put_url` sign
636    /// real `SigV4` URLs stamped from `clock`, checked back by
637    /// [`verify_presigned`](Self::verify_presigned).
638    #[must_use]
639    pub fn with_presign(clock: Arc<dyn Clock>) -> Self {
640        Self {
641            presign_clock: Some(clock),
642            ..Self::default()
643        }
644    }
645
646    /// How many objects are stored, for assertions.
647    #[must_use]
648    pub fn len(&self) -> usize {
649        self.objects.lock().unwrap().len()
650    }
651
652    /// Whether the store is empty, for assertions.
653    #[must_use]
654    pub fn is_empty(&self) -> bool {
655        self.len() == 0
656    }
657
658    /// Signs `method` on `key` (used verbatim as the URL path — keep test keys
659    /// URL-safe) with `headers`, or `Unsupported` without `with_presign`.
660    fn presign(
661        &self,
662        method: &str,
663        key: &str,
664        headers: &[(String, String)],
665        ttl: Duration,
666    ) -> Result<String, cratefield_core::BlobError> {
667        let clock = self
668            .presign_clock
669            .as_ref()
670            .ok_or_else(|| cratefield_core::BlobError::Unsupported("no presigner".to_owned()))?;
671        let path = format!("/{key}");
672        let request = SignableRequest {
673            method,
674            host: PRESIGN_HOST,
675            path: &path,
676            query: &[],
677            headers,
678        };
679        Ok(sigv4::presign(
680            &Credentials::new(PRESIGN_ACCESS_KEY_ID, PRESIGN_SECRET_ACCESS_KEY),
681            "auto",
682            "s3",
683            &request,
684            ttl.as_secs(),
685            clock.now(),
686        ))
687    }
688
689    /// Verifies a URL this store minted, at its clock's current time, and
690    /// returns the key it names.
691    ///
692    /// # Errors
693    ///
694    /// As [`sigv4::verify_presigned`](cratefield_core::sigv4::verify_presigned),
695    /// or [`SigV4Error::Malformed`] without a clock or a URL path.
696    pub fn verify_presigned(
697        &self,
698        url: &str,
699        method: &str,
700        headers: &[(String, String)],
701    ) -> Result<String, SigV4Error> {
702        let clock = self
703            .presign_clock
704            .as_ref()
705            .ok_or_else(|| SigV4Error::Malformed("no presigning clock".to_owned()))?;
706        let credentials = Credentials::new(PRESIGN_ACCESS_KEY_ID, PRESIGN_SECRET_ACCESS_KEY);
707        sigv4::verify_presigned(
708            url,
709            method,
710            headers,
711            &credentials,
712            "auto",
713            "s3",
714            clock.now(),
715        )?;
716        url.split_once("://")
717            .and_then(|(_, rest)| rest.split_once('/'))
718            .map(|(_, path)| path.split(['?', '#']).next().unwrap_or(path).to_owned())
719            .ok_or_else(|| SigV4Error::Malformed("not an absolute URL".to_owned()))
720    }
721}
722
723#[async_trait]
724impl cratefield_core::Blob for MemoryBlob {
725    async fn put(
726        &self,
727        key: &str,
728        bytes: &[u8],
729        content_type: &str,
730    ) -> Result<(), cratefield_core::BlobError> {
731        cratefield_core::check_blob_size(bytes)?;
732        self.objects.lock().unwrap().insert(
733            key.to_owned(),
734            cratefield_core::BlobObject {
735                bytes: bytes.to_vec(),
736                content_type: content_type.to_owned(),
737            },
738        );
739        Ok(())
740    }
741    async fn get(
742        &self,
743        key: &str,
744    ) -> Result<Option<cratefield_core::BlobObject>, cratefield_core::BlobError> {
745        Ok(self.objects.lock().unwrap().get(key).cloned())
746    }
747    async fn delete(&self, key: &str) -> Result<(), cratefield_core::BlobError> {
748        self.objects.lock().unwrap().remove(key);
749        Ok(())
750    }
751    async fn signed_url(
752        &self,
753        key: &str,
754        ttl: std::time::Duration,
755    ) -> Result<String, cratefield_core::BlobError> {
756        self.presign("GET", key, &[], ttl)
757    }
758    async fn signed_put_url(
759        &self,
760        key: &str,
761        content_type: &str,
762        content_length: Option<u64>,
763        ttl: std::time::Duration,
764    ) -> Result<cratefield_core::PresignedPut, cratefield_core::BlobError> {
765        let mut headers = vec![("content-type".to_owned(), content_type.to_owned())];
766        if let Some(length) = content_length {
767            headers.push(("content-length".to_owned(), length.to_string()));
768        }
769        Ok(cratefield_core::PresignedPut {
770            url: self.presign("PUT", key, &headers, ttl)?,
771            method: "PUT",
772            headers,
773        })
774    }
775}
776
777// ---------------------------------------------------------------------------
778// FakeEmbedder
779
780/// The width of every [`FakeEmbedder`] vector, and so of the
781/// [`ExactVectorIndex`](cratefield_core::ExactVectorIndex) the kit wires in
782/// [`full_fake_ports`](crate::full_fake_ports) — the two must agree.
783pub const FAKE_EMBEDDER_DIMENSIONS: usize = 8;
784
785/// The model name a [`FakeEmbedder`] reports.
786pub const FAKE_EMBEDDER_MODEL: &str = "fake-embedder";
787
788/// A deterministic [`cratefield_core::Embedder`] for module tests: one
789/// fixed-width vector per input text, derived from the text's own bytes,
790/// and a whitespace-word-count `input_tokens`.
791#[derive(Debug, Clone, Copy, Default)]
792pub struct FakeEmbedder;
793
794impl FakeEmbedder {
795    /// The vector `text` embeds to: its bytes accumulated round-robin into
796    /// [`FAKE_EMBEDDER_DIMENSIONS`] slots. Pure, so a test can compute the
797    /// expected vector itself.
798    #[must_use]
799    pub fn vector(text: &str) -> Vec<f32> {
800        let mut out = vec![0.0; FAKE_EMBEDDER_DIMENSIONS];
801        for (index, byte) in text.bytes().enumerate() {
802            out[index % FAKE_EMBEDDER_DIMENSIONS] += f32::from(byte);
803        }
804        out
805    }
806}
807
808#[async_trait]
809impl cratefield_core::Embedder for FakeEmbedder {
810    async fn embed(
811        &self,
812        texts: &[String],
813    ) -> Result<cratefield_core::Embeddings, cratefield_core::EmbedError> {
814        let input_tokens = texts
815            .iter()
816            .map(|text| text.split_whitespace().count() as u64)
817            .sum();
818        let mut embeddings =
819            cratefield_core::Embeddings::new(FAKE_EMBEDDER_MODEL).usage(input_tokens);
820        for text in texts {
821            embeddings = embeddings.vector(Self::vector(text));
822        }
823        Ok(embeddings)
824    }
825}
826
827// ---------------------------------------------------------------------------
828// FakePush
829
830/// How a [`FakePush`] responds, mirroring [`MailerMode`] for the push port.
831///
832/// [`Error`](Self::Error) carries the provider text a real adapter would
833/// have wrapped (issue #236). The fixed modes only ever produce clean
834/// strings, so no test using them could show a device token or a push
835/// endpoint arriving somewhere it should not.
836#[derive(Debug, Clone, PartialEq, Eq)]
837pub enum PushMode {
838    /// Accept and record the notification.
839    DeliverOk,
840    /// Report the adapter is not configured (no key).
841    NotConfigured,
842    /// Report the device token is dead (APNs `410`): the caller prunes it.
843    Unregistered,
844    /// A retryable failure.
845    Transient,
846    /// Exactly this error, text and all.
847    Error(cratefield_core::PushError),
848}
849
850/// An in-memory [`cratefield_core::Push`] for module tests: records every
851/// `(recipient, notification)` and answers according to its [`PushMode`].
852///
853/// The mode is global by default and can be overridden **per recipient**
854/// ([`FakePush::set_mode_for`]), so a fan-out test can make exactly one of
855/// five devices dead and assert that only that one is pruned — the thing a
856/// single global mode cannot express (issue #177).
857#[derive(Clone)]
858pub struct FakePush {
859    inner: Arc<FakePushInner>,
860}
861
862struct FakePushInner {
863    mode: Mutex<PushMode>,
864    per_recipient: Mutex<HashMap<cratefield_core::Recipient, PushMode>>,
865    sent: Mutex<Vec<(cratefield_core::Recipient, cratefield_core::Notification)>>,
866}
867
868impl FakePush {
869    #[must_use]
870    pub fn new(mode: PushMode) -> Self {
871        Self {
872            inner: Arc::new(FakePushInner {
873                mode: Mutex::new(mode),
874                per_recipient: Mutex::new(HashMap::new()),
875                sent: Mutex::new(Vec::new()),
876            }),
877        }
878    }
879
880    /// Every `(recipient, notification)` delivered so far. A send that
881    /// answered `NotConfigured` or failed is not recorded.
882    #[must_use]
883    pub fn sent(&self) -> Vec<(cratefield_core::Recipient, cratefield_core::Notification)> {
884        self.inner.sent.lock().expect("push lock").clone()
885    }
886
887    /// The most recent `(recipient, notification)`.
888    #[must_use]
889    pub fn last(&self) -> Option<(cratefield_core::Recipient, cratefield_core::Notification)> {
890        self.inner.sent.lock().expect("push lock").last().cloned()
891    }
892
893    /// Everything delivered to one recipient.
894    #[must_use]
895    pub fn sent_to(
896        &self,
897        recipient: &cratefield_core::Recipient,
898    ) -> Vec<cratefield_core::Notification> {
899        self.inner
900            .sent
901            .lock()
902            .expect("push lock")
903            .iter()
904            .filter(|(to, _)| to == recipient)
905            .map(|(_, notification)| notification.clone())
906            .collect()
907    }
908
909    /// Switches the mode every recipient without an override answers with
910    /// (e.g. flip to `Unregistered` mid-test).
911    pub fn set_mode(&self, mode: PushMode) {
912        *self.inner.mode.lock().expect("push lock") = mode;
913    }
914
915    /// Makes one recipient answer with `mode`, whatever the global mode is —
916    /// one dead token among live ones, say.
917    pub fn set_mode_for(&self, recipient: &cratefield_core::Recipient, mode: PushMode) {
918        self.inner
919            .per_recipient
920            .lock()
921            .expect("push lock")
922            .insert(recipient.clone(), mode);
923    }
924
925    /// Drops one recipient's override, putting it back on the global mode.
926    pub fn clear_mode_for(&self, recipient: &cratefield_core::Recipient) {
927        self.inner
928            .per_recipient
929            .lock()
930            .expect("push lock")
931            .remove(recipient);
932    }
933
934    /// The mode `recipient` will answer with.
935    #[must_use]
936    pub fn mode_for(&self, recipient: &cratefield_core::Recipient) -> PushMode {
937        self.inner
938            .per_recipient
939            .lock()
940            .expect("push lock")
941            .get(recipient)
942            .cloned()
943            .unwrap_or_else(|| self.inner.mode.lock().expect("push lock").clone())
944    }
945}
946
947impl Default for FakePush {
948    fn default() -> Self {
949        Self::new(PushMode::DeliverOk)
950    }
951}
952
953#[async_trait]
954impl cratefield_core::Push for FakePush {
955    async fn send(
956        &self,
957        to: &cratefield_core::Recipient,
958        notification: &cratefield_core::Notification,
959    ) -> Result<cratefield_core::PushOutcome, cratefield_core::PushError> {
960        match self.mode_for(to) {
961            PushMode::DeliverOk => {
962                let id = format!(
963                    "fake-push-{}",
964                    self.inner.sent.lock().expect("push lock").len()
965                );
966                self.inner
967                    .sent
968                    .lock()
969                    .expect("push lock")
970                    .push((to.clone(), notification.clone()));
971                Ok(cratefield_core::PushOutcome::Delivered { id: Some(id) })
972            }
973            PushMode::NotConfigured => Ok(cratefield_core::PushOutcome::NotConfigured),
974            PushMode::Unregistered => Err(cratefield_core::PushError::Unregistered),
975            PushMode::Transient => Err(cratefield_core::PushError::transient("fake push failure")),
976            PushMode::Error(error) => Err(error),
977        }
978    }
979}
980
981// ---------------------------------------------------------------------------
982// FakeTextModel
983
984/// How a [`FakeTextModel`] answers, mirroring [`PushMode`] for the text
985/// model port (issue #429).
986///
987/// [`Error`](Self::Error) carries the exact error a test wants, and
988/// [`Rejected`](Self::Rejected)/[`Transient`](Self::Transient) are the two
989/// fixed modes the port's own contract names — a caller's retry and
990/// back-off arms stay reachable without inventing provider responses.
991#[derive(Debug, Clone, PartialEq)]
992pub enum TextModelMode {
993    /// Complete with this text, under a deterministic `fake-<tier>` model
994    /// name and plausible token counts.
995    Reply(String),
996    /// Answer exactly this completion, usage and parsed JSON included.
997    Complete(cratefield_core::Completion),
998    /// Report the tier is unwired (`TextModelError::NotConfigured`).
999    NotConfigured,
1000    /// A non-retryable refusal carrying the provider text.
1001    Rejected(String),
1002    /// A retryable failure with the provider's back-off, where it said one.
1003    Transient { retry_after: Option<Duration> },
1004    /// Exactly this error, text and all.
1005    Error(cratefield_core::TextModelError),
1006}
1007
1008/// An in-memory [`TextModel`] for module tests: records every [`Prompt`]
1009/// it completed and answers according to its [`TextModelMode`].
1010///
1011/// The mode is global by default and can be overridden **per tier**
1012/// ([`FakeTextModel::set_mode_for`]), mirroring [`FakePush`]'s
1013/// per-recipient override — the natural analogue, and the thing the port
1014/// exists for: one test can wire the fast tier to a drafted reply and the
1015/// strong tier to a refusal, and watch a module treat the two differently
1016/// without either vendor being named.
1017///
1018/// A completion that answered `NotConfigured` or failed is **not**
1019/// recorded, the same rule [`FakePush`] applies to a send — the recording
1020/// means "the model answered", and a caller that retried on
1021/// `Transient { .. }` then sees one recorded prompt per attempt it got an
1022/// answer for.
1023#[derive(Clone)]
1024pub struct FakeTextModel {
1025    inner: Arc<FakeTextModelInner>,
1026}
1027
1028struct FakeTextModelInner {
1029    mode: Mutex<TextModelMode>,
1030    per_tier: Mutex<HashMap<ModelTier, TextModelMode>>,
1031    prompts: Mutex<Vec<Prompt>>,
1032}
1033
1034/// Plausible, deterministic token counts for a fake answer: roughly four
1035/// characters per token on both sides. Stable for a given prompt and text,
1036/// which is what an assertion needs — no test wants to guess a provider's
1037/// tokenizer.
1038fn fake_usage(prompt: &Prompt, text: &str) -> (u64, u64) {
1039    let prompt_chars = prompt.system.as_deref().map_or(0, str::len)
1040        + prompt
1041            .messages
1042            .iter()
1043            .map(|turn| turn.content.len())
1044            .sum::<usize>();
1045    let input = (prompt_chars / 4).max(1) as u64;
1046    let output = (text.len() / 4).max(1) as u64;
1047    (input, output)
1048}
1049
1050impl FakeTextModel {
1051    #[must_use]
1052    pub fn new(mode: TextModelMode) -> Self {
1053        Self {
1054            inner: Arc::new(FakeTextModelInner {
1055                mode: Mutex::new(mode),
1056                per_tier: Mutex::new(HashMap::new()),
1057                prompts: Mutex::new(Vec::new()),
1058            }),
1059        }
1060    }
1061
1062    /// Every prompt answered so far, in order. A completion that answered
1063    /// `NotConfigured` or failed is not recorded.
1064    #[must_use]
1065    pub fn prompts(&self) -> Vec<Prompt> {
1066        self.inner.prompts.lock().expect("text model lock").clone()
1067    }
1068
1069    /// The most recent prompt answered.
1070    #[must_use]
1071    pub fn last(&self) -> Option<Prompt> {
1072        self.inner
1073            .prompts
1074            .lock()
1075            .expect("text model lock")
1076            .last()
1077            .cloned()
1078    }
1079
1080    /// Switches the mode every tier without an override answers with
1081    /// (e.g. flip to `NotConfigured` mid-test).
1082    pub fn set_mode(&self, mode: TextModelMode) {
1083        *self.inner.mode.lock().expect("text model lock") = mode;
1084    }
1085
1086    /// Makes one tier answer with `mode`, whatever the global mode is —
1087    /// the fast tier drafting and the strong tier refusing, say.
1088    pub fn set_mode_for(&self, tier: ModelTier, mode: TextModelMode) {
1089        self.inner
1090            .per_tier
1091            .lock()
1092            .expect("text model lock")
1093            .insert(tier, mode);
1094    }
1095
1096    /// Drops one tier's override, putting it back on the global mode.
1097    pub fn clear_mode_for(&self, tier: ModelTier) {
1098        self.inner
1099            .per_tier
1100            .lock()
1101            .expect("text model lock")
1102            .remove(&tier);
1103    }
1104
1105    /// The mode `tier` will answer with.
1106    #[must_use]
1107    pub fn mode_for(&self, tier: ModelTier) -> TextModelMode {
1108        self.inner
1109            .per_tier
1110            .lock()
1111            .expect("text model lock")
1112            .get(&tier)
1113            .cloned()
1114            .unwrap_or_else(|| self.inner.mode.lock().expect("text model lock").clone())
1115    }
1116
1117    fn record(&self, prompt: &Prompt) {
1118        self.inner
1119            .prompts
1120            .lock()
1121            .expect("text model lock")
1122            .push(prompt.clone());
1123    }
1124}
1125
1126impl Default for FakeTextModel {
1127    fn default() -> Self {
1128        Self::new(TextModelMode::Reply("fake completion".to_owned()))
1129    }
1130}
1131
1132#[async_trait]
1133impl TextModel for FakeTextModel {
1134    async fn complete(&self, prompt: &Prompt) -> Result<Completion, TextModelError> {
1135        match self.mode_for(prompt.tier) {
1136            TextModelMode::Reply(text) => {
1137                self.record(prompt);
1138                let (input_tokens, output_tokens) = fake_usage(prompt, &text);
1139                Ok(
1140                    Completion::new(text, format!("fake-{}", prompt.tier.name()))
1141                        .usage(input_tokens, output_tokens),
1142                )
1143            }
1144            TextModelMode::Complete(completion) => {
1145                self.record(prompt);
1146                Ok(completion)
1147            }
1148            TextModelMode::NotConfigured => Err(TextModelError::NotConfigured),
1149            TextModelMode::Rejected(message) => Err(TextModelError::Rejected(message)),
1150            TextModelMode::Transient { retry_after } => {
1151                Err(TextModelError::Transient { retry_after })
1152            }
1153            TextModelMode::Error(error) => Err(error),
1154        }
1155    }
1156}
1157
1158// ---------------------------------------------------------------------------
1159// FakeClassifier
1160
1161/// How a [`FakeClassifier`] answers, mirroring [`TextModelMode`] for the
1162/// classifier port (issue #456).
1163///
1164/// The fixed modes [`NotConfigured`](Self::NotConfigured),
1165/// [`Rejected`](Self::Rejected) and [`Transient`](Self::Transient) are the
1166/// three the port's own contract names, and [`Error`](Self::Error) carries
1167/// any `ClassifierError` a test wants — between them every arm of a
1168/// caller's error mapping is reachable, `Transport` included.
1169#[derive(Debug, Clone, PartialEq)]
1170pub enum ClassifierMode {
1171    /// Answer exactly these, keyed by question id. A question the set asks
1172    /// that the map does not name falls back to the deterministic answer,
1173    /// so one test can script a subset and let the rest stay plausible.
1174    Answers(BTreeMap<String, Answer>),
1175    /// Answer every question deterministically from its own labels (the
1176    /// default): the first label, with a plausible normalised distribution
1177    /// over all of them. A module under conformance gets a usable answer
1178    /// without scripting anything.
1179    Deterministic,
1180    /// Report the port is unwired (`ClassifierError::NotConfigured`).
1181    NotConfigured,
1182    /// A non-retryable refusal carrying the provider text.
1183    Rejected(String),
1184    /// A retryable failure with the provider's back-off, where it said one.
1185    Transient { retry_after: Option<Duration> },
1186    /// Exactly this error, text and all.
1187    Error(ClassifierError),
1188}
1189
1190/// One `Classifier::ask` a [`FakeClassifier`] answered, for assertions:
1191/// the state as it arrived (untruncated — this fake has no provider
1192/// budget to spend) and the question ids in the set, in map order.
1193#[derive(Debug, Clone, PartialEq, Eq)]
1194pub struct Ask {
1195    /// The state the questions were asked about.
1196    pub state: String,
1197    /// The ids of the questions in the set, in `BTreeMap` order.
1198    pub question_ids: Vec<String>,
1199}
1200
1201/// An in-memory [`Classifier`] for module tests: records every ask it
1202/// answered and answers according to its [`ClassifierMode`].
1203///
1204/// The mode is global by default and can be overridden **per question**
1205/// ([`FakeClassifier::set_answer_for`]), mirroring [`FakeTextModel`]'s
1206/// per-tier override: one test can script one question's probabilities
1207/// while the rest stay deterministic.
1208///
1209/// An ask that failed (`NotConfigured`, `Rejected`, `Transient` or
1210/// `Error`) is **not** recorded, the same rule [`FakeTextModel`] applies
1211/// to a completion — the recording means "the classifier answered", so a
1212/// caller that retried on `Transient { .. }` sees one recorded ask per
1213/// attempt that got answers.
1214#[derive(Clone)]
1215pub struct FakeClassifier {
1216    inner: Arc<FakeClassifierInner>,
1217}
1218
1219struct FakeClassifierInner {
1220    mode: Mutex<ClassifierMode>,
1221    per_question: Mutex<BTreeMap<String, Answer>>,
1222    asks: Mutex<Vec<Ask>>,
1223    profile: Mutex<ClassifierProfile>,
1224}
1225
1226/// The deterministic answer for `question`: its first label
1227/// ([`Question::labels`] order — first criterion, first level, or `true`
1228/// for a `Noul`) carries most of the mass, spread plausibly over all the
1229/// labels. Pure: the same question always answers the same way, which is
1230/// what an assertion needs.
1231fn deterministic_answer(question: &Question) -> Answer {
1232    let labels = question.labels();
1233    let chosen = labels.first().copied().unwrap_or_default();
1234    // Most of the mass on the chosen label, the rest split evenly: for a
1235    // two-label question 0.6/0.4, for five 0.6 then four shares of 0.1.
1236    let share = if labels.len() > 1 {
1237        0.4 / f32::from(u16::try_from(labels.len() - 1).unwrap_or(1))
1238    } else {
1239        0.0
1240    };
1241    let probabilities: BTreeMap<String, f32> = labels
1242        .iter()
1243        .map(|label| {
1244            let mass = if *label == chosen { 0.6 } else { share };
1245            ((*label).to_owned(), mass)
1246        })
1247        .collect();
1248    match question {
1249        Question::Choice { .. } => Answer::choice(chosen, probabilities),
1250        Question::Noul { .. } => Answer::noul(chosen == "true", probabilities),
1251        // A scale's labels are usually named for their scores ("1".."5"),
1252        // so the first level parses; when it does not, the score is 0.0
1253        // and the confidence still comes from the label itself.
1254        Question::Score { .. } => Answer::new(
1255            AnswerValue::Score(chosen.parse().unwrap_or_default()),
1256            probabilities,
1257            0.6,
1258        ),
1259    }
1260}
1261
1262impl FakeClassifier {
1263    #[must_use]
1264    pub fn new(mode: ClassifierMode) -> Self {
1265        Self {
1266            inner: Arc::new(FakeClassifierInner {
1267                mode: Mutex::new(mode),
1268                per_question: Mutex::new(BTreeMap::new()),
1269                asks: Mutex::new(Vec::new()),
1270                profile: Mutex::new(ClassifierProfile::new(
1271                    Calibration::Classifier,
1272                    cratefield_core::DEFAULT_MAX_STATE_CHARS,
1273                )),
1274            }),
1275        }
1276    }
1277
1278    /// Every ask answered so far, in order. An ask that failed is not
1279    /// recorded.
1280    #[must_use]
1281    pub fn asks(&self) -> Vec<Ask> {
1282        self.inner.asks.lock().expect("classifier lock").clone()
1283    }
1284
1285    /// The most recent ask answered.
1286    #[must_use]
1287    pub fn last(&self) -> Option<Ask> {
1288        self.inner
1289            .asks
1290            .lock()
1291            .expect("classifier lock")
1292            .last()
1293            .cloned()
1294    }
1295
1296    /// Switches the mode every question without an override answers with
1297    /// (e.g. flip to `NotConfigured` mid-test).
1298    pub fn set_mode(&self, mode: ClassifierMode) {
1299        *self.inner.mode.lock().expect("classifier lock") = mode;
1300    }
1301
1302    /// Makes one question answer with `answer`, whatever the mode is —
1303    /// scripting one question's probabilities while the rest stay
1304    /// deterministic, say.
1305    pub fn set_answer_for(&self, question_id: impl Into<String>, answer: Answer) {
1306        self.inner
1307            .per_question
1308            .lock()
1309            .expect("classifier lock")
1310            .insert(question_id.into(), answer);
1311    }
1312
1313    /// Drops one question's override, putting it back on the mode.
1314    pub fn clear_answer_for(&self, question_id: &str) {
1315        self.inner
1316            .per_question
1317            .lock()
1318            .expect("classifier lock")
1319            .remove(question_id);
1320    }
1321
1322    /// Reports `calibration` and `max_state_chars` from
1323    /// [`Classifier::profile`], so a test can exercise a module's
1324    /// calibration- or truncation-aware behaviour against either family.
1325    pub fn set_profile(&self, calibration: Calibration, max_state_chars: usize) {
1326        *self.inner.profile.lock().expect("classifier lock") =
1327            ClassifierProfile::new(calibration, max_state_chars);
1328    }
1329}
1330
1331impl Default for FakeClassifier {
1332    fn default() -> Self {
1333        Self::new(ClassifierMode::Deterministic)
1334    }
1335}
1336
1337#[async_trait]
1338impl Classifier for FakeClassifier {
1339    fn profile(&self) -> ClassifierProfile {
1340        *self.inner.profile.lock().expect("classifier lock")
1341    }
1342
1343    async fn ask(
1344        &self,
1345        state: &str,
1346        questions: &BTreeMap<String, Question>,
1347    ) -> Result<BTreeMap<String, Answer>, ClassifierError> {
1348        // A malformed set lands on `Rejected`, never a panic — the same
1349        // way a real adapter refuses it.
1350        validate_questions(questions)?;
1351
1352        let mode = self.inner.mode.lock().expect("classifier lock").clone();
1353        match mode {
1354            ClassifierMode::NotConfigured => return Err(ClassifierError::NotConfigured),
1355            ClassifierMode::Rejected(message) => return Err(ClassifierError::Rejected(message)),
1356            ClassifierMode::Transient { retry_after } => {
1357                return Err(ClassifierError::Transient { retry_after });
1358            }
1359            ClassifierMode::Error(error) => return Err(error),
1360            ClassifierMode::Answers(_) | ClassifierMode::Deterministic => {}
1361        }
1362
1363        let overrides = self.inner.per_question.lock().expect("classifier lock");
1364        let answers: BTreeMap<String, Answer> = questions
1365            .iter()
1366            .map(|(id, question)| {
1367                let answer = overrides.get(id).cloned().unwrap_or_else(|| match &mode {
1368                    ClassifierMode::Answers(map) => map
1369                        .get(id)
1370                        .cloned()
1371                        .unwrap_or_else(|| deterministic_answer(question)),
1372                    _ => deterministic_answer(question),
1373                });
1374                (id.clone(), answer)
1375            })
1376            .collect();
1377        drop(overrides);
1378
1379        self.inner.asks.lock().expect("classifier lock").push(Ask {
1380            state: state.to_owned(),
1381            question_ids: questions.keys().cloned().collect(),
1382        });
1383        Ok(answers)
1384    }
1385}
1386
1387// ---------------------------------------------------------------------------
1388// FakePayments
1389
1390/// How a [`FakePayments`] responds.
1391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1392pub enum PaymentsMode {
1393    /// Succeed and record the call.
1394    Ok,
1395    /// Report `NotConfigured` (no Stripe key).
1396    NotConfigured,
1397    /// A retryable failure.
1398    Transient,
1399}
1400
1401/// What a [`FakePayments`] recorded, for assertions.
1402#[derive(Debug, Clone, PartialEq, Eq)]
1403pub enum PaymentsCall {
1404    Checkout,
1405    SubscriptionCheckout,
1406    ConnectAccountLink,
1407    ChargeWithTransfer,
1408    Refund,
1409    VerifyWebhook,
1410}
1411
1412/// An in-memory [`cratefield_core::Payments`] for module tests: records which
1413/// calls were made and answers per its [`PaymentsMode`]. `verify_webhook`
1414/// treats a signature header of `"invalid"` as a tampered event.
1415#[derive(Clone)]
1416pub struct FakePayments {
1417    inner: Arc<FakePaymentsInner>,
1418}
1419
1420struct FakePaymentsInner {
1421    mode: Mutex<PaymentsMode>,
1422    calls: Mutex<Vec<PaymentsCall>>,
1423}
1424
1425impl FakePayments {
1426    #[must_use]
1427    pub fn new(mode: PaymentsMode) -> Self {
1428        Self {
1429            inner: Arc::new(FakePaymentsInner {
1430                mode: Mutex::new(mode),
1431                calls: Mutex::new(Vec::new()),
1432            }),
1433        }
1434    }
1435
1436    /// The calls recorded so far.
1437    #[must_use]
1438    pub fn calls(&self) -> Vec<PaymentsCall> {
1439        self.inner.calls.lock().expect("payments lock").clone()
1440    }
1441
1442    pub fn set_mode(&self, mode: PaymentsMode) {
1443        *self.inner.mode.lock().expect("payments lock") = mode;
1444    }
1445
1446    fn record(&self, call: PaymentsCall) {
1447        self.inner.calls.lock().expect("payments lock").push(call);
1448    }
1449
1450    fn guard(&self) -> Result<(), cratefield_core::PaymentsError> {
1451        match *self.inner.mode.lock().expect("payments lock") {
1452            PaymentsMode::Ok => Ok(()),
1453            PaymentsMode::NotConfigured => Err(cratefield_core::PaymentsError::NotConfigured),
1454            PaymentsMode::Transient => Err(cratefield_core::PaymentsError::Transient(
1455                "fake payments failure".to_owned(),
1456            )),
1457        }
1458    }
1459}
1460
1461impl Default for FakePayments {
1462    fn default() -> Self {
1463        Self::new(PaymentsMode::Ok)
1464    }
1465}
1466
1467#[async_trait]
1468impl cratefield_core::Payments for FakePayments {
1469    async fn create_checkout(
1470        &self,
1471        _request: &cratefield_core::CheckoutRequest,
1472    ) -> Result<cratefield_core::CheckoutSession, cratefield_core::PaymentsError> {
1473        self.guard()?;
1474        self.record(PaymentsCall::Checkout);
1475        Ok(cratefield_core::CheckoutSession {
1476            id: "cs_fake".to_owned(),
1477            url: "https://checkout.stripe.test/cs_fake".to_owned(),
1478        })
1479    }
1480
1481    async fn create_subscription_checkout(
1482        &self,
1483        _request: &cratefield_core::SubscriptionCheckoutRequest,
1484    ) -> Result<cratefield_core::CheckoutSession, cratefield_core::PaymentsError> {
1485        self.guard()?;
1486        self.record(PaymentsCall::SubscriptionCheckout);
1487        Ok(cratefield_core::CheckoutSession {
1488            id: "cs_sub_fake".to_owned(),
1489            url: "https://checkout.stripe.test/cs_sub_fake".to_owned(),
1490        })
1491    }
1492
1493    async fn create_connect_account_link(
1494        &self,
1495        _request: &cratefield_core::ConnectAccountLinkRequest,
1496    ) -> Result<cratefield_core::ConnectAccountLink, cratefield_core::PaymentsError> {
1497        self.guard()?;
1498        self.record(PaymentsCall::ConnectAccountLink);
1499        Ok(cratefield_core::ConnectAccountLink {
1500            account_id: "acct_fake".to_owned(),
1501            url: "https://connect.stripe.test/acct_fake".to_owned(),
1502        })
1503    }
1504
1505    async fn charge_with_transfer(
1506        &self,
1507        _request: &cratefield_core::TransferCharge,
1508    ) -> Result<cratefield_core::Charge, cratefield_core::PaymentsError> {
1509        self.guard()?;
1510        self.record(PaymentsCall::ChargeWithTransfer);
1511        Ok(cratefield_core::Charge {
1512            id: "pi_fake".to_owned(),
1513            status: "succeeded".to_owned(),
1514        })
1515    }
1516
1517    async fn refund(
1518        &self,
1519        _request: &cratefield_core::RefundRequest,
1520    ) -> Result<cratefield_core::Refund, cratefield_core::PaymentsError> {
1521        self.guard()?;
1522        self.record(PaymentsCall::Refund);
1523        Ok(cratefield_core::Refund {
1524            id: "re_fake".to_owned(),
1525        })
1526    }
1527
1528    async fn verify_webhook(
1529        &self,
1530        signature_header: &str,
1531        _body: &[u8],
1532    ) -> Result<cratefield_core::WebhookEvent, cratefield_core::PaymentsError> {
1533        self.record(PaymentsCall::VerifyWebhook);
1534        if signature_header == "invalid" {
1535            return Err(cratefield_core::PaymentsError::SignatureInvalid(
1536                "fake tampered signature".to_owned(),
1537            ));
1538        }
1539        self.guard()?;
1540        Ok(cratefield_core::WebhookEvent {
1541            id: "evt_fake".to_owned(),
1542            kind: "checkout.session.completed".to_owned(),
1543            data: serde_json::json!({ "object": "checkout.session" }),
1544        })
1545    }
1546}
1547
1548// ---------------------------------------------------------------------------
1549// FakeTracker
1550
1551/// A short, non-reversible stand-in for a [`Credential`] in a recorded
1552/// call, after `push.rs`'s `fingerprint` for `Recipient`: enough to tell
1553/// two credentials apart in an assertion, useless for recovering the
1554/// token.
1555///
1556/// `push.rs` hashes with `sha2`, which is not a dependency of the kit; a
1557/// test fake needs no cross-version stability and no collision resistance
1558/// beyond "different credentials assert differently", so std's
1559/// [`DefaultHasher`](std::hash::DefaultHasher) does the same job. The
1560/// plaintext is never stored — that is the point of the port: a fake that
1561/// recorded the secret would make every "the token went nowhere" assertion
1562/// unfalsifiable.
1563fn fingerprint(credential: &Credential) -> String {
1564    use std::hash::{Hash, Hasher};
1565
1566    // The one `expose` in the kit: the fingerprint is computed and the
1567    // borrow dropped before anything is recorded.
1568    let mut hasher = std::hash::DefaultHasher::new();
1569    credential.expose().hash(&mut hasher);
1570    format!("fp:{:016x}", hasher.finish())
1571}
1572
1573/// How a [`FakeTracker`] answers, mirroring [`MailerMode`] and [`PushMode`]
1574/// for the tracker port (issue #431).
1575///
1576/// [`Error`](Self::Error) carries the exact `TrackerError` a test wants,
1577/// text and delay and all (issue #236's rule): the fixed modes only ever
1578/// produce clean strings, so the arms of a caller's outcome mapping — a
1579/// `Rejected` carrying the tracker's own words, a `Transient` carrying a
1580/// provider-stated delay — would otherwise be unreachable from a test.
1581#[derive(Debug, Clone, PartialEq, Eq)]
1582pub enum TrackerMode {
1583    /// Accept and record the ticket.
1584    FileOk,
1585    /// Report that no adapter is configured for this destination.
1586    NotConfigured,
1587    /// Report that the per-tenant credential was refused (`401`/`403`).
1588    Unauthorized,
1589    /// Report that the tracker refused the ticket — a `4xx` about the
1590    /// draft, not about the credential.
1591    Rejected,
1592    /// A retryable failure with no delay stated.
1593    Transient,
1594    /// Exactly this error, text and all.
1595    Error(TrackerError),
1596}
1597
1598/// One `Tracker::file` a [`FakeTracker`] accepted, for assertions.
1599///
1600/// The credential appears only as its non-reversible
1601/// `fingerprint`: proof that a credential reached the port, and never
1602/// the secret itself.
1603#[derive(Debug, Clone, PartialEq, Eq)]
1604pub struct FiledCall {
1605    /// Where the ticket was filed.
1606    pub dest: Destination,
1607    /// The ticket as the caller handed it over.
1608    pub draft: TicketDraft,
1609    /// A fingerprint of the credential the caller passed.
1610    pub credential_fingerprint: String,
1611}
1612
1613/// One `Tracker::status` a [`FakeTracker`] accepted, for assertions.
1614#[derive(Debug, Clone, PartialEq, Eq)]
1615pub struct StatusedCall {
1616    /// Where the ticket lives.
1617    pub dest: Destination,
1618    /// The external id the caller asked about.
1619    pub external_id: String,
1620    /// A fingerprint of the credential the caller passed.
1621    pub credential_fingerprint: String,
1622}
1623
1624/// One `Tracker::comment` a [`FakeTracker`] accepted, for assertions.
1625#[derive(Debug, Clone, PartialEq, Eq)]
1626pub struct CommentedCall {
1627    /// Where the ticket lives.
1628    pub dest: Destination,
1629    /// The ticket the note was added to.
1630    pub external_id: String,
1631    /// The note as the caller handed it over.
1632    pub comment: TicketComment,
1633    /// A fingerprint of the credential the caller passed.
1634    pub credential_fingerprint: String,
1635}
1636
1637/// An in-memory [`Tracker`] for module tests: records every accepted
1638/// `file`/`status`/`comment` call and answers according to its
1639/// [`TrackerMode`]. A
1640/// call the mode answered with an error is not recorded, as [`FakePush`]
1641/// does not record a failed send.
1642///
1643/// The mode is global by default and can be overridden **per destination**
1644/// ([`FakeTracker::set_mode_for`]), the [`FakePush`] shape: one tenant's
1645/// expired token among live ones. The state an accepted `status` reports
1646/// is scripted separately ([`FakeTracker::set_state`]), because "the file
1647/// succeeded" and "the ticket has moved on since" are two different
1648/// answers a test wants to set independently.
1649#[derive(Clone, Debug)]
1650pub struct FakeTracker {
1651    inner: Arc<FakeTrackerInner>,
1652}
1653
1654#[derive(Debug)]
1655struct FakeTrackerInner {
1656    mode: Mutex<TrackerMode>,
1657    per_destination: Mutex<HashMap<Destination, TrackerMode>>,
1658    filed: Mutex<Vec<FiledCall>>,
1659    statused: Mutex<Vec<StatusedCall>>,
1660    commented: Mutex<Vec<CommentedCall>>,
1661    state: Mutex<TicketState>,
1662}
1663
1664impl FakeTracker {
1665    #[must_use]
1666    pub fn new(mode: TrackerMode) -> Self {
1667        Self {
1668            inner: Arc::new(FakeTrackerInner {
1669                mode: Mutex::new(mode),
1670                per_destination: Mutex::new(HashMap::new()),
1671                filed: Mutex::new(Vec::new()),
1672                statused: Mutex::new(Vec::new()),
1673                commented: Mutex::new(Vec::new()),
1674                state: Mutex::new(TicketState::Open),
1675            }),
1676        }
1677    }
1678
1679    /// Every `file` accepted so far, in order.
1680    #[must_use]
1681    pub fn filed(&self) -> Vec<FiledCall> {
1682        self.inner.filed.lock().expect("tracker lock").clone()
1683    }
1684
1685    /// The most recent `file` accepted.
1686    #[must_use]
1687    pub fn last_filed(&self) -> Option<FiledCall> {
1688        self.inner
1689            .filed
1690            .lock()
1691            .expect("tracker lock")
1692            .last()
1693            .cloned()
1694    }
1695
1696    /// Every `status` accepted so far, in order.
1697    #[must_use]
1698    pub fn statused(&self) -> Vec<StatusedCall> {
1699        self.inner.statused.lock().expect("tracker lock").clone()
1700    }
1701
1702    /// Every `comment` accepted so far, in order.
1703    #[must_use]
1704    pub fn commented(&self) -> Vec<CommentedCall> {
1705        self.inner.commented.lock().expect("tracker lock").clone()
1706    }
1707
1708    /// Switches the mode every destination without an override answers
1709    /// with (e.g. flip to `Unauthorized` mid-test).
1710    pub fn set_mode(&self, mode: TrackerMode) {
1711        *self.inner.mode.lock().expect("tracker lock") = mode;
1712    }
1713
1714    /// Makes one destination answer with `mode`, whatever the global mode
1715    /// is — one tenant's expired token among live ones, say.
1716    pub fn set_mode_for(&self, dest: &Destination, mode: TrackerMode) {
1717        self.inner
1718            .per_destination
1719            .lock()
1720            .expect("tracker lock")
1721            .insert(dest.clone(), mode);
1722    }
1723
1724    /// Drops one destination's override, putting it back on the global
1725    /// mode.
1726    pub fn clear_mode_for(&self, dest: &Destination) {
1727        self.inner
1728            .per_destination
1729            .lock()
1730            .expect("tracker lock")
1731            .remove(dest);
1732    }
1733
1734    /// The mode `dest` will answer with.
1735    #[must_use]
1736    pub fn mode_for(&self, dest: &Destination) -> TrackerMode {
1737        self.inner
1738            .per_destination
1739            .lock()
1740            .expect("tracker lock")
1741            .get(dest)
1742            .cloned()
1743            .unwrap_or_else(|| self.inner.mode.lock().expect("tracker lock").clone())
1744    }
1745
1746    /// The state an accepted `status` reports.
1747    pub fn set_state(&self, state: TicketState) {
1748        *self.inner.state.lock().expect("tracker lock") = state;
1749    }
1750
1751    /// The state an accepted `status` currently reports.
1752    #[must_use]
1753    pub fn state(&self) -> TicketState {
1754        *self.inner.state.lock().expect("tracker lock")
1755    }
1756
1757    /// The error arms shared by `file`, `status` and `comment`: the same
1758    /// fixed modes script all three, the way a real adapter answers the
1759    /// one `TrackerError` vocabulary on every method.
1760    fn error_for(mode: &TrackerMode) -> Option<TrackerError> {
1761        match mode {
1762            TrackerMode::FileOk => None,
1763            TrackerMode::NotConfigured => Some(TrackerError::NotConfigured),
1764            TrackerMode::Unauthorized => Some(TrackerError::Unauthorized),
1765            TrackerMode::Rejected => {
1766                Some(TrackerError::Rejected("fake tracker rejection".to_owned()))
1767            }
1768            TrackerMode::Transient => Some(TrackerError::Transient { retry_after: None }),
1769            TrackerMode::Error(error) => Some(error.clone()),
1770        }
1771    }
1772}
1773
1774impl Default for FakeTracker {
1775    fn default() -> Self {
1776        Self::new(TrackerMode::FileOk)
1777    }
1778}
1779
1780#[async_trait]
1781impl Tracker for FakeTracker {
1782    async fn file(
1783        &self,
1784        dest: &Destination,
1785        cred: &Credential,
1786        draft: &TicketDraft,
1787    ) -> Result<Filed, TrackerError> {
1788        if let Some(error) = Self::error_for(&self.mode_for(dest)) {
1789            return Err(error);
1790        }
1791        let id = format!(
1792            "fake-{}",
1793            self.inner.filed.lock().expect("tracker lock").len()
1794        );
1795        self.inner
1796            .filed
1797            .lock()
1798            .expect("tracker lock")
1799            .push(FiledCall {
1800                dest: dest.clone(),
1801                draft: draft.clone(),
1802                credential_fingerprint: fingerprint(cred),
1803            });
1804        Ok(Filed {
1805            url: format!("https://tracker.fake.test/browse/{id}"),
1806            external_id: id,
1807        })
1808    }
1809
1810    async fn status(
1811        &self,
1812        dest: &Destination,
1813        cred: &Credential,
1814        external_id: &str,
1815    ) -> Result<TicketStatus, TrackerError> {
1816        if let Some(error) = Self::error_for(&self.mode_for(dest)) {
1817            return Err(error);
1818        }
1819        self.inner
1820            .statused
1821            .lock()
1822            .expect("tracker lock")
1823            .push(StatusedCall {
1824                dest: dest.clone(),
1825                external_id: external_id.to_owned(),
1826                credential_fingerprint: fingerprint(cred),
1827            });
1828        Ok(TicketStatus {
1829            external_id: external_id.to_owned(),
1830            state: self.state(),
1831            url: None,
1832        })
1833    }
1834
1835    async fn comment(
1836        &self,
1837        dest: &Destination,
1838        cred: &Credential,
1839        external_id: &str,
1840        comment: &TicketComment,
1841    ) -> Result<(), TrackerError> {
1842        if let Some(error) = Self::error_for(&self.mode_for(dest)) {
1843            return Err(error);
1844        }
1845        self.inner
1846            .commented
1847            .lock()
1848            .expect("tracker lock")
1849            .push(CommentedCall {
1850                dest: dest.clone(),
1851                external_id: external_id.to_owned(),
1852                comment: comment.clone(),
1853                credential_fingerprint: fingerprint(cred),
1854            });
1855        Ok(())
1856    }
1857}
1858
1859// ---------------------------------------------------------------------------
1860// FakeRealtime
1861
1862/// Room id -> the member ids a [`FakeRealtime`] reports for it.
1863type RealtimeMembers = std::collections::HashMap<String, Vec<String>>;
1864/// The `(room_id, message)` broadcasts a [`FakeRealtime`] recorded.
1865type RealtimeBroadcasts = Vec<(String, Vec<u8>)>;
1866
1867/// An in-memory [`cratefield_core::Realtime`] for module tests: records every
1868/// broadcast per room and reports a fixed member list. It exercises the port a
1869/// module holds (broadcast/members from outside a socket); the socket lifecycle
1870/// and `RoomHandler` are the runtime adapter's job, covered by the native
1871/// adapter's own tests.
1872#[derive(Clone, Default)]
1873pub struct FakeRealtime {
1874    broadcasts: Arc<Mutex<RealtimeBroadcasts>>,
1875    members: Arc<Mutex<RealtimeMembers>>,
1876}
1877
1878impl FakeRealtime {
1879    #[must_use]
1880    pub fn new() -> Self {
1881        Self::default()
1882    }
1883
1884    /// Presets the members a room reports (for `members` assertions).
1885    pub fn set_members(&self, room_id: &str, ids: &[&str]) {
1886        self.members.lock().expect("realtime lock").insert(
1887            room_id.to_owned(),
1888            ids.iter().map(|id| (*id).to_owned()).collect(),
1889        );
1890    }
1891
1892    /// Every `(room_id, message)` broadcast so far.
1893    #[must_use]
1894    pub fn broadcasts(&self) -> Vec<(String, Vec<u8>)> {
1895        self.broadcasts.lock().expect("realtime lock").clone()
1896    }
1897}
1898
1899#[async_trait]
1900impl cratefield_core::Realtime for FakeRealtime {
1901    async fn broadcast(
1902        &self,
1903        room_id: &str,
1904        message: &[u8],
1905    ) -> Result<(), cratefield_core::RealtimeError> {
1906        self.broadcasts
1907            .lock()
1908            .expect("realtime lock")
1909            .push((room_id.to_owned(), message.to_vec()));
1910        Ok(())
1911    }
1912
1913    async fn members(
1914        &self,
1915        room_id: &str,
1916    ) -> Result<Vec<cratefield_core::Member>, cratefield_core::RealtimeError> {
1917        Ok(self
1918            .members
1919            .lock()
1920            .expect("realtime lock")
1921            .get(room_id)
1922            .map(|ids| ids.iter().map(cratefield_core::Member::new).collect())
1923            .unwrap_or_default())
1924    }
1925}
1926
1927/// What a [`FakeAuth`] answers.
1928///
1929/// Every outcome the port has, because a fake that cannot produce one
1930/// makes the arm handling it unreachable from every test — which is how
1931/// a five-arm mapping shipped with four of the arms never executed.
1932#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1933pub enum AuthMode {
1934    /// Every request is anonymous, header or not. The deployment where
1935    /// nobody signs in.
1936    Anonymous,
1937    /// A bearer token is taken at face value as the subject's id, and a
1938    /// request with no `Authorization` header is anonymous. The mode for
1939    /// a test that wants both branches without minting a JWT.
1940    TokenIsTheSubject,
1941    /// Every presented credential is refused, and a request with no
1942    /// header is still anonymous — the distinction the port exists to
1943    /// keep.
1944    NotVerified,
1945    /// The verifier cannot answer, credential or not.
1946    Unavailable,
1947}
1948
1949/// A [`cratefield_core::Auth`] answering from a mode rather than from
1950/// a key set.
1951pub struct FakeAuth {
1952    mode: AuthMode,
1953    calls: std::sync::Mutex<Vec<String>>,
1954}
1955
1956impl FakeAuth {
1957    /// A fake in `mode`.
1958    #[must_use]
1959    pub fn new(mode: AuthMode) -> Self {
1960        Self {
1961            mode,
1962            calls: std::sync::Mutex::new(Vec::new()),
1963        }
1964    }
1965
1966    /// A fake that reads the bearer token as the subject's id.
1967    #[must_use]
1968    pub fn subjects() -> Self {
1969        Self::new(AuthMode::TokenIsTheSubject)
1970    }
1971
1972    /// The bearer values it was asked about, in order. `""` records a
1973    /// request that carried no `Authorization` header at all.
1974    #[must_use]
1975    pub fn calls(&self) -> Vec<String> {
1976        self.calls.lock().expect("uncontended").clone()
1977    }
1978}
1979
1980#[async_trait::async_trait]
1981impl cratefield_core::Auth for FakeAuth {
1982    async fn identify(
1983        &self,
1984        headers: &http::HeaderMap,
1985    ) -> Result<cratefield_core::Caller, cratefield_core::AuthError> {
1986        let presented = headers
1987            .get(http::header::AUTHORIZATION)
1988            .and_then(|value| value.to_str().ok())
1989            .and_then(|value| value.strip_prefix("Bearer "))
1990            .map(str::to_owned);
1991        self.calls
1992            .lock()
1993            .expect("uncontended")
1994            .push(presented.clone().unwrap_or_default());
1995        match self.mode {
1996            AuthMode::Anonymous => Ok(cratefield_core::Caller::Anonymous),
1997            AuthMode::Unavailable => Err(cratefield_core::AuthError::Unavailable(
1998                "the fake is in Unavailable mode".to_owned(),
1999            )),
2000            AuthMode::NotVerified | AuthMode::TokenIsTheSubject => match presented {
2001                None => Ok(cratefield_core::Caller::Anonymous),
2002                Some(_) if self.mode == AuthMode::NotVerified => {
2003                    Err(cratefield_core::AuthError::NotVerified)
2004                }
2005                Some(token) => Ok(cratefield_core::Caller::Subject(
2006                    cratefield_core::Subject::new(token).session("fake-session"),
2007                )),
2008            },
2009        }
2010    }
2011}
2012
2013// ---------------------------------------------------------------------------
2014// FakeCustomHostnames
2015
2016/// An in-memory [`cratefield_core::CustomHostnames`] for module tests:
2017/// records every call, walks a claim from pending to live on demand, and
2018/// scripts the error the next call answers with.
2019///
2020/// `create` runs [`cratefield_core::check_hostname`] first, exactly as a
2021/// real adapter must, so a test can drive the `Refused` arm — the refusal
2022/// never reaches a provider.
2023#[derive(Clone)]
2024pub struct FakeCustomHostnames {
2025    inner: Arc<FakeCustomHostnamesInner>,
2026}
2027
2028struct FakeCustomHostnamesInner {
2029    own_zone: String,
2030    hostnames: Mutex<BTreeMap<String, CustomHostname>>,
2031    calls: Mutex<Vec<String>>,
2032    /// The scripted answer for the next call, if any.
2033    refusals: Mutex<VecDeque<CustomHostnameError>>,
2034    next_id: AtomicUsize,
2035}
2036
2037impl FakeCustomHostnames {
2038    /// A fake serving hostnames outside `own_zone` — the zone whose names
2039    /// are refused as the deployment's own.
2040    #[must_use]
2041    pub fn new(own_zone: impl Into<String>) -> Self {
2042        Self {
2043            inner: Arc::new(FakeCustomHostnamesInner {
2044                own_zone: own_zone.into(),
2045                hostnames: Mutex::new(BTreeMap::new()),
2046                calls: Mutex::new(Vec::new()),
2047                refusals: Mutex::new(VecDeque::new()),
2048                next_id: AtomicUsize::new(0),
2049            }),
2050        }
2051    }
2052
2053    /// Every call so far, in order, as `"create share.acme.com"`.
2054    #[must_use]
2055    pub fn calls(&self) -> Vec<String> {
2056        self.inner
2057            .calls
2058            .lock()
2059            .expect("custom hostnames lock")
2060            .clone()
2061    }
2062
2063    /// Walks a claim to `Active`/`Active` with no records left to publish.
2064    ///
2065    /// A hostname that was never claimed is a no-op rather than a panic:
2066    /// a test's setup failing should fail on the assertion, not here.
2067    pub fn activate(&self, hostname: &str) {
2068        let key = self.key(hostname);
2069        if let Some(claim) = self
2070            .inner
2071            .hostnames
2072            .lock()
2073            .expect("custom hostnames lock")
2074            .get_mut(&key)
2075        {
2076            claim.status = ProviderStatus::Active;
2077            claim.certificate = CertificateStatus::Active;
2078            claim.validation.clear();
2079        }
2080    }
2081
2082    /// Records the claim as failed with the provider's own `reason`.
2083    pub fn fail(&self, hostname: &str, reason: &str) {
2084        let key = self.key(hostname);
2085        if let Some(claim) = self
2086            .inner
2087            .hostnames
2088            .lock()
2089            .expect("custom hostnames lock")
2090            .get_mut(&key)
2091        {
2092            claim.status = ProviderStatus::Failed {
2093                reason: reason.to_owned(),
2094            };
2095        }
2096    }
2097
2098    /// Makes the next call answer `error` instead of its normal answer.
2099    /// Queue several to script a sequence.
2100    pub fn refuse_next(&self, error: CustomHostnameError) {
2101        self.inner
2102            .refusals
2103            .lock()
2104            .expect("custom hostnames lock")
2105            .push_back(error);
2106    }
2107
2108    /// The key a hostname is stored under: the same normalisation the
2109    /// adapter's `check_hostname` call produces.
2110    fn key(&self, hostname: &str) -> String {
2111        check_hostname(hostname, &self.inner.own_zone)
2112            .unwrap_or_else(|_| hostname.trim().trim_end_matches('.').to_ascii_lowercase())
2113    }
2114
2115    fn log(&self, call: String) {
2116        self.inner
2117            .calls
2118            .lock()
2119            .expect("custom hostnames lock")
2120            .push(call);
2121    }
2122
2123    fn take_refusal(&self) -> Option<CustomHostnameError> {
2124        self.inner
2125            .refusals
2126            .lock()
2127            .expect("custom hostnames lock")
2128            .pop_front()
2129    }
2130}
2131
2132#[async_trait]
2133impl CustomHostnames for FakeCustomHostnames {
2134    async fn create(&self, claim: &HostnameClaim) -> Result<CustomHostname, CustomHostnameError> {
2135        self.log(format!("create {}", claim.hostname));
2136        if let Some(error) = self.take_refusal() {
2137            return Err(error);
2138        }
2139        let hostname = check_hostname(&claim.hostname, &self.inner.own_zone)
2140            .map_err(CustomHostnameError::Refused)?;
2141        let mut hostnames = self.inner.hostnames.lock().expect("custom hostnames lock");
2142        if hostnames.contains_key(&hostname) {
2143            return Err(CustomHostnameError::AlreadyExists);
2144        }
2145        let id = format!(
2146            "fake-{}",
2147            self.inner.next_id.fetch_add(1, Ordering::Relaxed)
2148        );
2149        let created = CustomHostname {
2150            id: id.clone(),
2151            hostname: hostname.clone(),
2152            status: ProviderStatus::Pending,
2153            certificate: CertificateStatus::Pending,
2154            validation: vec![Validation {
2155                record_type: DnsRecordType::Txt,
2156                name: format!("_cf-custom-hostname.{hostname}"),
2157                value: id,
2158            }],
2159        };
2160        hostnames.insert(hostname, created.clone());
2161        Ok(created)
2162    }
2163
2164    async fn get(&self, hostname: &str) -> Result<Option<CustomHostname>, CustomHostnameError> {
2165        self.log(format!("get {hostname}"));
2166        if let Some(error) = self.take_refusal() {
2167            return Err(error);
2168        }
2169        let key = self.key(hostname);
2170        Ok(self
2171            .inner
2172            .hostnames
2173            .lock()
2174            .expect("custom hostnames lock")
2175            .get(&key)
2176            .cloned())
2177    }
2178
2179    async fn delete(&self, hostname: &str) -> Result<(), CustomHostnameError> {
2180        self.log(format!("delete {hostname}"));
2181        if let Some(error) = self.take_refusal() {
2182            return Err(error);
2183        }
2184        let key = self.key(hostname);
2185        self.inner
2186            .hostnames
2187            .lock()
2188            .expect("custom hostnames lock")
2189            .remove(&key);
2190        Ok(())
2191    }
2192
2193    async fn refresh(&self, hostname: &str) -> Result<CustomHostname, CustomHostnameError> {
2194        self.log(format!("refresh {hostname}"));
2195        if let Some(error) = self.take_refusal() {
2196            return Err(error);
2197        }
2198        let key = self.key(hostname);
2199        self.inner
2200            .hostnames
2201            .lock()
2202            .expect("custom hostnames lock")
2203            .get(&key)
2204            .cloned()
2205            .ok_or(CustomHostnameError::NotFound)
2206    }
2207}
2208
2209#[cfg(test)]
2210mod tests {
2211    use super::*;
2212    use cratefield_core::Blob as _;
2213
2214    fn json_body(response: &Response<Bytes>) -> serde_json::Value {
2215        serde_json::from_slice(response.body()).expect("the body is json")
2216    }
2217
2218    #[test]
2219    fn problem_json_carries_the_problem_fields_and_media_type() {
2220        let response = problem_json(
2221            StatusCode::TOO_MANY_REQUESTS,
2222            "Too Many Requests",
2223            "slow down",
2224        );
2225        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
2226        assert_eq!(response.headers()[CONTENT_TYPE], "application/problem+json");
2227        let body = json_body(&response);
2228        assert_eq!(body["type"], "about:blank");
2229        assert_eq!(body["title"], "Too Many Requests");
2230        assert_eq!(body["status"], 429);
2231        assert_eq!(body["detail"], "slow down");
2232    }
2233
2234    #[test]
2235    fn error_json_is_the_colonizer_shape() {
2236        let response = error_json(StatusCode::BAD_REQUEST, "bad request");
2237        assert_eq!(response.headers()[CONTENT_TYPE], "application/json");
2238        assert_eq!(json_body(&response)["error"], "bad request");
2239    }
2240
2241    #[test]
2242    fn with_retry_after_sets_the_header_in_seconds() {
2243        let response = with_retry_after(error_json(StatusCode::TOO_MANY_REQUESTS, "slow"), 42);
2244        assert_eq!(response.headers()[RETRY_AFTER], "42");
2245    }
2246
2247    #[test]
2248    fn a_preset_answers_through_the_port() {
2249        // Drive a preset end to end: it must be wired into `scripted`, not
2250        // merely build a body.
2251        let http = FakeHttpClient::owlpost_rate_limited(7);
2252        let request = Request::builder()
2253            .uri("https://api.owlpost.fake/v1/emails")
2254            .body(Bytes::new())
2255            .expect("the request builds");
2256        let response = pollster::block_on(http.send(request)).expect("the preset answers");
2257        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
2258        assert_eq!(response.headers()[RETRY_AFTER], "7");
2259        assert_eq!(json_body(&response)["detail"], "slow down");
2260    }
2261
2262    #[pollster::test]
2263    async fn memory_blob_presigns_and_verifies_back() {
2264        // A clock dated in the past: a URL it stamps is expired if verification
2265        // reads the wall clock instead of the store's.
2266        let at = time::OffsetDateTime::from_unix_timestamp(1_577_836_800).expect("a valid epoch");
2267        let blob = MemoryBlob::with_presign(Arc::new(FixedClock(at)));
2268        let ttl = Duration::from_secs(3600);
2269
2270        let get = blob.signed_url("cms/clip.mp3", ttl).await.unwrap();
2271        assert!(get.starts_with("https://blob.test/cms/clip.mp3?"), "{get}");
2272        assert_eq!(
2273            blob.verify_presigned(&get, "GET", &[]).unwrap(),
2274            "cms/clip.mp3"
2275        );
2276
2277        let put = blob
2278            .signed_put_url("cms/clip.mp3", "audio/mpeg", Some(3), ttl)
2279            .await
2280            .unwrap();
2281        assert_eq!((put.method, put.headers.len()), ("PUT", 2));
2282        assert!(blob.verify_presigned(&put.url, "PUT", &put.headers).is_ok());
2283        // A header the URL was not signed for is refused.
2284        assert!(blob.verify_presigned(&put.url, "PUT", &[]).is_err());
2285
2286        // Without presigning, the Unsupported default stands.
2287        assert!(matches!(
2288            MemoryBlob::new().signed_url("k", ttl).await.unwrap_err(),
2289            cratefield_core::BlobError::Unsupported(_)
2290        ));
2291    }
2292}