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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 17:03:10 +01:00
1 parent be78517500
commit 763cb48273
3 files changed
+84

No files matched your search

@@ -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[] {
@@ -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\//);
});
});
+5
View File
@@ -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: {