Files
HealthLog/tests/integration/recovery-readiness-notify.test.ts
T
Marc-André Bombeck 8678a3edc4 test(recovery): keep the weekly-copy push test to its one signal
With the real dump health wired in, an empty test database also reports
a missing dump. The file follows the weekly copy alone; the dump signal
is judged in the readiness unit tests.
2026-10-25 15:16:14 +01:00

303 lines
10 KiB
TypeScript

/**
* Recovery readiness, end to end against Postgres: a weekly copy that stopped
* reaches every admin exactly once per state change, through the real
* readiness reader, the real notice ledger, the real dispatcher with its
* preference lookup and ledger, and the real APNs sender (vendor client
* stubbed, the shape `delegated-intake-notification.test.ts` uses).
*
* The facts:
*
* 1. A stale copy becomes one `BACKUP_HEALTH` delivery per admin; the next
* hourly runs at the same state add none.
* 2. Escalating to failing adds one; a reminder comes a day later and not
* sooner; the recovery adds one; ok after that adds none.
* 3. An admin who switched the event off on the channel gets nothing, and
* the notice is still spent, so switching it back on later does not
* replay an old alarm.
* 4. The hourly job runs the same path from its handler.
*
* The ledger write is fire-and-forget in `recordPushAttempt`, so counts are
* polled up to the expected number and then asserted exactly.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const apnsSendMock = vi.fn();
vi.mock("@parse/node-apn", () => {
class Provider {
constructor(_opts: unknown) {
void _opts;
}
send(...args: unknown[]) {
return apnsSendMock(...args);
}
shutdown() {}
}
class Notification {
public topic = "";
public alert: unknown;
public sound: string | undefined;
public badge: number | undefined;
public threadId: string | undefined;
public collapseId: string | undefined;
public payload: unknown;
}
return { default: { Provider, Notification }, Provider, Notification };
});
// This file follows the weekly-copy signal alone. The database dump has its
// own signal, judged in the readiness unit tests; here no dump is scheduled,
// so an empty test database does not add a dump alarm of its own.
vi.mock("@/lib/recovery/database-dump-health", () => ({
readDatabaseBackupHealth: async () => null,
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import { getPrismaClient, truncateAllTables } from "./setup";
const ADMIN_ID = "user-readiness-admin";
const SECOND_ADMIN_ID = "user-readiness-admin-2";
const MEMBER_ID = "user-readiness-member";
const DAY = 86_400_000;
const HOUR = 3_600_000;
const TEST_EC_PEM_LINES = [
"-----BEGIN PRIVATE KEY-----",
"MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLOXP3Exjr5L5tamN",
"pTxck85Iaum80PdRlWDpc/ezviOgCgYIKoZIzj0DAQehRANCAAT1x8nKRb8KshQU",
"1aPieSCqOY6ilgC959umaFSlhfav8eZ91UHP/xond9aMoZcuQ7lJG/Rsj70SWMvZ",
"bw81BG89",
"-----END PRIVATE KEY-----",
];
const APNS_ENV = {
APNS_KEY_ID: "ABCDE12345",
APNS_TEAM_ID: "TEAM123456",
APNS_BUNDLE_ID: "test.healthlog.ios",
APNS_KEY: TEST_EC_PEM_LINES.join("\\n"),
};
async function seedApnsChannel(userId: string): Promise<string> {
const { encrypt } = await import("@/lib/crypto");
const channel = await getPrismaClient().notificationChannel.create({
data: { userId, type: "APNS", enabled: true, config: encrypt("{}") },
});
await getPrismaClient().device.create({
data: {
userId,
platform: "ios",
token: `device-${userId}`,
bundleId: APNS_ENV.APNS_BUNDLE_ID,
apnsToken: `apns-${userId}`,
apnsEnvironment: "sandbox",
},
});
return channel.id;
}
async function backupAttempts(
userId: string,
expected: number,
): Promise<number> {
const count = () =>
getPrismaClient().pushAttempt.count({
where: { userId, eventType: "BACKUP_HEALTH" },
});
for (let attempt = 0; attempt < 40; attempt += 1) {
if ((await count()) >= expected) break;
await new Promise((r) => setTimeout(r, 25));
}
// A short settle, so a delivery that should NOT have happened has the same
// chance to land as one that should.
await new Promise((r) => setTimeout(r, 75));
return count();
}
/** Put the member's weekly copy `ageDays` before `now`. */
async function weeklyCopyAged(ageDays: number, now: Date): Promise<void> {
await getPrismaClient().dataBackup.upsert({
where: { userId_type: { userId: MEMBER_ID, type: "WEEKLY_AUTO" } },
create: {
userId: MEMBER_ID,
type: "WEEKLY_AUTO",
data: "sealed",
createdAt: new Date(now.getTime() - ageDays * DAY),
},
update: { createdAt: new Date(now.getTime() - ageDays * DAY) },
});
}
/** One hourly evaluation at `now`, through the reader and the notifier. */
async function evaluateAt(now: Date) {
const { readRecoveryReadiness } = await import("@/lib/recovery/readiness");
const { notifyReadinessChanges } =
await import("@/lib/recovery/readiness-notify");
const readiness = await readRecoveryReadiness({ now });
await notifyReadinessChanges(readiness, now);
return readiness;
}
beforeEach(async () => {
vi.clearAllMocks();
for (const [k, v] of Object.entries(APNS_ENV)) process.env[k] = v;
delete process.env.APNS_PRODUCTION;
for (const name of [
"BACKUP_S3_ENDPOINT",
"BACKUP_S3_BUCKET",
"BACKUP_S3_ACCESS_KEY",
"BACKUP_S3_SECRET_KEY",
"BACKUP_ENCRYPTION_KEY",
]) {
delete process.env[name];
}
const apns = await import("@/lib/notifications/senders/apns");
apns.resetApnsForTesting();
const { __resetDispatchLocaleCacheForTests } =
await import("@/lib/notifications/dispatch-localised");
__resetDispatchLocaleCacheForTests();
const prisma = getPrismaClient();
await truncateAllTables(prisma);
// Admins created "now": the setup grace keeps the off-host and kit signals
// quiet, so the weekly copy is the only signal that moves.
await prisma.user.createMany({
data: [
{
id: ADMIN_ID,
username: "readiness-admin",
email: "readiness-admin@example.test",
role: "ADMIN",
locale: "en",
},
{
id: SECOND_ADMIN_ID,
username: "readiness-admin-2",
email: "readiness-admin-2@example.test",
role: "ADMIN",
locale: "de",
},
{
id: MEMBER_ID,
username: "readiness-member",
email: "readiness-member@example.test",
createdAt: new Date(Date.now() - 90 * DAY),
},
],
});
// An admin's own account needs a fresh copy too, or it would age from its
// creation and move the verdict on its own.
const now = new Date();
for (const userId of [ADMIN_ID, SECOND_ADMIN_ID]) {
await prisma.dataBackup.create({
data: { userId, type: "WEEKLY_AUTO", data: "sealed", createdAt: now },
});
}
apnsSendMock.mockImplementation(async (note: unknown) => {
void note;
return { sent: [{ device: "x" }], failed: [] };
});
});
describe("recovery readiness push", () => {
it("notifies every admin once per state change, reminds daily while failing, and announces the recovery once", async () => {
await seedApnsChannel(ADMIN_ID);
await seedApnsChannel(SECOND_ADMIN_ID);
const t0 = new Date();
const at = (hours: number) => new Date(t0.getTime() + hours * HOUR);
// Ten days: attention.
await weeklyCopyAged(10, t0);
const first = await evaluateAt(t0);
expect(first.signals.find((s) => s.id === "weekly_copy")).toMatchObject({
state: "attention",
reason: "stale",
});
expect(first.signals.find((s) => s.id === "recovery_kit")?.state).toBe(
"ok",
);
await evaluateAt(at(1));
await evaluateAt(at(2));
expect(await backupAttempts(ADMIN_ID, 1)).toBe(1);
expect(await backupAttempts(SECOND_ADMIN_ID, 1)).toBe(1);
// Each admin in their own language.
const titles = apnsSendMock.mock.calls.map(
([note]) => (note as { alert: { title: string } }).alert.title,
);
expect(titles).toContain("Backups need attention");
expect(titles).toContain("Sicherungen brauchen Aufmerksamkeit");
// Fifteen days: failing, once.
await weeklyCopyAged(15, at(3));
await evaluateAt(at(3));
await evaluateAt(at(4));
expect(await backupAttempts(ADMIN_ID, 2)).toBe(2);
// Still failing: nothing until a day after the last notice, then one.
await evaluateAt(at(3 + 23));
expect(await backupAttempts(ADMIN_ID, 3)).toBe(2);
await evaluateAt(at(3 + 24));
await evaluateAt(at(3 + 25));
expect(await backupAttempts(ADMIN_ID, 3)).toBe(3);
// A fresh copy: one recovery notice, then silence.
await weeklyCopyAged(0, at(30));
await evaluateAt(at(30));
await evaluateAt(at(31));
expect(await backupAttempts(ADMIN_ID, 4)).toBe(4);
expect(await backupAttempts(SECOND_ADMIN_ID, 4)).toBe(4);
const notice = await getPrismaClient().recoveryReadinessNotice.findUnique({
where: { signal: "weekly_copy" },
});
expect(notice?.notifiedState).toBe("ok");
});
it("respects an admin's per-channel opt-out and still spends the notice", async () => {
const channelId = await seedApnsChannel(ADMIN_ID);
await getPrismaClient().notificationPreference.create({
data: { channelId, eventType: "BACKUP_HEALTH", enabled: false },
});
const t0 = new Date();
await weeklyCopyAged(10, t0);
await evaluateAt(t0);
expect(await backupAttempts(ADMIN_ID, 1)).toBe(0);
await getPrismaClient().notificationPreference.updateMany({
where: { channelId },
data: { enabled: true },
});
await evaluateAt(new Date(t0.getTime() + HOUR));
expect(await backupAttempts(ADMIN_ID, 1)).toBe(0);
const notice = await getPrismaClient().recoveryReadinessNotice.findUnique({
where: { signal: "weekly_copy" },
});
expect(notice?.notifiedState).toBe("attention");
});
it("runs the same path from the hourly job", async () => {
await seedApnsChannel(ADMIN_ID);
await weeklyCopyAged(10, new Date());
const { handleRecoveryReadiness } =
await import("@/lib/jobs/recovery-readiness");
const outcome = await handleRecoveryReadiness([]);
expect(outcome).toMatchObject({
ok: true,
did: {
recovery_readiness_state: "attention",
recovery_readiness_notices: 1,
recovery_readiness_admins: 2,
},
});
await handleRecoveryReadiness([]);
expect(await backupAttempts(ADMIN_ID, 1)).toBe(1);
});
});