diff --git a/e2e/backup-restore-journey.spec.ts b/e2e/backup-restore-journey.spec.ts index 581fdb204..a5193ca58 100644 --- a/e2e/backup-restore-journey.spec.ts +++ b/e2e/backup-restore-journey.spec.ts @@ -18,7 +18,11 @@ * 5. what was deleted is back under its ORIGINAL id, and what arrived after * the snapshot is gone — which is what "the restore replaces the * account's data tables" in `docs/ops/backup-restore.md` means when it is - * spelled out. + * spelled out; + * 6. a second person downloads their own full backup, their account is + * recreated empty under the same id (the runbook's new host), the admin + * uploads the file and restores it, and the person signs in to the same + * rows under the same ids. * * Refusing controls, because a restore path that cannot say no is worse than * none: a stored copy with one flipped ciphertext byte is refused and changes @@ -36,19 +40,20 @@ * * Everything the journey does, it does through the app: the readings and the * document ride the page's own `fetch`, the archive and the snapshot ride the - * real buttons, the restore rides the typed-confirmation dialog. The only two + * real buttons, the restore rides the typed-confirmation dialog. The only * things reaching Postgres directly are the ones a browser cannot express — - * resetting the account between repetitions, and corrupting one stored copy so - * the authenticated envelope has something to refuse. Both live in - * `setup/backup-journey-fixture.ts`. + * resetting the accounts between repetitions, corrupting one stored copy so + * the authenticated envelope has something to refuse (both in + * `setup/backup-journey-fixture.ts`), and recreating the second account under + * its own id (`setup/critical-fixture.ts`). * - * Serial and single-project: the four tests are four acts of one journey, they - * mutate the account destructively, and the last one moves a session's record + * Serial and single-project: the five tests are five acts of one journey, they + * mutate the accounts destructively, and the last one moves a session's record * selector. Its jar is its own for the reason `global-setup.ts` spells out. */ import { readFile } from "node:fs/promises"; -import type { Page } from "@playwright/test"; +import type { Page, Response } from "@playwright/test"; import { EXPORT_ARGON2_PARAMS } from "@/lib/export/passphrase-archive"; @@ -65,6 +70,13 @@ import { storedMeasurements, storeTamperedCopy, } from "./setup/backup-journey-fixture"; +import { + accountRowCounts, + clearLoginBuckets, + criticalAccount, + recreateCriticalAccount, + resetCriticalAccount, +} from "./setup/critical-fixture"; import { actWithReproof, completeReproofWithPassword, @@ -91,6 +103,8 @@ const RESTORED_WEIGHT_KG = 81.4; const UNTOUCHED_PULSE_BPM = 58; /** The reading that arrives AFTER the snapshot and must not survive it. */ const POST_SNAPSHOT_PULSE_BPM = 66; +/** The second account's reading, carried through its own backup. */ +const FRESH_ACCOUNT_WEIGHT_KG = 67.3; const MEDICATION_NAME = "E2E Backup Tablet"; @@ -291,6 +305,45 @@ async function restoreThroughDialog(page: Page, id: string) { return answered; } +/** + * Wait for a queued restore to finish, and fail with its code if it failed. + * + * The restore runs on the `backup-restore` queue: the request answers 202 + * with the job and its status route, and the worker inside the server under + * test does the work. Nothing after this is read until the job says it is + * done, so every assertion is about the finished restore rather than a moment + * in the middle of it. + */ +async function awaitRestore(page: Page, response: Response): Promise { + expect(response.status()).toBe(202); + const queued = (await response.json()) as { + data: { jobId: string; status: string; statusUrl: string }; + }; + expect(queued.data.status).toBe("queued"); + expect(queued.data.statusUrl).toBe( + `/api/admin/backups/restores/${queued.data.jobId}`, + ); + await expect + .poll( + async () => { + const res = await page.request.get(queued.data.statusUrl); + const body = (await res.json()) as { + data: { + status: string; + failure: { code: string } | null; + } | null; + }; + // A failed job ends the wait with its code in the message instead of + // running the clock out on a state that will never change. + return body.data?.status === "failed" + ? `failed: ${body.data.failure?.code ?? "unknown"}` + : body.data?.status; + }, + { timeout: 60_000, intervals: [250, 500, 1_000] }, + ) + .toBe("succeeded"); +} + test.describe("Backup and restore, through the settings surfaces", () => { test.use({ storageState: BACKUP_STORAGE_STATE_PATH }); @@ -563,39 +616,10 @@ test.describe("Backup and restore, through the settings surfaces", () => { expect(beforeRestore.map((m) => m.id)).toContain(laterId); await openBackupsConsole(page); - const response = await restoreThroughDialog(page, requireSnapshot()); - // The restore runs on the `backup-restore` queue: the request answers 202 - // with the job and its status route, and the worker inside the server - // under test does the work. Nothing below is read until the job says it - // is done, so every assertion is about the finished restore rather than - // a moment in the middle of it. - expect(response.status()).toBe(202); - const queued = (await response.json()) as { - data: { jobId: string; status: string; statusUrl: string }; - }; - expect(queued.data.status).toBe("queued"); - expect(queued.data.statusUrl).toBe( - `/api/admin/backups/restores/${queued.data.jobId}`, + await awaitRestore( + page, + await restoreThroughDialog(page, requireSnapshot()), ); - await expect - .poll( - async () => { - const res = await page.request.get(queued.data.statusUrl); - const body = (await res.json()) as { - data: { - status: string; - failure: { code: string } | null; - } | null; - }; - // A failed job ends the wait with its code in the message instead - // of running the clock out on a state that will never change. - return body.data?.status === "failed" - ? `failed: ${body.data.failure?.code ?? "unknown"}` - : body.data?.status; - }, - { timeout: 60_000, intervals: [250, 500, 1_000] }, - ) - .toBe("succeeded"); const afterRestore = await storedMeasurements(); @@ -650,6 +674,166 @@ test.describe("Backup and restore, through the settings surfaces", () => { ), ).toHaveCount(0); }); + + test("a full backup the operator restores brings the record back into a fresh account @critical", async ({ + page, + browser, + }, testInfo) => { + // The second account: a person with a record of their own, who downloads + // the file the export card offers. Its jar is its own; the act below + // gives it a session that needs a re-proof, like the admin's. + const person = criticalAccount("restore", testInfo); + await resetCriticalAccount(person, [ + "DELETE FROM measurement_rollups WHERE user_id = $1", + "DELETE FROM measurements WHERE user_id = $1", + "DELETE FROM mood_entry_rollups WHERE user_id = $1", + "DELETE FROM mood_entries WHERE user_id = $1", + "DELETE FROM rate_limits WHERE key = 'export:' || $1", + ]); + + const context = await browser.newContext({ + storageState: person.storageStatePath, + baseURL: testInfo.project.use.baseURL, + }); + let backupPath = ""; + let written: { weightId: string; moodId: string }; + try { + const own = await context.newPage(); + const endOwnSession = await useStaleSession(own, person.username); + try { + await own.goto("/measurements", { waitUntil: "domcontentloaded" }); + await expect(own.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({ + timeout: 30_000, + }); + written = await own.evaluate(async (weightKg: number) => { + const post = async (path: string, body: unknown) => { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = (await res.json()) as { data: { id: string } | null }; + if (!res.ok || !json.data) { + throw new Error(`${path} answered ${res.status}`); + } + return json.data.id; + }; + const at = new Date(Date.now() - 90 * 60_000).toISOString(); + return { + weightId: await post("/api/measurements", { + type: "WEIGHT", + value: weightKg, + measuredAt: at, + }), + moodId: await post("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: at, + }), + }; + }, FRESH_ACCOUNT_WEIGHT_KG); + + await own.goto("/settings/export", { waitUntil: "domcontentloaded" }); + await expect(own.getByTestId("export-card-full-backup")).toBeVisible({ + timeout: 30_000, + }); + const download = own.waitForEvent("download"); + await actWithReproof(own, person.password, () => + own.getByTestId("export-action-full-backup").click(), + ); + // Kept past the context: a download's own file goes with it. + backupPath = testInfo.outputPath("full-backup.json"); + await (await download).saveAs(backupPath); + } finally { + await endOwnSession(); + } + } finally { + await context.close(); + } + + // ── the account is gone, and comes back empty under the same id ────── + // What a new host is to the runbook (`docs/ops/backup-restore.md`): the + // backup names its owner by id, so the account is recreated under it + // before the upload. A browser cannot express this; it is the one act + // here that goes to Postgres. + await recreateCriticalAccount(person); + expect(await accountRowCounts(person)).toEqual({ + measurements: 0, + moodEntries: 0, + }); + + // ── the operator uploads the file and restores it ─────────────────── + await openBackupsConsole(page); + const uploaded = page.waitForResponse( + (res) => + new URL(res.url()).pathname === "/api/admin/backups/upload" && + res.request().method() === "POST" && + res.status() !== 401, + ); + await actWithReproof(page, E2E_BACKUP_ADMIN.password, () => + page + .locator('input[type="file"][accept*="application/gzip"]') + .setInputFiles(backupPath), + ); + const upload = await uploaded; + expect(upload.status(), (await upload.text()).slice(0, 400)).toBe(201); + const uploadId = ((await upload.json()) as { data: { id: string } }).data + .id; + await expect(snapshotRow(page, uploadId)).toHaveCount(1, { + timeout: 30_000, + }); + await awaitRestore(page, await restoreThroughDialog(page, uploadId)); + + // ── and the person signs in to their record ────────────────────────── + const back = await browser.newContext({ + baseURL: testInfo.project.use.baseURL, + }); + try { + await clearLoginBuckets(); + const login = await back.request.post("/api/auth/login", { + data: { email: person.username, password: person.password }, + }); + expect(login.status()).toBe(200); + const signedIn = await back.newPage(); + + // The same rows, under their own ids: a restore, not a re-entry. + const measurements = ( + (await (await signedIn.request.get("/api/measurements")).json()) as { + data: { measurements: Array<{ id: string; value: number }> }; + } + ).data.measurements; + expect(measurements).toEqual([ + expect.objectContaining({ + id: written.weightId, + value: FRESH_ACCOUNT_WEIGHT_KG, + }), + ]); + const moods = ( + (await (await signedIn.request.get("/api/mood-entries")).json()) as { + data: { entries: Array<{ id: string; mood: string }> }; + } + ).data.entries; + expect(moods).toEqual([ + expect.objectContaining({ id: written.moodId, mood: "OKAY" }), + ]); + + await signedIn.goto("/measurements", { waitUntil: "domcontentloaded" }); + await expect( + signedIn + .locator( + '[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' + + `[data-measurement-value="${FRESH_ACCOUNT_WEIGHT_KG}"]`, + ) + .first(), + ).toBeVisible({ timeout: 30_000 }); + } finally { + await back.close(); + } + + // The admin's own record is untouched by a restore aimed at somebody else. + expect( + (await storedMeasurements()).filter((m) => m.deletedAt === null), + ).toHaveLength(2); + }); }); test.describe("A read-level delegate inside the record", () => { diff --git a/e2e/setup/critical-fixture.ts b/e2e/setup/critical-fixture.ts index 8601a6e1d..e7ec87b76 100644 --- a/e2e/setup/critical-fixture.ts +++ b/e2e/setup/critical-fixture.ts @@ -35,6 +35,10 @@ export const CRITICAL_JOURNEYS = [ "labs", "wellbeing", "settings", + // The fresh account a full backup is imported into. The backup journey + // runs in the desktop project only (`playwright.config.ts` says why), so + // only the desktop account of this pair is ever written. + "restore", ] as const; export type CriticalJourney = (typeof CRITICAL_JOURNEYS)[number]; @@ -174,3 +178,76 @@ export async function clearLoginBuckets(): Promise { await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`); }); } + +/** + * Delete a critical account and create it again, empty, under the same id, + * username and password. The backup journey restores a person's file into + * the account the way the runbook brings one back on a new host, where the + * file names its owner by id; a browser cannot delete and recreate a row + * under a chosen id, so this is the fixture's act, not the journey's. + */ +export async function recreateCriticalAccount( + account: CriticalAccount, +): Promise { + await withPool(async (pool) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const res = await client.query<{ + id: string; + password_hash: string; + module_preferences_json: unknown; + }>( + `SELECT id, password_hash, module_preferences_json + FROM users WHERE username = $1`, + [account.username], + ); + const row = res.rows[0]; + if (!row) { + throw new Error(`[critical-fixture] ${account.username} not seeded`); + } + await client.query("DELETE FROM users WHERE id = $1", [row.id]); + await client.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, 'USER', NOW(), NOW(), NOW(), true, $5::jsonb)`, + [ + row.id, + account.username, + account.email, + row.password_hash, + JSON.stringify(row.module_preferences_json), + ], + ); + await client.query("COMMIT"); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + }); +} + +/** How many measurements and mood entries a critical account holds. */ +export async function accountRowCounts( + account: CriticalAccount, +): Promise<{ measurements: number; moodEntries: number }> { + return withPool(async (pool) => { + const res = await pool.query<{ measurements: string; mood: string }>( + `SELECT + (SELECT count(*) FROM measurements m + JOIN users u ON u.id = m.user_id WHERE u.username = $1) + AS measurements, + (SELECT count(*) FROM mood_entries e + JOIN users u ON u.id = e.user_id WHERE u.username = $1) AS mood`, + [account.username], + ); + return { + measurements: Number(res.rows[0]?.measurements ?? 0), + moodEntries: Number(res.rows[0]?.mood ?? 0), + }; + }); +} diff --git a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts index 6486deaf0..983d71d8b 100644 --- a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts +++ b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts @@ -50,7 +50,7 @@ const E2E = join(process.cwd(), "e2e"); * somebody should see here. It counts title literals in the source, so a * title written once inside a loop counts once however many tests it names. */ -const CRITICAL_TEST_FLOOR = 15; +const CRITICAL_TEST_FLOOR = 16; /** A test whose title literal carries the tag, however the call is spelled. */ const CRITICAL_TEST =