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}