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, Capability, Captcha, CaptchaError, CertificateStatus,
19    Classifier, 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        // The fake answers plain completions; a prompt carrying tools would
1136        // otherwise be answered as if none were offered. Refuse it the way
1137        // a real adapter without the capability does, so a caller is told
1138        // rather than silently misled.
1139        if !prompt.tools.is_empty() {
1140            return Err(TextModelError::Unsupported(Capability::Tools));
1141        }
1142        match self.mode_for(prompt.tier) {
1143            TextModelMode::Reply(text) => {
1144                self.record(prompt);
1145                let (input_tokens, output_tokens) = fake_usage(prompt, &text);
1146                Ok(
1147                    Completion::new(text, format!("fake-{}", prompt.tier.name()))
1148                        .usage(input_tokens, output_tokens),
1149                )
1150            }
1151            TextModelMode::Complete(completion) => {
1152                self.record(prompt);
1153                Ok(completion)
1154            }
1155            TextModelMode::NotConfigured => Err(TextModelError::NotConfigured),
1156            TextModelMode::Rejected(message) => Err(TextModelError::Rejected(message)),
1157            TextModelMode::Transient { retry_after } => {
1158                Err(TextModelError::Transient { retry_after })
1159            }
1160            TextModelMode::Error(error) => Err(error),
1161        }
1162    }
1163}
1164
1165// ---------------------------------------------------------------------------
1166// FakeClassifier
1167
1168/// How a [`FakeClassifier`] answers, mirroring [`TextModelMode`] for the
1169/// classifier port (issue #456).
1170///
1171/// The fixed modes [`NotConfigured`](Self::NotConfigured),
1172/// [`Rejected`](Self::Rejected) and [`Transient`](Self::Transient) are the
1173/// three the port's own contract names, and [`Error`](Self::Error) carries
1174/// any `ClassifierError` a test wants — between them every arm of a
1175/// caller's error mapping is reachable, `Transport` included.
1176#[derive(Debug, Clone, PartialEq)]
1177pub enum ClassifierMode {
1178    /// Answer exactly these, keyed by question id. A question the set asks
1179    /// that the map does not name falls back to the deterministic answer,
1180    /// so one test can script a subset and let the rest stay plausible.
1181    Answers(BTreeMap<String, Answer>),
1182    /// Answer every question deterministically from its own labels (the
1183    /// default): the first label, with a plausible normalised distribution
1184    /// over all of them. A module under conformance gets a usable answer
1185    /// without scripting anything.
1186    Deterministic,
1187    /// Report the port is unwired (`ClassifierError::NotConfigured`).
1188    NotConfigured,
1189    /// A non-retryable refusal carrying the provider text.
1190    Rejected(String),
1191    /// A retryable failure with the provider's back-off, where it said one.
1192    Transient { retry_after: Option<Duration> },
1193    /// Exactly this error, text and all.
1194    Error(ClassifierError),
1195}
1196
1197/// One `Classifier::ask` a [`FakeClassifier`] answered, for assertions:
1198/// the state as it arrived (untruncated — this fake has no provider
1199/// budget to spend) and the question ids in the set, in map order.
1200#[derive(Debug, Clone, PartialEq, Eq)]
1201pub struct Ask {
1202    /// The state the questions were asked about.
1203    pub state: String,
1204    /// The ids of the questions in the set, in `BTreeMap` order.
1205    pub question_ids: Vec<String>,
1206}
1207
1208/// An in-memory [`Classifier`] for module tests: records every ask it
1209/// answered and answers according to its [`ClassifierMode`].
1210///
1211/// The mode is global by default and can be overridden **per question**
1212/// ([`FakeClassifier::set_answer_for`]), mirroring [`FakeTextModel`]'s
1213/// per-tier override: one test can script one question's probabilities
1214/// while the rest stay deterministic.
1215///
1216/// An ask that failed (`NotConfigured`, `Rejected`, `Transient` or
1217/// `Error`) is **not** recorded, the same rule [`FakeTextModel`] applies
1218/// to a completion — the recording means "the classifier answered", so a
1219/// caller that retried on `Transient { .. }` sees one recorded ask per
1220/// attempt that got answers.
1221#[derive(Clone)]
1222pub struct FakeClassifier {
1223    inner: Arc<FakeClassifierInner>,
1224}
1225
1226struct FakeClassifierInner {
1227    mode: Mutex<ClassifierMode>,
1228    per_question: Mutex<BTreeMap<String, Answer>>,
1229    asks: Mutex<Vec<Ask>>,
1230    profile: Mutex<ClassifierProfile>,
1231}
1232
1233/// The deterministic answer for `question`: its first label
1234/// ([`Question::labels`] order — first criterion, first level, or `true`
1235/// for a `Noul`) carries most of the mass, spread plausibly over all the
1236/// labels. Pure: the same question always answers the same way, which is
1237/// what an assertion needs.
1238fn deterministic_answer(question: &Question) -> Answer {
1239    let labels = question.labels();
1240    let chosen = labels.first().copied().unwrap_or_default();
1241    // Most of the mass on the chosen label, the rest split evenly: for a
1242    // two-label question 0.6/0.4, for five 0.6 then four shares of 0.1.
1243    let share = if labels.len() > 1 {
1244        0.4 / f32::from(u16::try_from(labels.len() - 1).unwrap_or(1))
1245    } else {
1246        0.0
1247    };
1248    let probabilities: BTreeMap<String, f32> = labels
1249        .iter()
1250        .map(|label| {
1251            let mass = if *label == chosen { 0.6 } else { share };
1252            ((*label).to_owned(), mass)
1253        })
1254        .collect();
1255    match question {
1256        Question::Choice { .. } => Answer::choice(chosen, probabilities),
1257        Question::Noul { .. } => Answer::noul(chosen == "true", probabilities),
1258        // A scale's labels are usually named for their scores ("1".."5"),
1259        // so the first level parses; when it does not, the score is 0.0
1260        // and the confidence still comes from the label itself.
1261        Question::Score { .. } => Answer::new(
1262            AnswerValue::Score(chosen.parse().unwrap_or_default()),
1263            probabilities,
1264            0.6,
1265        ),
1266    }
1267}
1268
1269impl FakeClassifier {
1270    #[must_use]
1271    pub fn new(mode: ClassifierMode) -> Self {
1272        Self {
1273            inner: Arc::new(FakeClassifierInner {
1274                mode: Mutex::new(mode),
1275                per_question: Mutex::new(BTreeMap::new()),
1276                asks: Mutex::new(Vec::new()),
1277                profile: Mutex::new(ClassifierProfile::new(
1278                    Calibration::Classifier,
1279                    cratefield_core::DEFAULT_MAX_STATE_CHARS,
1280                )),
1281            }),
1282        }
1283    }
1284
1285    /// Every ask answered so far, in order. An ask that failed is not
1286    /// recorded.
1287    #[must_use]
1288    pub fn asks(&self) -> Vec<Ask> {
1289        self.inner.asks.lock().expect("classifier lock").clone()
1290    }
1291
1292    /// The most recent ask answered.
1293    #[must_use]
1294    pub fn last(&self) -> Option<Ask> {
1295        self.inner
1296            .asks
1297            .lock()
1298            .expect("classifier lock")
1299            .last()
1300            .cloned()
1301    }
1302
1303    /// Switches the mode every question without an override answers with
1304    /// (e.g. flip to `NotConfigured` mid-test).
1305    pub fn set_mode(&self, mode: ClassifierMode) {
1306        *self.inner.mode.lock().expect("classifier lock") = mode;
1307    }
1308
1309    /// Makes one question answer with `answer`, whatever the mode is —
1310    /// scripting one question's probabilities while the rest stay
1311    /// deterministic, say.
1312    pub fn set_answer_for(&self, question_id: impl Into<String>, answer: Answer) {
1313        self.inner
1314            .per_question
1315            .lock()
1316            .expect("classifier lock")
1317            .insert(question_id.into(), answer);
1318    }
1319
1320    /// Drops one question's override, putting it back on the mode.
1321    pub fn clear_answer_for(&self, question_id: &str) {
1322        self.inner
1323            .per_question
1324            .lock()
1325            .expect("classifier lock")
1326            .remove(question_id);
1327    }
1328
1329    /// Reports `calibration` and `max_state_chars` from
1330    /// [`Classifier::profile`], so a test can exercise a module's
1331    /// calibration- or truncation-aware behaviour against either family.
1332    pub fn set_profile(&self, calibration: Calibration, max_state_chars: usize) {
1333        *self.inner.profile.lock().expect("classifier lock") =
1334            ClassifierProfile::new(calibration, max_state_chars);
1335    }
1336}
1337
1338impl Default for FakeClassifier {
1339    fn default() -> Self {
1340        Self::new(ClassifierMode::Deterministic)
1341    }
1342}
1343
1344#[async_trait]
1345impl Classifier for FakeClassifier {
1346    fn profile(&self) -> ClassifierProfile {
1347        *self.inner.profile.lock().expect("classifier lock")
1348    }
1349
1350    async fn ask(
1351        &self,
1352        state: &str,
1353        questions: &BTreeMap<String, Question>,
1354    ) -> Result<BTreeMap<String, Answer>, ClassifierError> {
1355        // A malformed set lands on `Rejected`, never a panic — the same
1356        // way a real adapter refuses it.
1357        validate_questions(questions)?;
1358
1359        let mode = self.inner.mode.lock().expect("classifier lock").clone();
1360        match mode {
1361            ClassifierMode::NotConfigured => return Err(ClassifierError::NotConfigured),
1362            ClassifierMode::Rejected(message) => return Err(ClassifierError::Rejected(message)),
1363            ClassifierMode::Transient { retry_after } => {
1364                return Err(ClassifierError::Transient { retry_after });
1365            }
1366            ClassifierMode::Error(error) => return Err(error),
1367            ClassifierMode::Answers(_) | ClassifierMode::Deterministic => {}
1368        }
1369
1370        let overrides = self.inner.per_question.lock().expect("classifier lock");
1371        let answers: BTreeMap<String, Answer> = questions
1372            .iter()
1373            .map(|(id, question)| {
1374                let answer = overrides.get(id).cloned().unwrap_or_else(|| match &mode {
1375                    ClassifierMode::Answers(map) => map
1376                        .get(id)
1377                        .cloned()
1378                        .unwrap_or_else(|| deterministic_answer(question)),
1379                    _ => deterministic_answer(question),
1380                });
1381                (id.clone(), answer)
1382            })
1383            .collect();
1384        drop(overrides);
1385
1386        self.inner.asks.lock().expect("classifier lock").push(Ask {
1387            state: state.to_owned(),
1388            question_ids: questions.keys().cloned().collect(),
1389        });
1390        Ok(answers)
1391    }
1392}
1393
1394// ---------------------------------------------------------------------------
1395// FakePayments
1396
1397/// How a [`FakePayments`] responds.
1398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1399pub enum PaymentsMode {
1400    /// Succeed and record the call.
1401    Ok,
1402    /// Report `NotConfigured` (no Stripe key).
1403    NotConfigured,
1404    /// A retryable failure.
1405    Transient,
1406}
1407
1408/// What a [`FakePayments`] recorded, for assertions.
1409#[derive(Debug, Clone, PartialEq, Eq)]
1410pub enum PaymentsCall {
1411    Checkout,
1412    SubscriptionCheckout,
1413    ConnectAccountLink,
1414    ChargeWithTransfer,
1415    Refund,
1416    VerifyWebhook,
1417}
1418
1419/// An in-memory [`cratefield_core::Payments`] for module tests: records which
1420/// calls were made and answers per its [`PaymentsMode`]. `verify_webhook`
1421/// treats a signature header of `"invalid"` as a tampered event.
1422#[derive(Clone)]
1423pub struct FakePayments {
1424    inner: Arc<FakePaymentsInner>,
1425}
1426
1427struct FakePaymentsInner {
1428    mode: Mutex<PaymentsMode>,
1429    calls: Mutex<Vec<PaymentsCall>>,
1430}
1431
1432impl FakePayments {
1433    #[must_use]
1434    pub fn new(mode: PaymentsMode) -> Self {
1435        Self {
1436            inner: Arc::new(FakePaymentsInner {
1437                mode: Mutex::new(mode),
1438                calls: Mutex::new(Vec::new()),
1439            }),
1440        }
1441    }
1442
1443    /// The calls recorded so far.
1444    #[must_use]
1445    pub fn calls(&self) -> Vec<PaymentsCall> {
1446        self.inner.calls.lock().expect("payments lock").clone()
1447    }
1448
1449    pub fn set_mode(&self, mode: PaymentsMode) {
1450        *self.inner.mode.lock().expect("payments lock") = mode;
1451    }
1452
1453    fn record(&self, call: PaymentsCall) {
1454        self.inner.calls.lock().expect("payments lock").push(call);
1455    }
1456
1457    fn guard(&self) -> Result<(), cratefield_core::PaymentsError> {
1458        match *self.inner.mode.lock().expect("payments lock") {
1459            PaymentsMode::Ok => Ok(()),
1460            PaymentsMode::NotConfigured => Err(cratefield_core::PaymentsError::NotConfigured),
1461            PaymentsMode::Transient => Err(cratefield_core::PaymentsError::Transient(
1462                "fake payments failure".to_owned(),
1463            )),
1464        }
1465    }
1466}
1467
1468impl Default for FakePayments {
1469    fn default() -> Self {
1470        Self::new(PaymentsMode::Ok)
1471    }
1472}
1473
1474#[async_trait]
1475impl cratefield_core::Payments for FakePayments {
1476    async fn create_checkout(
1477        &self,
1478        _request: &cratefield_core::CheckoutRequest,
1479    ) -> Result<cratefield_core::CheckoutSession, cratefield_core::PaymentsError> {
1480        self.guard()?;
1481        self.record(PaymentsCall::Checkout);
1482        Ok(cratefield_core::CheckoutSession {
1483            id: "cs_fake".to_owned(),
1484            url: "https://checkout.stripe.test/cs_fake".to_owned(),
1485        })
1486    }
1487
1488    async fn create_subscription_checkout(
1489        &self,
1490        _request: &cratefield_core::SubscriptionCheckoutRequest,
1491    ) -> Result<cratefield_core::CheckoutSession, cratefield_core::PaymentsError> {
1492        self.guard()?;
1493        self.record(PaymentsCall::SubscriptionCheckout);
1494        Ok(cratefield_core::CheckoutSession {
1495            id: "cs_sub_fake".to_owned(),
1496            url: "https://checkout.stripe.test/cs_sub_fake".to_owned(),
1497        })
1498    }
1499
1500    async fn create_connect_account_link(
1501        &self,
1502        _request: &cratefield_core::ConnectAccountLinkRequest,
1503    ) -> Result<cratefield_core::ConnectAccountLink, cratefield_core::PaymentsError> {
1504        self.guard()?;
1505        self.record(PaymentsCall::ConnectAccountLink);
1506        Ok(cratefield_core::ConnectAccountLink {
1507            account_id: "acct_fake".to_owned(),
1508            url: "https://connect.stripe.test/acct_fake".to_owned(),
1509        })
1510    }
1511
1512    async fn charge_with_transfer(
1513        &self,
1514        _request: &cratefield_core::TransferCharge,
1515    ) -> Result<cratefield_core::Charge, cratefield_core::PaymentsError> {
1516        self.guard()?;
1517        self.record(PaymentsCall::ChargeWithTransfer);
1518        Ok(cratefield_core::Charge {
1519            id: "pi_fake".to_owned(),
1520            status: "succeeded".to_owned(),
1521        })
1522    }
1523
1524    async fn refund(
1525        &self,
1526        _request: &cratefield_core::RefundRequest,
1527    ) -> Result<cratefield_core::Refund, cratefield_core::PaymentsError> {
1528        self.guard()?;
1529        self.record(PaymentsCall::Refund);
1530        Ok(cratefield_core::Refund {
1531            id: "re_fake".to_owned(),
1532        })
1533    }
1534
1535    async fn verify_webhook(
1536        &self,
1537        signature_header: &str,
1538        _body: &[u8],
1539    ) -> Result<cratefield_core::WebhookEvent, cratefield_core::PaymentsError> {
1540        self.record(PaymentsCall::VerifyWebhook);
1541        if signature_header == "invalid" {
1542            return Err(cratefield_core::PaymentsError::SignatureInvalid(
1543                "fake tampered signature".to_owned(),
1544            ));
1545        }
1546        self.guard()?;
1547        Ok(cratefield_core::WebhookEvent {
1548            id: "evt_fake".to_owned(),
1549            kind: "checkout.session.completed".to_owned(),
1550            data: serde_json::json!({ "object": "checkout.session" }),
1551        })
1552    }
1553}
1554
1555// ---------------------------------------------------------------------------
1556// FakeTracker
1557
1558/// A short, non-reversible stand-in for a [`Credential`] in a recorded
1559/// call, after `push.rs`'s `fingerprint` for `Recipient`: enough to tell
1560/// two credentials apart in an assertion, useless for recovering the
1561/// token.
1562///
1563/// `push.rs` hashes with `sha2`, which is not a dependency of the kit; a
1564/// test fake needs no cross-version stability and no collision resistance
1565/// beyond "different credentials assert differently", so std's
1566/// [`DefaultHasher`](std::hash::DefaultHasher) does the same job. The
1567/// plaintext is never stored — that is the point of the port: a fake that
1568/// recorded the secret would make every "the token went nowhere" assertion
1569/// unfalsifiable.
1570fn fingerprint(credential: &Credential) -> String {
1571    use std::hash::{Hash, Hasher};
1572
1573    // The one `expose` in the kit: the fingerprint is computed and the
1574    // borrow dropped before anything is recorded.
1575    let mut hasher = std::hash::DefaultHasher::new();
1576    credential.expose().hash(&mut hasher);
1577    format!("fp:{:016x}", hasher.finish())
1578}
1579
1580/// How a [`FakeTracker`] answers, mirroring [`MailerMode`] and [`PushMode`]
1581/// for the tracker port (issue #431).
1582///
1583/// [`Error`](Self::Error) carries the exact `TrackerError` a test wants,
1584/// text and delay and all (issue #236's rule): the fixed modes only ever
1585/// produce clean strings, so the arms of a caller's outcome mapping — a
1586/// `Rejected` carrying the tracker's own words, a `Transient` carrying a
1587/// provider-stated delay — would otherwise be unreachable from a test.
1588#[derive(Debug, Clone, PartialEq, Eq)]
1589pub enum TrackerMode {
1590    /// Accept and record the ticket.
1591    FileOk,
1592    /// Report that no adapter is configured for this destination.
1593    NotConfigured,
1594    /// Report that the per-tenant credential was refused (`401`/`403`).
1595    Unauthorized,
1596    /// Report that the tracker refused the ticket — a `4xx` about the
1597    /// draft, not about the credential.
1598    Rejected,
1599    /// A retryable failure with no delay stated.
1600    Transient,
1601    /// Exactly this error, text and all.
1602    Error(TrackerError),
1603}
1604
1605/// One `Tracker::file` a [`FakeTracker`] accepted, for assertions.
1606///
1607/// The credential appears only as its non-reversible
1608/// `fingerprint`: proof that a credential reached the port, and never
1609/// the secret itself.
1610#[derive(Debug, Clone, PartialEq, Eq)]
1611pub struct FiledCall {
1612    /// Where the ticket was filed.
1613    pub dest: Destination,
1614    /// The ticket as the caller handed it over.
1615    pub draft: TicketDraft,
1616    /// A fingerprint of the credential the caller passed.
1617    pub credential_fingerprint: String,
1618}
1619
1620/// One `Tracker::status` a [`FakeTracker`] accepted, for assertions.
1621#[derive(Debug, Clone, PartialEq, Eq)]
1622pub struct StatusedCall {
1623    /// Where the ticket lives.
1624    pub dest: Destination,
1625    /// The external id the caller asked about.
1626    pub external_id: String,
1627    /// A fingerprint of the credential the caller passed.
1628    pub credential_fingerprint: String,
1629}
1630
1631/// One `Tracker::comment` a [`FakeTracker`] accepted, for assertions.
1632#[derive(Debug, Clone, PartialEq, Eq)]
1633pub struct CommentedCall {
1634    /// Where the ticket lives.
1635    pub dest: Destination,
1636    /// The ticket the note was added to.
1637    pub external_id: String,
1638    /// The note as the caller handed it over.
1639    pub comment: TicketComment,
1640    /// A fingerprint of the credential the caller passed.
1641    pub credential_fingerprint: String,
1642}
1643
1644/// An in-memory [`Tracker`] for module tests: records every accepted
1645/// `file`/`status`/`comment` call and answers according to its
1646/// [`TrackerMode`]. A
1647/// call the mode answered with an error is not recorded, as [`FakePush`]
1648/// does not record a failed send.
1649///
1650/// The mode is global by default and can be overridden **per destination**
1651/// ([`FakeTracker::set_mode_for`]), the [`FakePush`] shape: one tenant's
1652/// expired token among live ones. The state an accepted `status` reports
1653/// is scripted separately ([`FakeTracker::set_state`]), because "the file
1654/// succeeded" and "the ticket has moved on since" are two different
1655/// answers a test wants to set independently.
1656#[derive(Clone, Debug)]
1657pub struct FakeTracker {
1658    inner: Arc<FakeTrackerInner>,
1659}
1660
1661#[derive(Debug)]
1662struct FakeTrackerInner {
1663    mode: Mutex<TrackerMode>,
1664    per_destination: Mutex<HashMap<Destination, TrackerMode>>,
1665    filed: Mutex<Vec<FiledCall>>,
1666    statused: Mutex<Vec<StatusedCall>>,
1667    commented: Mutex<Vec<CommentedCall>>,
1668    state: Mutex<TicketState>,
1669}
1670
1671impl FakeTracker {
1672    #[must_use]
1673    pub fn new(mode: TrackerMode) -> Self {
1674        Self {
1675            inner: Arc::new(FakeTrackerInner {
1676                mode: Mutex::new(mode),
1677                per_destination: Mutex::new(HashMap::new()),
1678                filed: Mutex::new(Vec::new()),
1679                statused: Mutex::new(Vec::new()),
1680                commented: Mutex::new(Vec::new()),
1681                state: Mutex::new(TicketState::Open),
1682            }),
1683        }
1684    }
1685
1686    /// Every `file` accepted so far, in order.
1687    #[must_use]
1688    pub fn filed(&self) -> Vec<FiledCall> {
1689        self.inner.filed.lock().expect("tracker lock").clone()
1690    }
1691
1692    /// The most recent `file` accepted.
1693    #[must_use]
1694    pub fn last_filed(&self) -> Option<FiledCall> {
1695        self.inner
1696            .filed
1697            .lock()
1698            .expect("tracker lock")
1699            .last()
1700            .cloned()
1701    }
1702
1703    /// Every `status` accepted so far, in order.
1704    #[must_use]
1705    pub fn statused(&self) -> Vec<StatusedCall> {
1706        self.inner.statused.lock().expect("tracker lock").clone()
1707    }
1708
1709    /// Every `comment` accepted so far, in order.
1710    #[must_use]
1711    pub fn commented(&self) -> Vec<CommentedCall> {
1712        self.inner.commented.lock().expect("tracker lock").clone()
1713    }
1714
1715    /// Switches the mode every destination without an override answers
1716    /// with (e.g. flip to `Unauthorized` mid-test).
1717    pub fn set_mode(&self, mode: TrackerMode) {
1718        *self.inner.mode.lock().expect("tracker lock") = mode;
1719    }
1720
1721    /// Makes one destination answer with `mode`, whatever the global mode
1722    /// is — one tenant's expired token among live ones, say.
1723    pub fn set_mode_for(&self, dest: &Destination, mode: TrackerMode) {
1724        self.inner
1725            .per_destination
1726            .lock()
1727            .expect("tracker lock")
1728            .insert(dest.clone(), mode);
1729    }
1730
1731    /// Drops one destination's override, putting it back on the global
1732    /// mode.
1733    pub fn clear_mode_for(&self, dest: &Destination) {
1734        self.inner
1735            .per_destination
1736            .lock()
1737            .expect("tracker lock")
1738            .remove(dest);
1739    }
1740
1741    /// The mode `dest` will answer with.
1742    #[must_use]
1743    pub fn mode_for(&self, dest: &Destination) -> TrackerMode {
1744        self.inner
1745            .per_destination
1746            .lock()
1747            .expect("tracker lock")
1748            .get(dest)
1749            .cloned()
1750            .unwrap_or_else(|| self.inner.mode.lock().expect("tracker lock").clone())
1751    }
1752
1753    /// The state an accepted `status` reports.
1754    pub fn set_state(&self, state: TicketState) {
1755        *self.inner.state.lock().expect("tracker lock") = state;
1756    }
1757
1758    /// The state an accepted `status` currently reports.
1759    #[must_use]
1760    pub fn state(&self) -> TicketState {
1761        *self.inner.state.lock().expect("tracker lock")
1762    }
1763
1764    /// The error arms shared by `file`, `status` and `comment`: the same
1765    /// fixed modes script all three, the way a real adapter answers the
1766    /// one `TrackerError` vocabulary on every method.
1767    fn error_for(mode: &TrackerMode) -> Option<TrackerError> {
1768        match mode {
1769            TrackerMode::FileOk => None,
1770            TrackerMode::NotConfigured => Some(TrackerError::NotConfigured),
1771            TrackerMode::Unauthorized => Some(TrackerError::Unauthorized),
1772            TrackerMode::Rejected => {
1773                Some(TrackerError::Rejected("fake tracker rejection".to_owned()))
1774            }
1775            TrackerMode::Transient => Some(TrackerError::Transient { retry_after: None }),
1776            TrackerMode::Error(error) => Some(error.clone()),
1777        }
1778    }
1779}
1780
1781impl Default for FakeTracker {
1782    fn default() -> Self {
1783        Self::new(TrackerMode::FileOk)
1784    }
1785}
1786
1787#[async_trait]
1788impl Tracker for FakeTracker {
1789    async fn file(
1790        &self,
1791        dest: &Destination,
1792        cred: &Credential,
1793        draft: &TicketDraft,
1794    ) -> Result<Filed, TrackerError> {
1795        if let Some(error) = Self::error_for(&self.mode_for(dest)) {
1796            return Err(error);
1797        }
1798        let id = format!(
1799            "fake-{}",
1800            self.inner.filed.lock().expect("tracker lock").len()
1801        );
1802        self.inner
1803            .filed
1804            .lock()
1805            .expect("tracker lock")
1806            .push(FiledCall {
1807                dest: dest.clone(),
1808                draft: draft.clone(),
1809                credential_fingerprint: fingerprint(cred),
1810            });
1811        Ok(Filed {
1812            url: format!("https://tracker.fake.test/browse/{id}"),
1813            external_id: id,
1814        })
1815    }
1816
1817    async fn status(
1818        &self,
1819        dest: &Destination,
1820        cred: &Credential,
1821        external_id: &str,
1822    ) -> Result<TicketStatus, TrackerError> {
1823        if let Some(error) = Self::error_for(&self.mode_for(dest)) {
1824            return Err(error);
1825        }
1826        self.inner
1827            .statused
1828            .lock()
1829            .expect("tracker lock")
1830            .push(StatusedCall {
1831                dest: dest.clone(),
1832                external_id: external_id.to_owned(),
1833                credential_fingerprint: fingerprint(cred),
1834            });
1835        Ok(TicketStatus {
1836            external_id: external_id.to_owned(),
1837            state: self.state(),
1838            url: None,
1839        })
1840    }
1841
1842    async fn comment(
1843        &self,
1844        dest: &Destination,
1845        cred: &Credential,
1846        external_id: &str,
1847        comment: &TicketComment,
1848    ) -> Result<(), TrackerError> {
1849        if let Some(error) = Self::error_for(&self.mode_for(dest)) {
1850            return Err(error);
1851        }
1852        self.inner
1853            .commented
1854            .lock()
1855            .expect("tracker lock")
1856            .push(CommentedCall {
1857                dest: dest.clone(),
1858                external_id: external_id.to_owned(),
1859                comment: comment.clone(),
1860                credential_fingerprint: fingerprint(cred),
1861            });
1862        Ok(())
1863    }
1864}
1865
1866// ---------------------------------------------------------------------------
1867// FakeRealtime
1868
1869/// Room id -> the member ids a [`FakeRealtime`] reports for it.
1870type RealtimeMembers = std::collections::HashMap<String, Vec<String>>;
1871/// The `(room_id, message)` broadcasts a [`FakeRealtime`] recorded.
1872type RealtimeBroadcasts = Vec<(String, Vec<u8>)>;
1873
1874/// An in-memory [`cratefield_core::Realtime`] for module tests: records every
1875/// broadcast per room and reports a fixed member list. It exercises the port a
1876/// module holds (broadcast/members from outside a socket); the socket lifecycle
1877/// and `RoomHandler` are the runtime adapter's job, covered by the native
1878/// adapter's own tests.
1879#[derive(Clone, Default)]
1880pub struct FakeRealtime {
1881    broadcasts: Arc<Mutex<RealtimeBroadcasts>>,
1882    members: Arc<Mutex<RealtimeMembers>>,
1883}
1884
1885impl FakeRealtime {
1886    #[must_use]
1887    pub fn new() -> Self {
1888        Self::default()
1889    }
1890
1891    /// Presets the members a room reports (for `members` assertions).
1892    pub fn set_members(&self, room_id: &str, ids: &[&str]) {
1893        self.members.lock().expect("realtime lock").insert(
1894            room_id.to_owned(),
1895            ids.iter().map(|id| (*id).to_owned()).collect(),
1896        );
1897    }
1898
1899    /// Every `(room_id, message)` broadcast so far.
1900    #[must_use]
1901    pub fn broadcasts(&self) -> Vec<(String, Vec<u8>)> {
1902        self.broadcasts.lock().expect("realtime lock").clone()
1903    }
1904}
1905
1906#[async_trait]
1907impl cratefield_core::Realtime for FakeRealtime {
1908    async fn broadcast(
1909        &self,
1910        room_id: &str,
1911        message: &[u8],
1912    ) -> Result<(), cratefield_core::RealtimeError> {
1913        self.broadcasts
1914            .lock()
1915            .expect("realtime lock")
1916            .push((room_id.to_owned(), message.to_vec()));
1917        Ok(())
1918    }
1919
1920    async fn members(
1921        &self,
1922        room_id: &str,
1923    ) -> Result<Vec<cratefield_core::Member>, cratefield_core::RealtimeError> {
1924        Ok(self
1925            .members
1926            .lock()
1927            .expect("realtime lock")
1928            .get(room_id)
1929            .map(|ids| ids.iter().map(cratefield_core::Member::new).collect())
1930            .unwrap_or_default())
1931    }
1932}
1933
1934/// What a [`FakeAuth`] answers.
1935///
1936/// Every outcome the port has, because a fake that cannot produce one
1937/// makes the arm handling it unreachable from every test — which is how
1938/// a five-arm mapping shipped with four of the arms never executed.
1939#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1940pub enum AuthMode {
1941    /// Every request is anonymous, header or not. The deployment where
1942    /// nobody signs in.
1943    Anonymous,
1944    /// A bearer token is taken at face value as the subject's id, and a
1945    /// request with no `Authorization` header is anonymous. The mode for
1946    /// a test that wants both branches without minting a JWT.
1947    TokenIsTheSubject,
1948    /// Every presented credential is refused, and a request with no
1949    /// header is still anonymous — the distinction the port exists to
1950    /// keep.
1951    NotVerified,
1952    /// The verifier cannot answer, credential or not.
1953    Unavailable,
1954}
1955
1956/// A [`cratefield_core::Auth`] answering from a mode rather than from
1957/// a key set.
1958pub struct FakeAuth {
1959    mode: AuthMode,
1960    calls: std::sync::Mutex<Vec<String>>,
1961}
1962
1963impl FakeAuth {
1964    /// A fake in `mode`.
1965    #[must_use]
1966    pub fn new(mode: AuthMode) -> Self {
1967        Self {
1968            mode,
1969            calls: std::sync::Mutex::new(Vec::new()),
1970        }
1971    }
1972
1973    /// A fake that reads the bearer token as the subject's id.
1974    #[must_use]
1975    pub fn subjects() -> Self {
1976        Self::new(AuthMode::TokenIsTheSubject)
1977    }
1978
1979    /// The bearer values it was asked about, in order. `""` records a
1980    /// request that carried no `Authorization` header at all.
1981    #[must_use]
1982    pub fn calls(&self) -> Vec<String> {
1983        self.calls.lock().expect("uncontended").clone()
1984    }
1985}
1986
1987#[async_trait::async_trait]
1988impl cratefield_core::Auth for FakeAuth {
1989    async fn identify(
1990        &self,
1991        headers: &http::HeaderMap,
1992    ) -> Result<cratefield_core::Caller, cratefield_core::AuthError> {
1993        let presented = headers
1994            .get(http::header::AUTHORIZATION)
1995            .and_then(|value| value.to_str().ok())
1996            .and_then(|value| value.strip_prefix("Bearer "))
1997            .map(str::to_owned);
1998        self.calls
1999            .lock()
2000            .expect("uncontended")
2001            .push(presented.clone().unwrap_or_default());
2002        match self.mode {
2003            AuthMode::Anonymous => Ok(cratefield_core::Caller::Anonymous),
2004            AuthMode::Unavailable => Err(cratefield_core::AuthError::Unavailable(
2005                "the fake is in Unavailable mode".to_owned(),
2006            )),
2007            AuthMode::NotVerified | AuthMode::TokenIsTheSubject => match presented {
2008                None => Ok(cratefield_core::Caller::Anonymous),
2009                Some(_) if self.mode == AuthMode::NotVerified => {
2010                    Err(cratefield_core::AuthError::NotVerified)
2011                }
2012                Some(token) => Ok(cratefield_core::Caller::Subject(
2013                    cratefield_core::Subject::new(token).session("fake-session"),
2014                )),
2015            },
2016        }
2017    }
2018}
2019
2020// ---------------------------------------------------------------------------
2021// FakeCustomHostnames
2022
2023/// An in-memory [`cratefield_core::CustomHostnames`] for module tests:
2024/// records every call, walks a claim from pending to live on demand, and
2025/// scripts the error the next call answers with.
2026///
2027/// `create` runs [`cratefield_core::check_hostname`] first, exactly as a
2028/// real adapter must, so a test can drive the `Refused` arm — the refusal
2029/// never reaches a provider.
2030#[derive(Clone)]
2031pub struct FakeCustomHostnames {
2032    inner: Arc<FakeCustomHostnamesInner>,
2033}
2034
2035struct FakeCustomHostnamesInner {
2036    own_zone: String,
2037    hostnames: Mutex<BTreeMap<String, CustomHostname>>,
2038    calls: Mutex<Vec<String>>,
2039    /// The scripted answer for the next call, if any.
2040    refusals: Mutex<VecDeque<CustomHostnameError>>,
2041    next_id: AtomicUsize,
2042}
2043
2044impl FakeCustomHostnames {
2045    /// A fake serving hostnames outside `own_zone` — the zone whose names
2046    /// are refused as the deployment's own.
2047    #[must_use]
2048    pub fn new(own_zone: impl Into<String>) -> Self {
2049        Self {
2050            inner: Arc::new(FakeCustomHostnamesInner {
2051                own_zone: own_zone.into(),
2052                hostnames: Mutex::new(BTreeMap::new()),
2053                calls: Mutex::new(Vec::new()),
2054                refusals: Mutex::new(VecDeque::new()),
2055                next_id: AtomicUsize::new(0),
2056            }),
2057        }
2058    }
2059
2060    /// Every call so far, in order, as `"create share.acme.com"`.
2061    #[must_use]
2062    pub fn calls(&self) -> Vec<String> {
2063        self.inner
2064            .calls
2065            .lock()
2066            .expect("custom hostnames lock")
2067            .clone()
2068    }
2069
2070    /// Walks a claim to `Active`/`Active` with no records left to publish.
2071    ///
2072    /// A hostname that was never claimed is a no-op rather than a panic:
2073    /// a test's setup failing should fail on the assertion, not here.
2074    pub fn activate(&self, hostname: &str) {
2075        let key = self.key(hostname);
2076        if let Some(claim) = self
2077            .inner
2078            .hostnames
2079            .lock()
2080            .expect("custom hostnames lock")
2081            .get_mut(&key)
2082        {
2083            claim.status = ProviderStatus::Active;
2084            claim.certificate = CertificateStatus::Active;
2085            claim.validation.clear();
2086        }
2087    }
2088
2089    /// Records the claim as failed with the provider's own `reason`.
2090    pub fn fail(&self, hostname: &str, reason: &str) {
2091        let key = self.key(hostname);
2092        if let Some(claim) = self
2093            .inner
2094            .hostnames
2095            .lock()
2096            .expect("custom hostnames lock")
2097            .get_mut(&key)
2098        {
2099            claim.status = ProviderStatus::Failed {
2100                reason: reason.to_owned(),
2101            };
2102        }
2103    }
2104
2105    /// Makes the next call answer `error` instead of its normal answer.
2106    /// Queue several to script a sequence.
2107    pub fn refuse_next(&self, error: CustomHostnameError) {
2108        self.inner
2109            .refusals
2110            .lock()
2111            .expect("custom hostnames lock")
2112            .push_back(error);
2113    }
2114
2115    /// The key a hostname is stored under: the same normalisation the
2116    /// adapter's `check_hostname` call produces.
2117    fn key(&self, hostname: &str) -> String {
2118        check_hostname(hostname, &self.inner.own_zone)
2119            .unwrap_or_else(|_| hostname.trim().trim_end_matches('.').to_ascii_lowercase())
2120    }
2121
2122    fn log(&self, call: String) {
2123        self.inner
2124            .calls
2125            .lock()
2126            .expect("custom hostnames lock")
2127            .push(call);
2128    }
2129
2130    fn take_refusal(&self) -> Option<CustomHostnameError> {
2131        self.inner
2132            .refusals
2133            .lock()
2134            .expect("custom hostnames lock")
2135            .pop_front()
2136    }
2137}
2138
2139#[async_trait]
2140impl CustomHostnames for FakeCustomHostnames {
2141    async fn create(&self, claim: &HostnameClaim) -> Result<CustomHostname, CustomHostnameError> {
2142        self.log(format!("create {}", claim.hostname));
2143        if let Some(error) = self.take_refusal() {
2144            return Err(error);
2145        }
2146        let hostname = check_hostname(&claim.hostname, &self.inner.own_zone)
2147            .map_err(CustomHostnameError::Refused)?;
2148        let mut hostnames = self.inner.hostnames.lock().expect("custom hostnames lock");
2149        if hostnames.contains_key(&hostname) {
2150            return Err(CustomHostnameError::AlreadyExists);
2151        }
2152        let id = format!(
2153            "fake-{}",
2154            self.inner.next_id.fetch_add(1, Ordering::Relaxed)
2155        );
2156        let created = CustomHostname {
2157            id: id.clone(),
2158            hostname: hostname.clone(),
2159            status: ProviderStatus::Pending,
2160            certificate: CertificateStatus::Pending,
2161            validation: vec![Validation {
2162                record_type: DnsRecordType::Txt,
2163                name: format!("_cf-custom-hostname.{hostname}"),
2164                value: id,
2165            }],
2166        };
2167        hostnames.insert(hostname, created.clone());
2168        Ok(created)
2169    }
2170
2171    async fn get(&self, hostname: &str) -> Result<Option<CustomHostname>, CustomHostnameError> {
2172        self.log(format!("get {hostname}"));
2173        if let Some(error) = self.take_refusal() {
2174            return Err(error);
2175        }
2176        let key = self.key(hostname);
2177        Ok(self
2178            .inner
2179            .hostnames
2180            .lock()
2181            .expect("custom hostnames lock")
2182            .get(&key)
2183            .cloned())
2184    }
2185
2186    async fn delete(&self, hostname: &str) -> Result<(), CustomHostnameError> {
2187        self.log(format!("delete {hostname}"));
2188        if let Some(error) = self.take_refusal() {
2189            return Err(error);
2190        }
2191        let key = self.key(hostname);
2192        self.inner
2193            .hostnames
2194            .lock()
2195            .expect("custom hostnames lock")
2196            .remove(&key);
2197        Ok(())
2198    }
2199
2200    async fn refresh(&self, hostname: &str) -> Result<CustomHostname, CustomHostnameError> {
2201        self.log(format!("refresh {hostname}"));
2202        if let Some(error) = self.take_refusal() {
2203            return Err(error);
2204        }
2205        let key = self.key(hostname);
2206        self.inner
2207            .hostnames
2208            .lock()
2209            .expect("custom hostnames lock")
2210            .get(&key)
2211            .cloned()
2212            .ok_or(CustomHostnameError::NotFound)
2213    }
2214}
2215
2216#[cfg(test)]
2217mod tests {
2218    use super::*;
2219    use cratefield_core::Blob as _;
2220
2221    fn json_body(response: &Response<Bytes>) -> serde_json::Value {
2222        serde_json::from_slice(response.body()).expect("the body is json")
2223    }
2224
2225    #[test]
2226    fn problem_json_carries_the_problem_fields_and_media_type() {
2227        let response = problem_json(
2228            StatusCode::TOO_MANY_REQUESTS,
2229            "Too Many Requests",
2230            "slow down",
2231        );
2232        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
2233        assert_eq!(response.headers()[CONTENT_TYPE], "application/problem+json");
2234        let body = json_body(&response);
2235        assert_eq!(body["type"], "about:blank");
2236        assert_eq!(body["title"], "Too Many Requests");
2237        assert_eq!(body["status"], 429);
2238        assert_eq!(body["detail"], "slow down");
2239    }
2240
2241    #[test]
2242    fn error_json_is_the_colonizer_shape() {
2243        let response = error_json(StatusCode::BAD_REQUEST, "bad request");
2244        assert_eq!(response.headers()[CONTENT_TYPE], "application/json");
2245        assert_eq!(json_body(&response)["error"], "bad request");
2246    }
2247
2248    #[test]
2249    fn with_retry_after_sets_the_header_in_seconds() {
2250        let response = with_retry_after(error_json(StatusCode::TOO_MANY_REQUESTS, "slow"), 42);
2251        assert_eq!(response.headers()[RETRY_AFTER], "42");
2252    }
2253
2254    #[test]
2255    fn a_preset_answers_through_the_port() {
2256        // Drive a preset end to end: it must be wired into `scripted`, not
2257        // merely build a body.
2258        let http = FakeHttpClient::owlpost_rate_limited(7);
2259        let request = Request::builder()
2260            .uri("https://api.owlpost.fake/v1/emails")
2261            .body(Bytes::new())
2262            .expect("the request builds");
2263        let response = pollster::block_on(http.send(request)).expect("the preset answers");
2264        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
2265        assert_eq!(response.headers()[RETRY_AFTER], "7");
2266        assert_eq!(json_body(&response)["detail"], "slow down");
2267    }
2268
2269    #[pollster::test]
2270    async fn memory_blob_presigns_and_verifies_back() {
2271        // A clock dated in the past: a URL it stamps is expired if verification
2272        // reads the wall clock instead of the store's.
2273        let at = time::OffsetDateTime::from_unix_timestamp(1_577_836_800).expect("a valid epoch");
2274        let blob = MemoryBlob::with_presign(Arc::new(FixedClock(at)));
2275        let ttl = Duration::from_secs(3600);
2276
2277        let get = blob.signed_url("cms/clip.mp3", ttl).await.unwrap();
2278        assert!(get.starts_with("https://blob.test/cms/clip.mp3?"), "{get}");
2279        assert_eq!(
2280            blob.verify_presigned(&get, "GET", &[]).unwrap(),
2281            "cms/clip.mp3"
2282        );
2283
2284        let put = blob
2285            .signed_put_url("cms/clip.mp3", "audio/mpeg", Some(3), ttl)
2286            .await
2287            .unwrap();
2288        assert_eq!((put.method, put.headers.len()), ("PUT", 2));
2289        assert!(blob.verify_presigned(&put.url, "PUT", &put.headers).is_ok());
2290        // A header the URL was not signed for is refused.
2291        assert!(blob.verify_presigned(&put.url, "PUT", &[]).is_err());
2292
2293        // Without presigning, the Unsupported default stands.
2294        assert!(matches!(
2295            MemoryBlob::new().signed_url("k", ttl).await.unwrap_err(),
2296            cratefield_core::BlobError::Unsupported(_)
2297        ));
2298    }
2299}