Skip to main content

cratefield_core/
tenant_lifecycle.rs

1//! The write half of the tenant registry (issue #154): registering a
2//! tenant, reading the registry back, and walking a tenant to its
3//! erasure. The read half — turning a request's host into a tenant, and
4//! a tenant into a database handle — is [`ResolveTenant`] and
5//! [`TenantDatabases`]; those answer requests against the rows this
6//! writes. One registry, two halves, because the two fail differently
7//! and are trusted by different callers.
8//!
9//! Until now the only writers were `Postgres::register_tenant` and
10//! `Postgres::set_tenant_status`, and their answers were a `bool` and a
11//! string: a refused move and a failed write were the same `false`, and
12//! re-registering an archived id was a `DbError` whose reason lived in
13//! prose. That is the failure this shape prevents — a caller above the
14//! adapter could not tell "the lifecycle says no" from "the database is
15//! down" without parsing driver text, so it could not reliably refuse,
16//! retry or page. `docs/TENANT-ONBOARDING.md` §4 names exactly this gap
17//! and asks for the port; `docs/TENANT-PROMOTION.md` describes the
18//! promotion side of the same state machine.
19//!
20//! The state machine itself is not reimplemented here.
21//! [`TenantStatus::admits`] is the one place the rule lives, and a
22//! second copy — in a `WHERE` clause, a fake, or a runbook script — is
23//! how the copies drift. The runbook *order* is what this port owns:
24//! [`TenantLifecycle::begin_erasure`] and
25//! [`TenantLifecycle::complete_erasure`] encode the two registry writes
26//! of `docs/TENANT-ONBOARDING.md` §2 so no caller has to know that
27//! `offboarding` comes before the export or that `archived` is last.
28//!
29//! It is deliberately **not** a [`Port`](crate::ports::Port). Modules
30//! must not write the registry: the registry is what decides which
31//! tenant — and therefore which database — a module is allowed to touch,
32//! and a module that could move its own row back out of `offboarding`
33//! would resurrect a tenant whose keys are being destroyed. Offboarding
34//! is an operator and CLI concern (`docs/TENANT-ONBOARDING.md` §2, §4);
35//! the `fz tenant` command (#36) stands on this port when it arrives,
36//! which is why the port is runtime-neutral and the Postgres impl is
37//! not part of the module surface.
38
39use crate::tenant::TenantStatus;
40
41/// Why a registry write did not happen.
42///
43/// Carries the tenant id — the one thing every caller needs to know
44/// which row failed — and never a DSN, mirroring [`crate::tenant::TenantDbError`]:
45/// the DSN is a global secret (ADR 0008) that the registry exists to
46/// *name*, not to hand out, and an error is exactly the thing most
47/// likely to be logged verbatim. The one field that holds foreign text,
48/// `Backend`'s `message`, goes through [`crate::logging::scrub_text`]
49/// when rendered, because a driver message can quote a connect string —
50/// the guarantee belongs to this type, not to whoever constructs it,
51/// and construction is not something an enum's variants can control.
52#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum TenantLifecycleError {
55    /// No registry row for this tenant.
56    Unknown {
57        /// Which tenant was asked about. Safe to log.
58        tenant: String,
59    },
60    /// The move is not one [`TenantStatus::can_become`] allows.
61    Refused {
62        /// Which tenant was being moved. Safe to log.
63        tenant: String,
64        /// The status the row was in.
65        from: TenantStatus,
66        /// The status that was asked for.
67        to: TenantStatus,
68    },
69    /// The registry write itself failed.
70    Backend {
71        /// Which tenant was being written. Safe to log.
72        tenant: String,
73        /// Why, scrubbed of secrets on the way out — a driver message
74        /// can quote a DSN.
75        message: String,
76    },
77}
78
79impl std::fmt::Display for TenantLifecycleError {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Self::Unknown { tenant } => {
83                write!(f, "no registry row for tenant `{tenant}`")
84            }
85            Self::Refused { tenant, from, to } => {
86                write!(f, "tenant `{tenant}` cannot move from {from} to {to}")
87            }
88            Self::Backend { tenant, message } => {
89                // Scrub at the sink: the variant's fields are public, so
90                // the type cannot control what it is constructed with,
91                // and `scrub_text` is idempotent for the paths that
92                // scrubbed at construction.
93                let message = crate::logging::scrub_text(message);
94                write!(f, "tenant `{tenant}`'s registry write failed: {message}")
95            }
96        }
97    }
98}
99
100impl std::error::Error for TenantLifecycleError {}
101
102/// One row of the registry, as this port reports it.
103///
104/// Deliberately omits the DSN, unlike the adapter's `TenantRecord`
105/// (which carries it, because reconciliation connects with it): a
106/// runtime-neutral listing is the thing most likely to be logged,
107/// rendered into a dashboard, or printed by the `fz tenant` command
108/// this port exists for. ADR 0008 makes a tenant DSN a global secret
109/// the registry names but never hands out, so a type that does not
110/// contain the string is a leak the compiler rules out — a comment on a
111/// field that does is not.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct TenantSummary {
114    /// The tenant id, e.g. `factory0`.
115    pub tenant: String,
116    /// The status the registry last recorded.
117    pub status: TenantStatus,
118}
119
120/// One step of a tenant's erasure, in the order
121/// `docs/TENANT-ONBOARDING.md` §2 gives it.
122///
123/// The order is the contract: the export happens while the data is
124/// still readable, the crypto-shred happens *before* the drop so any
125/// copy of the ciphertext — including provider backups that outlive the
126/// drop — stays unreadable, and the drop happens after the retention
127/// hold. Two of the steps are registry writes this port performs; the
128/// rest are a person's, which [`ErasureStep::is_operator`] states so a
129/// runbook can list only the steps it has to wait for.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum ErasureStep {
133    /// §2 row 1: set status `offboarding`; the tenant stops serving.
134    StopServing,
135    /// §2 row 2: final export with `fz data export`.
136    ExportData,
137    /// §2 row 3: crypto-shred — destroy the tenant's data keys
138    /// (`docs/KEY-ROTATION.md`).
139    ShredDataKeys,
140    /// §2 row 4: retention hold for the agreed period.
141    HoldRetention,
142    /// §2 row 5: drop the database after the hold.
143    DropDatabase,
144    /// §2 row 6: set status `archived`; the id can never be registered
145    /// again.
146    Archive,
147}
148
149impl ErasureStep {
150    /// The runbook's lowercase name for the step.
151    #[must_use]
152    pub const fn as_str(self) -> &'static str {
153        match self {
154            Self::StopServing => "stop-serving",
155            Self::ExportData => "export-data",
156            Self::ShredDataKeys => "shred-data-keys",
157            Self::HoldRetention => "hold-retention",
158            Self::DropDatabase => "drop-database",
159            Self::Archive => "archive",
160        }
161    }
162
163    /// Whether the step is a person's rather than this port's.
164    ///
165    /// `docs/TENANT-ONBOARDING.md` §2 marks every step `harness` or
166    /// `operator`, where operator means a person's step — it touches a
167    /// cloud console or holds a credential, and the harness cannot do it
168    /// (§1's definition of the mark). The two `harness` rows are the
169    /// registry writes this port performs; everything a console,
170    /// clipboard or calendar is involved in belongs to an operator.
171    #[must_use]
172    pub const fn is_operator(self) -> bool {
173        match self {
174            // §2 row 1, "harness, no CLI (§4)": this port's
175            // `begin_erasure` performs it — as row 6, also "harness, no
176            // CLI (§4)", is performed by `complete_erasure`.
177            Self::StopServing | Self::Archive => false,
178            // §2 rows 2–5 are the "operator" steps, each a person's:
179            // `fz data export` is run by one (row 2); destroying data
180            // keys is a person's step (`docs/KEY-ROTATION.md`, row 3);
181            // the hold is a person's calendar entry, and nothing is
182            // dropped during it (row 4); the drop happens in the
183            // provider console, and its evidence is the console itself
184            // (row 5).
185            Self::ExportData | Self::ShredDataKeys | Self::HoldRetention | Self::DropDatabase => {
186                true
187            }
188        }
189    }
190}
191
192impl std::fmt::Display for ErasureStep {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        f.write_str(self.as_str())
195    }
196}
197
198/// The erasure steps still outstanding for a tenant in `status`.
199///
200/// This is the runbook's position, in one place: a caller that stops a
201/// tenant and needs to know what it is now waiting for asks here
202/// instead of keeping its own copy of §2's table. A tenant still serving
203/// has all six steps ahead of it; one in `offboarding` has had the stop
204/// performed for it and starts at the export; an archived tenant has
205/// nothing left, because `archived` records that the drop and the shred
206/// both happened.
207///
208/// # The match is the exhaustiveness argument
209///
210/// [`TenantStatus`] is `#[non_exhaustive]` so downstream crates can keep
211/// matching it as variants are added. This match lives in the same
212/// crate as the enum, so that attribute does not weaken it here: a new
213/// status cannot compile until someone places it in this match and says
214/// which erasure steps it leaves outstanding — the same idiom
215/// [`TenantStatus::is_reconciled`] established, applied to the runbook
216/// instead of the fleet.
217#[must_use]
218pub fn remaining_erasure(status: TenantStatus) -> &'static [ErasureStep] {
219    match status {
220        // Serving, trying to serve, or unwell — §2 row 1 has not run,
221        // so every step is outstanding.
222        TenantStatus::Active | TenantStatus::Provisioning | TenantStatus::Degraded => &[
223            ErasureStep::StopServing,
224            ErasureStep::ExportData,
225            ErasureStep::ShredDataKeys,
226            ErasureStep::HoldRetention,
227            ErasureStep::DropDatabase,
228            ErasureStep::Archive,
229        ],
230        // The stop happened (`begin_erasure` performed §2 row 1); the
231        // export is the first step still to do.
232        TenantStatus::Offboarding => &[
233            ErasureStep::ExportData,
234            ErasureStep::ShredDataKeys,
235            ErasureStep::HoldRetention,
236            ErasureStep::DropDatabase,
237            ErasureStep::Archive,
238        ],
239        // Terminal: the shred and the drop happened. §2 has no further
240        // row for this tenant.
241        TenantStatus::Archived => &[],
242    }
243}
244
245/// The write half of the tenant registry: create a tenant, read the
246/// registry, and walk a tenant through its erasure.
247///
248/// Implemented by the runtime's registry adapter — on Postgres, against
249/// the `harness_tenants` table — and by test doubles; never by a
250/// module, which has no business writing the row that decides what it
251/// may touch. The legal moves are [`TenantStatus`]'s alone: an
252/// implementation enforces [`TenantStatus::admits`] and reports a
253/// refusal through [`TenantLifecycleError::Refused`], it does not have
254/// an opinion about the lifecycle.
255#[async_trait::async_trait]
256pub trait TenantLifecycle: Send + Sync {
257    /// Registers a tenant: the row is inserted, or an existing
258    /// non-archived row is re-pointed at `dsn`, and the status lands on
259    /// [`TenantStatus::Provisioning`] — the next reconciliation is what
260    /// promotes it.
261    ///
262    /// # Errors
263    ///
264    /// [`TenantLifecycleError::Refused`] with `from` =
265    /// [`TenantStatus::Archived`] when the id was archived: archiving
266    /// destroyed its data keys, so a resurrected row would name a tenant
267    /// nothing can reconstitute. [`TenantLifecycleError::Backend`] when
268    /// the write itself fails. Never a DSN in either case.
269    async fn create(&self, tenant: &str, dsn: &str) -> Result<(), TenantLifecycleError>;
270
271    /// The status the registry records for `tenant`.
272    ///
273    /// # Errors
274    ///
275    /// [`TenantLifecycleError::Unknown`] when there is no row;
276    /// [`TenantLifecycleError::Backend`] when the read fails.
277    async fn status(&self, tenant: &str) -> Result<TenantStatus, TenantLifecycleError>;
278
279    /// Every row of the registry, as [`TenantSummary`]s — no DSNs.
280    ///
281    /// # Errors
282    ///
283    /// [`TenantLifecycleError::Backend`] when the read fails.
284    async fn tenants(&self) -> Result<Vec<TenantSummary>, TenantLifecycleError>;
285
286    /// Moves the tenant's status, if [`TenantStatus::can_become`]
287    /// allows the move.
288    ///
289    /// # Errors
290    ///
291    /// [`TenantLifecycleError::Unknown`] when there is no row;
292    /// [`TenantLifecycleError::Refused`] naming the row's actual status
293    /// when the move is not allowed;
294    /// [`TenantLifecycleError::Backend`] when the write fails.
295    async fn set_status(
296        &self,
297        tenant: &str,
298        next: TenantStatus,
299    ) -> Result<(), TenantLifecycleError>;
300
301    /// Begins an erasure: moves the tenant to
302    /// [`TenantStatus::Offboarding`] and returns the steps of
303    /// `docs/TENANT-ONBOARDING.md` §2 still outstanding, in order, so
304    /// the caller can work down the list without keeping its own copy
305    /// of the table.
306    ///
307    /// This stops the tenant serving **immediately**, and that is the
308    /// point rather than a side effect: `Resolution::admit` answers an
309    /// `offboarding` row as a 404 unknown-tenant — indistinguishable
310    /// from a host that never existed — so the moment the row moves, the
311    /// fleet stops flying the tenant and its hosts go dark, before
312    /// anyone starts destroying its keys.
313    ///
314    /// # Errors
315    ///
316    /// [`TenantLifecycleError::Unknown`] when there is no row;
317    /// [`TenantLifecycleError::Refused`] when the tenant is archived
318    /// (there is nothing left to erase) or otherwise unable to move;
319    /// [`TenantLifecycleError::Backend`] when the write fails.
320    async fn begin_erasure(&self, tenant: &str) -> Result<Vec<ErasureStep>, TenantLifecycleError> {
321        self.set_status(tenant, TenantStatus::Offboarding).await?;
322        Ok(remaining_erasure(TenantStatus::Offboarding).to_vec())
323    }
324
325    /// Completes an erasure: records [`TenantStatus::Archived`].
326    ///
327    /// Irreversible by design: `Archived` is terminal
328    /// ([`TenantStatus::is_terminal`], and it admits nothing, itself
329    /// included), because the row is the record that the database was
330    /// dropped and the data keys destroyed — a record of an irreversible
331    /// act is not rewritten, and the id can never be registered again.
332    /// Call it only after the operator steps of §2 are done.
333    ///
334    /// # Errors
335    ///
336    /// [`TenantLifecycleError::Unknown`] when there is no row;
337    /// [`TenantLifecycleError::Refused`] naming the row's actual status
338    /// when the tenant is not in [`TenantStatus::Offboarding`] — only
339    /// the offboarding that performed the shred ends here;
340    /// [`TenantLifecycleError::Backend`] when the write fails.
341    async fn complete_erasure(&self, tenant: &str) -> Result<(), TenantLifecycleError> {
342        self.set_status(tenant, TenantStatus::Archived).await
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::{
349        ErasureStep, TenantLifecycle, TenantLifecycleError, TenantSummary, remaining_erasure,
350    };
351    use crate::tenant::TenantStatus;
352
353    /// Declares [`EVERY`] and, from the same list, a match that has to
354    /// be exhaustive. The local twin of `tenant.rs`'s `statuses!`, which
355    /// is private to that module's test block and cannot be imported.
356    macro_rules! statuses {
357        ($($variant:ident),+ $(,)?) => {
358            const EVERY: &[TenantStatus] = &[$(TenantStatus::$variant),+];
359
360            #[expect(dead_code, reason = "its only job is to be exhaustive")]
361            fn exhaustive(status: TenantStatus) {
362                match status {
363                    $(TenantStatus::$variant => {}),+
364                }
365            }
366        };
367    }
368
369    statuses!(Provisioning, Active, Degraded, Offboarding, Archived);
370
371    /// A row of the fake registry. The DSN is kept only so the tests can
372    /// prove the listing drops it.
373    struct FakeRow {
374        tenant: String,
375        status: TenantStatus,
376        dsn: String,
377    }
378
379    /// An in-memory registry with the same shape the Postgres adapter
380    /// implements: an upsert that refuses archived ids, and status
381    /// writes gated on `TenantStatus::can_become`.
382    struct FakeLifecycle {
383        #[expect(
384            clippy::disallowed_types,
385            reason = "a test double needs interior mutability behind the trait's \
386                      `Sync` bound, and lock poisoning is irrelevant here: a \
387                      poisoned lock means a test has already failed"
388        )]
389        rows: std::sync::Mutex<Vec<FakeRow>>,
390    }
391
392    impl FakeLifecycle {
393        #[expect(
394            clippy::disallowed_types,
395            reason = "the same double's constructor names the same lock the \
396                      field above already carries the expect for: interior \
397                      mutability behind the trait's `Sync` bound, with lock \
398                      poisoning irrelevant to a test that has already failed"
399        )]
400        fn new() -> Self {
401            Self {
402                rows: std::sync::Mutex::new(Vec::new()),
403            }
404        }
405    }
406
407    const DSN: &str = "postgres://acme:s3cret@db.internal:5432/acme";
408
409    #[async_trait::async_trait]
410    impl TenantLifecycle for FakeLifecycle {
411        async fn create(&self, tenant: &str, dsn: &str) -> Result<(), TenantLifecycleError> {
412            let mut rows = self
413                .rows
414                .lock()
415                .expect("a poisoned lock means a test has already failed");
416            match rows.iter_mut().find(|row| row.tenant == tenant) {
417                // The upsert the Postgres adapter performs: re-point the
418                // DSN and reset to provisioning — unless archived,
419                // which refuses.
420                Some(row) if row.status != TenantStatus::Archived => {
421                    row.status = TenantStatus::Provisioning;
422                    row.dsn = dsn.to_owned();
423                    Ok(())
424                }
425                Some(row) => {
426                    let from = row.status;
427                    Err(TenantLifecycleError::Refused {
428                        tenant: row.tenant.clone(),
429                        from,
430                        to: TenantStatus::Provisioning,
431                    })
432                }
433                None => {
434                    rows.push(FakeRow {
435                        tenant: tenant.to_owned(),
436                        status: TenantStatus::Provisioning,
437                        dsn: dsn.to_owned(),
438                    });
439                    Ok(())
440                }
441            }
442        }
443
444        async fn status(&self, tenant: &str) -> Result<TenantStatus, TenantLifecycleError> {
445            let rows = self
446                .rows
447                .lock()
448                .expect("a poisoned lock means a test has already failed");
449            match rows.iter().find(|row| row.tenant == tenant) {
450                Some(row) => Ok(row.status),
451                None => Err(TenantLifecycleError::Unknown {
452                    tenant: tenant.to_owned(),
453                }),
454            }
455        }
456
457        async fn tenants(&self) -> Result<Vec<TenantSummary>, TenantLifecycleError> {
458            let rows = self
459                .rows
460                .lock()
461                .expect("a poisoned lock means a test has already failed");
462            let mut summaries: Vec<TenantSummary> = rows
463                .iter()
464                .map(|row| TenantSummary {
465                    tenant: row.tenant.clone(),
466                    status: row.status,
467                })
468                .collect();
469            summaries.sort_by(|a, b| a.tenant.cmp(&b.tenant));
470            Ok(summaries)
471        }
472
473        async fn set_status(
474            &self,
475            tenant: &str,
476            next: TenantStatus,
477        ) -> Result<(), TenantLifecycleError> {
478            let mut rows = self
479                .rows
480                .lock()
481                .expect("a poisoned lock means a test has already failed");
482            match rows.iter_mut().find(|row| row.tenant == tenant) {
483                Some(row) if row.status.can_become(next) => {
484                    row.status = next;
485                    Ok(())
486                }
487                Some(row) => {
488                    let from = row.status;
489                    Err(TenantLifecycleError::Refused {
490                        tenant: row.tenant.clone(),
491                        from,
492                        to: next,
493                    })
494                }
495                None => Err(TenantLifecycleError::Unknown {
496                    tenant: tenant.to_owned(),
497                }),
498            }
499        }
500    }
501
502    #[pollster::test]
503    async fn a_tenant_walks_the_whole_lifecycle_through_the_port() {
504        let lifecycle = FakeLifecycle::new();
505        lifecycle
506            .create("acme", DSN)
507            .await
508            .expect("a fresh id registers");
509        assert_eq!(
510            lifecycle.status("acme").await.expect("registered"),
511            TenantStatus::Provisioning,
512            "a tenant registers as provisioning"
513        );
514        lifecycle
515            .set_status("acme", TenantStatus::Active)
516            .await
517            .expect("provisioning becomes active once reconciliation succeeds");
518
519        let steps = lifecycle
520            .begin_erasure("acme")
521            .await
522            .expect("a serving tenant can be retired");
523        assert_eq!(
524            lifecycle.status("acme").await.expect("registered"),
525            TenantStatus::Offboarding,
526            "begin_erasure moved the row to offboarding"
527        );
528        assert_eq!(
529            steps,
530            remaining_erasure(TenantStatus::Offboarding).to_vec(),
531            "the returned steps are the ones §2 still has outstanding"
532        );
533        assert_eq!(
534            steps.first(),
535            Some(&ErasureStep::ExportData),
536            "the stop already happened, so the export is the first step left"
537        );
538
539        lifecycle
540            .complete_erasure("acme")
541            .await
542            .expect("archived is the only way out of offboarding");
543        assert_eq!(
544            lifecycle.status("acme").await.expect("registered"),
545            TenantStatus::Archived,
546        );
547    }
548
549    #[pollster::test]
550    async fn every_revival_out_of_offboarding_is_a_typed_refusal() {
551        // Offboarding admits only itself and archived. Every other
552        // target is the refusal the port exists to type: `from` is the
553        // row's real status, `to` is what was asked for.
554        let lifecycle = FakeLifecycle::new();
555        lifecycle.create("acme", DSN).await.expect("registers");
556        lifecycle
557            .begin_erasure("acme")
558            .await
559            .expect("retirement starts");
560
561        for &revival in EVERY {
562            let outcome = lifecycle.set_status("acme", revival).await;
563            if matches!(revival, TenantStatus::Offboarding | TenantStatus::Archived) {
564                assert!(
565                    outcome.is_ok(),
566                    "{revival} is a legal way out of offboarding"
567                );
568            } else {
569                assert_eq!(
570                    outcome,
571                    Err(TenantLifecycleError::Refused {
572                        tenant: "acme".to_owned(),
573                        from: TenantStatus::Offboarding,
574                        to: revival,
575                    }),
576                    "{revival} must be refused with the move that was asked for"
577                );
578            }
579        }
580    }
581
582    #[pollster::test]
583    async fn nothing_leaves_archived_through_the_port() {
584        let lifecycle = FakeLifecycle::new();
585        lifecycle.create("acme", DSN).await.expect("registers");
586        lifecycle
587            .begin_erasure("acme")
588            .await
589            .expect("retirement starts");
590        lifecycle.complete_erasure("acme").await.expect("archives");
591
592        for &to in EVERY {
593            assert_eq!(
594                lifecycle.set_status("acme", to).await,
595                Err(TenantLifecycleError::Refused {
596                    tenant: "acme".to_owned(),
597                    from: TenantStatus::Archived,
598                    to,
599                }),
600                "archived -> {to} is not a move that exists, not even a repeat"
601            );
602        }
603        // Re-creating the id is refused the same way: the record of a
604        // destroyed tenant is not overwritten.
605        assert_eq!(
606            lifecycle.create("acme", DSN).await,
607            Err(TenantLifecycleError::Refused {
608                tenant: "acme".to_owned(),
609                from: TenantStatus::Archived,
610                to: TenantStatus::Provisioning,
611            }),
612            "an archived id must not re-register"
613        );
614        assert_eq!(
615            lifecycle.status("acme").await.expect("registered"),
616            TenantStatus::Archived,
617            "the row survived every attempt unmoved"
618        );
619    }
620
621    #[pollster::test]
622    async fn an_absent_tenant_is_unknown_not_backend() {
623        let lifecycle = FakeLifecycle::new();
624        assert_eq!(
625            lifecycle.status("ghost").await,
626            Err(TenantLifecycleError::Unknown {
627                tenant: "ghost".to_owned(),
628            }),
629            "a tenant with no row is unknown"
630        );
631        assert_eq!(
632            lifecycle.set_status("ghost", TenantStatus::Active).await,
633            Err(TenantLifecycleError::Unknown {
634                tenant: "ghost".to_owned(),
635            }),
636            "moving a tenant with no row is unknown, not a refusal"
637        );
638        // The provided methods stand on `set_status`, so they answer the
639        // same way and not with something new.
640        assert!(
641            matches!(
642                lifecycle.begin_erasure("ghost").await,
643                Err(TenantLifecycleError::Unknown { .. })
644            ),
645            "begin_erasure on an absent tenant is unknown"
646        );
647        assert!(
648            matches!(
649                lifecycle.complete_erasure("ghost").await,
650                Err(TenantLifecycleError::Unknown { .. })
651            ),
652            "complete_erasure on an absent tenant is unknown"
653        );
654    }
655
656    #[pollster::test]
657    async fn a_listing_through_the_port_never_carries_a_dsn() {
658        let lifecycle = FakeLifecycle::new();
659        lifecycle.create("acme", DSN).await.expect("registers");
660        lifecycle.create("zephyr", DSN).await.expect("registers");
661
662        let summaries: Vec<TenantSummary> = lifecycle.tenants().await.expect("readable");
663        assert_eq!(summaries.len(), 2, "every row is listed");
664        assert_eq!(
665            summaries
666                .iter()
667                .map(|summary| (summary.tenant.as_str(), summary.status))
668                .collect::<Vec<_>>(),
669            vec![
670                ("acme", TenantStatus::Provisioning),
671                ("zephyr", TenantStatus::Provisioning),
672            ],
673            "the listing carries the tenant and its status"
674        );
675
676        // The type has no field a DSN could ride in; this asserts the
677        // fake's stored DSN does not survive into the formatted listing
678        // either — the rendering is what gets logged.
679        let rendered = format!("{summaries:?}");
680        assert!(
681            !rendered.contains("postgres://"),
682            "a listing leaked a connect string: {rendered}"
683        );
684        assert!(
685            !rendered.contains("s3cret"),
686            "a listing leaked the DSN's secret: {rendered}"
687        );
688    }
689
690    #[test]
691    fn the_remaining_erasure_of_a_status_is_its_runbook_position() {
692        assert!(
693            remaining_erasure(TenantStatus::Archived).is_empty(),
694            "an archived tenant has no erasure left"
695        );
696        assert_eq!(
697            remaining_erasure(TenantStatus::Active).first(),
698            Some(&ErasureStep::StopServing),
699            "a serving tenant has not even been stopped"
700        );
701        assert_eq!(
702            remaining_erasure(TenantStatus::Active),
703            remaining_erasure(TenantStatus::Provisioning),
704            "every status that still serves has the whole runbook ahead of it"
705        );
706        assert_eq!(
707            remaining_erasure(TenantStatus::Offboarding).first(),
708            Some(&ErasureStep::ExportData),
709            "offboarding means the stop was performed for it"
710        );
711        // §2's order, stated once: the export while the data is readable,
712        // the shred before the drop, the drop after the hold.
713        assert_eq!(
714            remaining_erasure(TenantStatus::Offboarding),
715            &[
716                ErasureStep::ExportData,
717                ErasureStep::ShredDataKeys,
718                ErasureStep::HoldRetention,
719                ErasureStep::DropDatabase,
720                ErasureStep::Archive,
721            ]
722        );
723    }
724
725    #[test]
726    fn a_backend_message_is_scrubbed_on_its_way_out() {
727        // `scrub_text` rewrites URL userinfo to `scheme://[redacted]@host`
728        // and cuts the query, so the rendered error carries neither the
729        // password nor the parameters — but keeps the host, which is what
730        // makes a connect failure diagnosable at all.
731        let error = TenantLifecycleError::Backend {
732            tenant: "acme".to_owned(),
733            message: "error connecting to postgres://venture:sup3r-s3cret@db.internal:5432/app?\
734                      sslmode=require"
735                .to_owned(),
736        };
737        let rendered = error.to_string();
738        assert!(
739            !rendered.contains("sup3r-s3cret"),
740            "the password reached the rendered error: {rendered}"
741        );
742        assert!(
743            !rendered.contains("sslmode"),
744            "the query reached the rendered error: {rendered}"
745        );
746        assert!(
747            rendered.contains("postgres://[redacted]@db.internal:5432/app?[redacted]"),
748            "what survives is the scrubbed host and path: {rendered}"
749        );
750        assert!(
751            rendered.contains("acme"),
752            "the error still names its tenant: {rendered}"
753        );
754    }
755}