From 763cb48273c9458e4ac2e065421c1228a99312da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 17:03:10 +0100 Subject: [PATCH] fix(recovery): readiness leaves accounts pending deletion out The weekly and off-host passes skip an account pending deletion on purpose, and the readiness read counted it anyway: within days of any deletion the weekly copy read failing and the off-host copies stale, every admin was pushed a problem and then a recovery after the purge, and a real failure hid behind a signal already red. The read now filters with ACTIVE_ACCOUNT, and the enumeration guard covers the file. --- ...account-deletion-enumeration-guard.test.ts | 3 + .../readiness-pending-deletion.test.ts | 76 +++++++++++++++++++ src/lib/recovery/readiness.ts | 5 ++ 3 files changed, 84 insertions(+) create mode 100644 src/lib/recovery/__tests__/readiness-pending-deletion.test.ts diff --git a/src/__tests__/account-deletion-enumeration-guard.test.ts b/src/__tests__/account-deletion-enumeration-guard.test.ts index 8294b1478..2d8026647 100644 --- a/src/__tests__/account-deletion-enumeration-guard.test.ts +++ b/src/__tests__/account-deletion-enumeration-guard.test.ts @@ -49,6 +49,9 @@ const SRC = join(process.cwd(), "src"); const EXTRA_FILES = [ "lib/measurements/consolidation-base.ts", "lib/integrations/watchdog.ts", + // Read by the hourly readiness evaluation, which judges every account's + // copies. + "lib/recovery/readiness.ts", ]; function backgroundFiles(): string[] { diff --git a/src/lib/recovery/__tests__/readiness-pending-deletion.test.ts b/src/lib/recovery/__tests__/readiness-pending-deletion.test.ts new file mode 100644 index 000000000..13aaada2b --- /dev/null +++ b/src/lib/recovery/__tests__/readiness-pending-deletion.test.ts @@ -0,0 +1,76 @@ +/** + * The readiness read leaves accounts pending deletion out, as the weekly and + * off-host passes do. Their copies stop on purpose; counted, one deletion + * turned "weekly copy" failing and "off-host copies" stale, and every admin + * was pushed about a backup that was not broken. + */ +import { describe, expect, it, vi } from "vitest"; + +const NOW = new Date("2026-11-10T12:00:00Z"); +const DAY = 86_400_000; +const ago = (ms: number) => new Date(NOW.getTime() - ms); + +const healthy = { + createdAt: ago(400 * DAY), + dataBackups: [{ createdAt: ago(2 * DAY) }], + offhostBackupState: { + lastAttemptAt: ago(0.4 * DAY), + lastSuccessAt: ago(0.4 * DAY), + }, +}; +// Pending deletion for ten days: both passes have skipped it since. +const pending = { + createdAt: ago(400 * DAY), + dataBackups: [{ createdAt: ago(15 * DAY) }], + offhostBackupState: { + lastAttemptAt: ago(10.4 * DAY), + lastSuccessAt: ago(10.4 * DAY), + }, + deletionRequestedAt: ago(10 * DAY), +}; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findFirst: vi.fn(async () => ({ createdAt: ago(400 * DAY) })), + findMany: vi.fn( + async (args: { where?: { deletionRequestedAt?: null } }) => + [healthy, pending].filter( + (a) => + !(args.where && "deletionRequestedAt" in args.where) || + !("deletionRequestedAt" in a), + ), + ), + }, + restoreDrillOutcome: { findUnique: vi.fn(async () => null) }, + recoveryReadinessNotice: { findUnique: vi.fn(async () => null) }, + }, +})); +vi.mock("@/lib/jobs/offhost-backup", () => ({ + offhostBackupConfigured: () => true, + probeOffhostLifecycle: async () => ({ + database: { state: "configured", expirationDays: 30 }, + }), +})); +vi.mock("@/lib/recovery/kit", () => ({ + readRecoveryKit: async () => ({ + confirmedAt: NOW.toISOString(), + unconfirmed: [], + }), +})); + +const { readRecoveryReadiness } = await import("@/lib/recovery/readiness"); + +describe("readiness and accounts pending deletion", () => { + it("judges the weekly and off-host copies of active accounts only", async () => { + const r = await readRecoveryReadiness({ + now: NOW, + dumpHealth: async () => null, + }); + const byId = Object.fromEntries( + r.signals.map((s) => [s.id, `${s.state}/${s.reason}`]), + ); + expect(byId.weekly_copy).toMatch(/^ok\//); + expect(byId.offhost_copies).toMatch(/^ok\//); + }); +}); diff --git a/src/lib/recovery/readiness.ts b/src/lib/recovery/readiness.ts index 97782e0a9..944911e4f 100644 --- a/src/lib/recovery/readiness.ts +++ b/src/lib/recovery/readiness.ts @@ -17,6 +17,7 @@ * - the kit: the same assessment the kit card shows; * - the dump: whatever the dump module reports, through a provider. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { offhostBackupConfigured, @@ -53,7 +54,11 @@ export async function readReadinessInputs( orderBy: { createdAt: "asc" }, select: { createdAt: true }, }), + // The weekly and off-host passes skip an account pending deletion on + // purpose, so its copies age without anything being wrong; counted + // here, every deletion turned both signals red and pushed the admins. prisma.user.findMany({ + where: ACTIVE_ACCOUNT, select: { createdAt: true, dataBackups: {