diff --git a/e2e/backup-restore-journey.spec.ts b/e2e/backup-restore-journey.spec.ts new file mode 100644 index 000000000..e54acbbcd --- /dev/null +++ b/e2e/backup-restore-journey.spec.ts @@ -0,0 +1,619 @@ +/** + * A record, a snapshot of it, and the snapshot put back — one journey. + * + * The script this file is checked against: + * + * 1. an account records readings across three domains — two measurements, a + * medication with one dose, a mood entry — and files a small PDF in the + * vault; + * 2. it downloads its own record as the passphrase-sealed archive the export + * card offers, and the bytes are the `HLX1` envelope + * `src/lib/export/passphrase-archive.ts` documents, not a JSON file with + * a new extension; + * 3. an admin takes a snapshot from the backups console, waits for the + * pg-boss pass by asking the console's own endpoint what it holds, and + * downloads the stored copy; + * 4. the account loses a reading and a dose, gains a reading the snapshot + * never saw, and the snapshot is restored over it from the same console; + * 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. + * + * 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 + * nothing, and a delegate holding whole-record READ inside the account sees no + * backup surface at all and is refused by every route behind it. + * + * 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 + * 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`. + * + * 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 + * 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 { + BACKUP_DELEGATE_STORAGE_STATE_PATH, + BACKUP_STORAGE_STATE_PATH, + E2E_BACKUP_ADMIN, +} from "./setup/global-setup"; +import { + backupAccountId, + resetBackupJourney, + storedIntakeIds, + storedMeasurements, + storeTamperedCopy, +} from "./setup/backup-journey-fixture"; +import { expect, test } from "./setup/test"; + +test.describe.configure({ mode: "serial" }); + +/** + * The pass backs up every account in the database, the restore rewrites a + * dozen tables in one transaction, and the archive runs Argon2id at the OWASP + * cost. None of that fits the suite's 30-second default on a loaded runner. + */ +test.setTimeout(180_000); + +/** Long enough to clear the export card's own 12-character minimum. */ +const ARCHIVE_PASSPHRASE = "e2e-backup-journey-passphrase"; + +/** The reading the journey deletes and expects back, by value and by id. */ +const RESTORED_WEIGHT_KG = 81.4; +/** The reading nothing touches — a restore that wiped it would be a defect. */ +const UNTOUCHED_PULSE_BPM = 58; +/** The reading that arrives AFTER the snapshot and must not survive it. */ +const POST_SNAPSHOT_PULSE_BPM = 66; + +const MEDICATION_NAME = "E2E Backup Tablet"; + +/** The console's row list is painted, or its honest empty state is. */ +const BACKUPS_SETTLED = '[data-slot="backup-rows"], [data-slot="empty-state"]'; +/** The measurements list has finished its read, either way. */ +const MEASUREMENTS_SETTLED = + '[data-slot="measurement-rows"], [data-slot="empty-state"]'; + +/** One stored snapshot as the console's own endpoint reports it. */ +interface ConsoleRow { + id: string; + username: string; + type: string; + sizeBytes: number; + createdAt: string; +} + +interface SeededRecord { + weightId: string; + intakeId: string; + medicationId: string; + documentId: string; +} + +/** What the journey wrote, once the first act has written it. */ +let seeded: SeededRecord | null = null; +/** The stored snapshot the first act takes; every later act addresses it. */ +let snapshotId: string | null = null; + +function requireSeeded(): SeededRecord { + if (!seeded) throw new Error("the record was never seeded"); + return seeded; +} + +function requireSnapshot(): string { + if (!snapshotId) throw new Error("no snapshot was taken"); + return snapshotId; +} + +/** + * Write the record through the app's own routes, from the page's origin. + * + * The page's `fetch` rather than a Playwright request context, for the reason + * `glucose-journey.spec.ts` gives: a pooled context can hand a POST to a + * keep-alive socket the server has already closed, and the retry lands as a + * second row. + */ +function seedRecord(page: Page): Promise { + return page.evaluate( + async ([weightKg, pulseBpm, medicationName]: [number, number, string]) => { + const post = async (path: string, body: unknown): Promise => { + 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; + error: string | null; + }; + if (!res.ok || !json.data) { + throw new Error(`${path} answered ${res.status}: ${json.error}`); + } + return json.data.id; + }; + const at = (minutesAgo: number) => + new Date(Date.now() - minutesAgo * 60_000).toISOString(); + + const weightId = await post("/api/measurements", { + type: "WEIGHT", + value: weightKg, + measuredAt: at(180), + }); + await post("/api/measurements", { + type: "PULSE", + value: pulseBpm, + measuredAt: at(170), + }); + const medicationId = await post("/api/medications", { + name: medicationName, + dose: "5 mg", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }); + const intakeId = await post(`/api/medications/${medicationId}/intake`, { + takenAt: at(160), + }); + await post("/api/mood-entries", { + mood: "GUT", + note: "backup journey", + moodLoggedAt: at(150), + }); + + // The vault, through the same multipart body the upload card sends. A + // real minimal PDF: the route classifies by magic bytes and never trusts + // the wire content-type, so bytes that only claim to be one are refused. + const pdf = new TextEncoder().encode( + "%PDF-1.4\n%e2e-backup-journey\n" + + "1 0 obj<>endobj\n" + + "2 0 obj<>endobj\n" + + "3 0 obj<>endobj\n" + + "xref\n0 4\ntrailer<>\n%%EOF\n", + ); + const form = new FormData(); + form.append( + "file", + new File([pdf], "backup-journey.pdf", { type: "application/pdf" }), + ); + form.append("title", "Backup journey probe"); + const docRes = await fetch("/api/documents/inbound", { + method: "POST", + body: form, + }); + const docJson = (await docRes.json()) as { + data: { id: string } | null; + error: string | null; + }; + if (!docRes.ok || !docJson.data) { + throw new Error( + `/api/documents/inbound answered ${docRes.status}: ${docJson.error}`, + ); + } + + return { weightId, medicationId, intakeId, documentId: docJson.data.id }; + }, + [RESTORED_WEIGHT_KG, UNTOUCHED_PULSE_BPM, MEDICATION_NAME] as [ + number, + number, + string, + ], + ); +} + +/** This account's scheduled snapshot as the console's endpoint reports it. */ +function readConsoleRow(page: Page): Promise { + return page.evaluate(async (username: string) => { + const res = await fetch("/api/admin/backups", { credentials: "include" }); + const body = (await res.json()) as { + data: { rows: ConsoleRow[] } | null; + }; + const mine = (body.data?.rows ?? []).filter( + (row) => row.username === username && row.type === "WEEKLY_AUTO", + ); + return mine[0] ?? null; + }, E2E_BACKUP_ADMIN.username); +} + +/** Open the backups console and wait for its row list to settle. */ +async function openBackupsConsole(page: Page): Promise { + await page.goto("/admin/backups", { waitUntil: "domcontentloaded" }); + await expect(page.locator(BACKUPS_SETTLED).first()).toBeVisible({ + timeout: 30_000, + }); +} + +/** + * The console's row for one snapshot, at whichever layout is painting. + * + * Both the wide table and the narrow card list are in the tree — only CSS + * hides one — so the `:visible` filter is what keeps this a single element + * rather than a pair. + */ +function snapshotRow(page: Page, id: string) { + return page.locator(`[data-backup-id="${id}"]:visible`); +} + +/** Drive the typed-confirmation dialog on one row and hand back the answer. */ +async function restoreThroughDialog(page: Page, id: string) { + const answered = page.waitForResponse( + (response) => + response.url().includes(`/api/admin/backups/${id}/restore`) && + response.request().method() === "POST", + ); + await snapshotRow(page, id).getByTestId("backup-restore-trigger").click(); + await page.getByTestId("backup-restore-prompt").fill("RESTORE"); + await page.getByTestId("backup-restore-confirm").click(); + return answered; +} + +test.describe("Backup and restore, through the settings surfaces", () => { + test.use({ storageState: BACKUP_STORAGE_STATE_PATH }); + + test.beforeAll(async () => { + seeded = null; + snapshotId = null; + await resetBackupJourney(); + }); + + test("a record is written, sealed into an HLX1 archive, and snapshotted", async ({ + page, + }) => { + await page.goto("/measurements", { waitUntil: "domcontentloaded" }); + await expect(page.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({ + timeout: 30_000, + }); + seeded = await seedRecord(page); + const record = seeded; + + // On the list, by the number the row carries rather than by the text the + // formatter prints for whoever is looking. + await page.reload({ waitUntil: "domcontentloaded" }); + await expect( + page + .locator( + '[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' + + `[data-measurement-value="${RESTORED_WEIGHT_KG}"]`, + ) + .first(), + ).toBeVisible({ timeout: 30_000 }); + + // ── the account's own archive ──────────────────────────────────────── + await page.goto("/settings/export", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("export-card-full-backup")).toBeVisible({ + timeout: 30_000, + }); + await page.getByTestId("export-full-backup-encrypt").click(); + await page + .getByTestId("export-full-backup-passphrase") + .fill(ARCHIVE_PASSPHRASE); + const archiveDownload = page.waitForEvent("download"); + await page.getByTestId("export-action-full-backup").click(); + const bytes = await readFile(await (await archiveDownload).path()); + + // The header `passphrase-archive.ts` documents, field by field: magic, + // version, KDF id, then the Argon2 cost the archive carries so a later + // cost bump still opens it. Asserted as bytes, because "a file arrived" + // is equally true of plaintext JSON under a new extension. + expect(bytes.subarray(0, 4).toString("ascii")).toBe("HLX1"); + expect(bytes[4], "HLX1 version").toBe(0x01); + expect(bytes[5], "KDF id — Argon2id").toBe(0x01); + expect(bytes.readUInt32BE(6), "Argon2 memory cost, KiB").toBe(19456); + expect(bytes.readUInt32BE(10), "Argon2 time cost").toBe(2); + expect(bytes[14], "Argon2 parallelism").toBe(1); + expect(bytes[15], "salt length").toBe(16); + // A record carrying five sections is not a header and an auth tag. + expect(bytes.byteLength).toBeGreaterThan(1024); + + // ── the instance's own snapshot ────────────────────────────────────── + await openBackupsConsole(page); + const clickedAt = Date.now(); + await page.getByTestId("backup-run-now").first().click(); + + // The pass runs on pg-boss inside this process, so the console cannot know + // when it finished — it can only be asked. Poll the endpoint the page + // itself reads until this account's stored copy is newer than the click. + // No sleep: a fixed wait would be a guess about a queue. + const found: { row: ConsoleRow | null } = { row: null }; + await expect + .poll( + async () => { + const row = await readConsoleRow(page); + const fresh = + row !== null && + new Date(row.createdAt).getTime() >= clickedAt - 1_000; + if (fresh) found.row = row; + return fresh; + }, + { + timeout: 120_000, + intervals: [500, 1_000, 2_000], + message: "the data-backup pass never stored a copy of this account", + }, + ) + .toBe(true); + + const row = found.row; + if (!row) throw new Error("the poll reported a row it did not keep"); + expect( + row.sizeBytes, + "a stored copy of a record is not empty", + ).toBeGreaterThan(512); + snapshotId = row.id; + + // The console paints it, and the row's own actions are on screen. + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(snapshotRow(page, row.id)).toHaveCount(1, { timeout: 30_000 }); + await expect( + snapshotRow(page, row.id).getByTestId("backup-restore-trigger"), + ).toBeVisible(); + + // ── what the stored copy actually carries ──────────────────────────── + const snapshotDownload = page.waitForEvent("download"); + await snapshotRow(page, row.id).getByTestId("backup-download").click(); + const payload = JSON.parse( + await readFile(await (await snapshotDownload).path(), "utf8"), + ) as { + measurements: Array<{ id: string }>; + intakeEvents: Array<{ id: string }>; + moodEntries: unknown[]; + documents: Array<{ id: string; contentEncrypted?: string }>; + }; + + expect(payload.measurements.map((m) => m.id)).toContain(record.weightId); + expect(payload.intakeEvents.map((e) => e.id)).toContain(record.intakeId); + expect(payload.moodEntries).toHaveLength(1); + expect(payload.documents.map((d) => d.id)).toContain(record.documentId); + // A disaster-recovery copy carries document content as ciphertext, and the + // restore refuses a file that brought only metadata — so an empty field + // here is a snapshot nobody could restore. + expect(payload.documents[0]?.contentEncrypted ?? "").not.toBe(""); + }); + + test("a stored copy with one flipped byte is refused and changes nothing", async ({ + page, + }) => { + const before = await storedMeasurements(); + const tamperedId = await storeTamperedCopy(requireSnapshot()); + + await openBackupsConsole(page); + const response = await restoreThroughDialog(page, tamperedId); + + // The envelope is AES-256-GCM and its tag covers every ciphertext byte, so + // the flip is caught before the transaction opens. 500 with this sentence + // is what the route answers on a payload it could not decrypt. + expect(response.status()).toBe(500); + expect(await response.json()).toMatchObject({ + data: null, + error: "Failed to decrypt backup payload", + }); + + // A refusal that still wrote something would be worse than no refusal. + expect(await storedMeasurements()).toEqual(before); + }); + + test("the snapshot puts back what was deleted and drops what came after", async ({ + page, + }) => { + const record = requireSeeded(); + + await page.goto("/measurements", { waitUntil: "domcontentloaded" }); + await expect(page.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({ + timeout: 30_000, + }); + + // One reading and one dose go, and a reading the snapshot never saw + // arrives. Both deletions are tombstones — the app soft-deletes a reading + // and a dose — so "back" has to mean live again, not merely present. + const laterId = await page.evaluate( + async ([weightId, medicationId, intakeId, pulse]: [ + string, + string, + string, + number, + ]) => { + const del = async (path: string) => { + const res = await fetch(path, { method: "DELETE" }); + if (!res.ok) throw new Error(`${path} answered ${res.status}`); + }; + await del(`/api/measurements/${weightId}`); + await del(`/api/medications/${medicationId}/intake/${intakeId}`); + + const res = await fetch("/api/measurements", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "PULSE", + value: pulse, + measuredAt: new Date(Date.now() - 60_000).toISOString(), + }), + }); + const body = (await res.json()) as { data: { id: string } | null }; + if (!res.ok || !body.data) { + throw new Error(`/api/measurements answered ${res.status}`); + } + return body.data.id; + }, + [ + record.weightId, + record.medicationId, + record.intakeId, + POST_SNAPSHOT_PULSE_BPM, + ] as [string, string, string, number], + ); + + const beforeRestore = await storedMeasurements(); + expect( + beforeRestore.find((m) => m.id === record.weightId)?.deletedAt, + "the reading really is tombstoned before the restore", + ).not.toBeNull(); + expect(beforeRestore.map((m) => m.id)).toContain(laterId); + + await openBackupsConsole(page); + const response = await restoreThroughDialog(page, requireSnapshot()); + expect(response.status()).toBe(200); + + const afterRestore = await storedMeasurements(); + + // Identity, not merely equivalence. The payload carries each row's own id, + // so a restore that re-created the reading under a fresh one would have + // written a different record that happens to read the same. + const weight = afterRestore.find((m) => m.id === record.weightId); + expect( + weight, + "the deleted reading is back under its own id", + ).toBeDefined(); + expect(weight?.value).toBe(RESTORED_WEIGHT_KG); + expect( + weight?.deletedAt, + "and it is live, not still tombstoned", + ).toBeNull(); + + const dose = (await storedIntakeIds()).find( + (e) => e.id === record.intakeId, + ); + expect(dose, "the deleted dose is back under its own id").toBeDefined(); + expect(dose?.deletedAt).toBeNull(); + + // The reading nothing touched is still there. Without this, an emptied + // account would satisfy every assertion above about the tombstones. + expect( + afterRestore.filter((m) => m.value === UNTOUCHED_PULSE_BPM), + ).toHaveLength(1); + + // And the reading that arrived after the snapshot is gone. + // `docs/ops/backup-restore.md` says the restore "replaces the account's + // data tables", and the route's own words are "one transaction replaces + // every serialized owner-scoped class". Replacing is not merging: whoever + // restores yesterday's copy loses today's readings, and that is the + // documented behaviour rather than an accident of the implementation. + expect(afterRestore.map((m) => m.id)).not.toContain(laterId); + + // The list agrees with the database. + await page.goto("/measurements", { waitUntil: "domcontentloaded" }); + await expect( + page + .locator( + '[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' + + `[data-measurement-value="${RESTORED_WEIGHT_KG}"]`, + ) + .first(), + ).toBeVisible({ timeout: 30_000 }); + await expect( + page.locator( + '[data-testid="measurement-row"][data-measurement-type="PULSE"]' + + `[data-measurement-value="${POST_SNAPSHOT_PULSE_BPM}"]`, + ), + ).toHaveCount(0); + }); +}); + +test.describe("A read-level delegate inside the record", () => { + test.use({ storageState: BACKUP_DELEGATE_STORAGE_STATE_PATH }); + + test("sees no backup surface, and every route behind it refuses", async ({ + page, + }) => { + // Switched in through the switcher a person uses, not by a stamp written + // behind the app's back. The switcher is a slice of the user menu, so the + // menu opens first — the same two steps `v137-record-session-fence.spec.ts` + // takes, and the trigger's accessible name is the only handle it has. + await page.goto("/", { waitUntil: "domcontentloaded" }); + const userMenu = page.getByRole("button", { name: "User menu" }).first(); + await expect(userMenu).toBeVisible({ timeout: 30_000 }); + await userMenu.click(); + const trigger = page.locator('[data-slot="account-switcher-trigger"]'); + await expect(trigger).toBeVisible({ timeout: 30_000 }); + await trigger.click(); + const entry = page.locator( + '[data-slot="account-switcher-entry"]' + + `[data-account-username="${E2E_BACKUP_ADMIN.username}"]`, + ); + await expect(entry).toBeVisible({ timeout: 30_000 }); + await entry.click(); + + // Wait for the shell to say the switch landed, not merely that the click + // did. The stamp is written on the session row by a request the click + // fires, so navigating straight afterwards can load the next page under + // the delegate's OWN record and find every actor surface exactly where it + // belongs — a pass that says nothing. The banner names the record and the + // affordance it resolved, so this waits for both; `view` is what + // `resolveRecordPresentation` calls a READ grant, and a grant that had + // widened would paint `view-and-add` here instead of matching. + await expect( + page.locator( + '[data-slot="shared-record-banner"]' + + `[data-account-id="${await backupAccountId()}"]` + + '[data-access-level="view"][data-record-kind="shared"]', + ), + ).toBeVisible({ timeout: 30_000 }); + + // Settings destinations that belong to the ACTOR do not mount inside + // somebody else's record: the shell paints the not-covered panel in place + // of the whole section. That panel is the positive half of this check — + // without it, an absent export card would only mean the page had not + // finished painting. + await page.goto("/settings/export", { waitUntil: "domcontentloaded" }); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("export-card-full-backup")).toHaveCount(0); + await expect(page.getByTestId("export-action-full-backup")).toHaveCount(0); + + // The console is not a place a non-admin lands: `AuthShell` sends them to + // the dashboard and the admin frame paints nothing on the way. + await page.goto("/admin/backups", { waitUntil: "domcontentloaded" }); + await expect(page).toHaveURL(/\/$/, { timeout: 30_000 }); + await expect(page.getByTestId("backup-run-now")).toHaveCount(0); + await expect(page.getByTestId("backup-restore-trigger")).toHaveCount(0); + + // And the routes underneath, asked from the delegate's own session. + // `requireAdmin()` is cookie-only and refuses before the row is read, so + // the restore below names the REAL snapshot: a 403 on an id that does not + // exist would prove nothing about the one that does. + const refusals = await page.evaluate(async (snapshot: string) => { + const call = async (path: string, init?: RequestInit) => { + const res = await fetch(path, init); + const body = (await res.json()) as { error: string | null }; + return { status: res.status, error: body.error }; + }; + return { + list: await call("/api/admin/backups"), + run: await call("/api/admin/backups/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + restore: await call(`/api/admin/backups/${snapshot}/restore`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }), + }; + }, requireSnapshot()); + + // Each refusal is named, not merely counted. A switched session is also + // fenced off non-allowlisted endpoints (`sharing.not_permitted`), and that + // fence answers 403 too — so a bare status check would stay green through + // an admin gate that had been removed, with the second guard quietly + // covering for it. The sentence says which boundary spoke. + for (const refusal of [ + refusals.list, + refusals.run, + refusals.restore, + ] as const) { + expect(refusal.status).toBe(403); + expect(refusal.error).toBe("Admin access required"); + } + + // The refused restore left the owner's record where the previous act put + // it, rather than half-applying and reporting a refusal. + const owner = await storedMeasurements(); + expect(owner.filter((m) => m.value === RESTORED_WEIGHT_KG)).toHaveLength(1); + }); +}); diff --git a/e2e/setup/backup-journey-fixture.ts b/e2e/setup/backup-journey-fixture.ts new file mode 100644 index 000000000..0ecce52b7 --- /dev/null +++ b/e2e/setup/backup-journey-fixture.ts @@ -0,0 +1,205 @@ +/** + * Database-side fixture for the backup/restore journey. + * + * Everything the journey WRITES it writes through the browser: the readings, + * the medication and its dose, the mood entry, the document, the snapshot, the + * deletions and the restore all ride the app's own routes. What lives here is + * only what a browser has no way to express — clearing the account back to a + * known state so the file survives `--repeat-each`, and corrupting one stored + * copy so the restore path has an authenticated envelope to refuse. + * + * Raw SQL through `pg` for the same reason `global-setup.ts` uses it: + * Playwright's TS loader does not handle the generated Prisma client's + * `import.meta.url` indirection. + */ +import pg from "pg"; + +import { E2E_BACKUP_ADMIN, E2E_BACKUP_DELEGATE } from "./global-setup"; + +/** One row of the account's readings, as the journey needs to compare it. */ +export interface StoredMeasurement { + id: string; + type: string; + value: number; + deletedAt: string | null; +} + +async function withPool(fn: (pool: pg.Pool) => Promise): Promise { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error( + "DATABASE_URL is unset — the backup journey needs the same database the app is running against.", + ); + } + const pool = new pg.Pool({ connectionString }); + try { + return await fn(pool); + } finally { + await pool.end(); + } +} + +/** The journey account's id, resolved by username rather than assumed. */ +export async function backupAccountId(): Promise { + return withPool(async (pool) => { + const { rows } = await pool.query<{ id: string }>( + `SELECT id FROM users WHERE username = $1`, + [E2E_BACKUP_ADMIN.username], + ); + if (rows.length !== 1) { + throw new Error( + `expected exactly one ${E2E_BACKUP_ADMIN.username} row, found ${rows.length}`, + ); + } + return rows[0].id; + }); +} + +/** + * Put the account back where the journey expects to find it. + * + * Deletes only what this file's account holds, and only the classes the + * journey writes. The stored snapshots go too: a row left behind from the + * previous repetition is indistinguishable from the one this repetition is + * about to take, and the whole journey turns on picking the right one. + * + * The rate buckets are cleared for the same reason `global-setup.ts` clears + * the login bucket: the manual-run and upload endpoints allow three calls a + * minute each and the export bucket ten an hour, which are the right + * production numbers and fewer than three repetitions of this file need. + */ +export async function resetBackupJourney(): Promise { + await withPool(async (pool) => { + const { rows } = await pool.query<{ id: string }>( + `SELECT id FROM users WHERE username = ANY($1)`, + [[E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username]], + ); + const ids = rows.map((row) => row.id); + + // Intake events before medications: the child rows carry the foreign key. + await pool.query( + `DELETE FROM medication_intake_events WHERE user_id = ANY($1)`, + [ids], + ); + await pool.query(`DELETE FROM medications WHERE user_id = ANY($1)`, [ids]); + await pool.query(`DELETE FROM measurements WHERE user_id = ANY($1)`, [ids]); + await pool.query(`DELETE FROM mood_entries WHERE user_id = ANY($1)`, [ids]); + await pool.query(`DELETE FROM inbound_documents WHERE user_id = ANY($1)`, [ + ids, + ]); + await pool.query(`DELETE FROM data_backups WHERE user_id = ANY($1)`, [ids]); + + // The delegate is switched back out of the record; the refusal control + // switches in and a stamp that outlived a repetition would start the next + // one already inside. + await pool.query( + `UPDATE sessions SET acting_as_user_id = NULL WHERE user_id = ANY($1)`, + [ids], + ); + + for (const id of ids) { + await pool.query( + `DELETE FROM rate_limits + WHERE key IN ($1, $2, $3)`, + [ + `admin-backups-run:${id}`, + `admin-backups-upload:${id}`, + `export:${id}`, + ], + ); + } + }); +} + +/** + * Copy one stored snapshot and flip a single character of its ciphertext. + * + * `DataBackup.data` is `~hlgcm1..` + * (see `src/lib/crypto.ts`), so the flip lands four fifths of the way in — + * well past the header, inside the ciphertext the tag covers. The replacement + * stays inside the base64 alphabet, so what the restore meets is a + * well-formed envelope whose authentication tag no longer matches, and not a + * decoder error standing in for one. + * + * A COPY rather than the row itself: the journey still needs the good + * snapshot afterwards, and a control that destroys its own subject can only + * be run once. + */ +export async function storeTamperedCopy(backupId: string): Promise { + return withPool(async (pool) => { + const { rows } = await pool.query<{ + user_id: string; + data: string; + }>(`SELECT user_id, data FROM data_backups WHERE id = $1`, [backupId]); + if (rows.length !== 1) { + throw new Error(`no stored backup ${backupId} to copy`); + } + const stored = rows[0].data; + const at = Math.floor(stored.length * 0.8); + const original = stored[at]; + if (!/[A-Za-z0-9+/]/.test(original)) { + throw new Error( + `the byte at ${at} of the stored blob is '${original}', not ciphertext base64`, + ); + } + const flipped = original === "A" ? "B" : "A"; + const tampered = stored.slice(0, at) + flipped + stored.slice(at + 1); + if (tampered === stored) { + throw new Error("the tamper changed nothing"); + } + + const { rows: created } = await pool.query<{ id: string }>( + `INSERT INTO data_backups (id, user_id, type, data, created_at) + VALUES ($1, $2, $3, $4, now()) + RETURNING id`, + [ + `c${Date.now().toString(36)}tampered${Math.floor(Math.random() * 1e6)}`, + rows[0].user_id, + `MANUAL_UPLOAD_${Date.now()}`, + tampered, + ], + ); + return created[0].id; + }); +} + +/** Every reading the account holds, tombstoned ones included. */ +export async function storedMeasurements(): Promise { + const userId = await backupAccountId(); + return withPool(async (pool) => { + const { rows } = await pool.query<{ + id: string; + type: string; + value: string; + deleted_at: Date | null; + }>( + `SELECT id, type, value, deleted_at + FROM measurements WHERE user_id = $1 ORDER BY measured_at ASC`, + [userId], + ); + return rows.map((row) => ({ + id: row.id, + type: row.type, + value: Number(row.value), + deletedAt: row.deleted_at ? row.deleted_at.toISOString() : null, + })); + }); +} + +/** Every dose the account holds, tombstoned ones included. */ +export async function storedIntakeIds(): Promise< + Array<{ id: string; deletedAt: string | null }> +> { + const userId = await backupAccountId(); + return withPool(async (pool) => { + const { rows } = await pool.query<{ id: string; deleted_at: Date | null }>( + `SELECT id, deleted_at FROM medication_intake_events + WHERE user_id = $1 ORDER BY scheduled_for ASC`, + [userId], + ); + return rows.map((row) => ({ + id: row.id, + deletedAt: row.deleted_at ? row.deleted_at.toISOString() : null, + })); + }); +} diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 9ee0f42ec..ddb8b16f1 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -71,6 +71,39 @@ export const E2E_OWNER = { */ export const E2E_OWNER_FULL_NAME = "Test Full Name"; +/** + * The backup/restore journey's own account. + * + * Its own account, and an administrative one, because the restore path is + * admin-only by construction (`requireAdmin()` is cookie-only) and because the + * journey is destructive on purpose: it deletes rows, restores an earlier + * snapshot over them, and asserts what came back. Pointing that at an account + * any other spec reads would make every one of them depend on where this + * journey happens to be. + */ +export const E2E_BACKUP_ADMIN = { + email: "e2e-backup-admin@healthlog.test", + username: "e2e-backup-admin", + password: "Hw3!Tq7vZm2xRb8L", + role: "ADMIN", +} as const; + +/** + * The read-level delegate on that account's record. + * + * The refusing half of the journey needs somebody who is inside the record and + * still has no business taking or replaying a copy of it. A separate account + * rather than one of the existing delegates: this one is granted READ over the + * whole record above, and reusing a delegate would change what the scoped + * journeys see. + */ +export const E2E_BACKUP_DELEGATE = { + email: "e2e-backup-delegate@healthlog.test", + username: "e2e-backup-delegate", + password: "Ld6!Yn4kW9pJc3Vt", + role: "USER", +} as const; + /** * v1.37.0 — the account that creates and administers managed profiles. * @@ -250,6 +283,23 @@ export const CROSS_TAB_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateCrossTab.json", ); +/** The backup/restore journey's jar, for the account it owns outright. */ +export const BACKUP_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateBackup.json", +); + +/** + * The read-level delegate's jar for that journey. Its own session row, like + * every other switching spec's — the refusal control switches into the + * journey's record, and the switch is stamped on the row rather than held in + * the tab. + */ +export const BACKUP_DELEGATE_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateBackupDelegate.json", +); + /** * The banner-geometry spec's jar, and the reason it exists is a release-shaped * one rather than a load-shaped one. @@ -647,7 +697,7 @@ export default async function globalSetup(config: FullConfig): Promise { WHERE key LIKE 'sharing:%'`, ); - // The login bucket, for the same reason. This setup signs in TEN times now + // The login bucket, for the same reason. This setup signs in TWELVE times now // (the shared jar, the owner, and one jar apiece for every spec that moves // a session's record selector), and the ceiling is five attempts per IP per // quarter-hour — so two local runs in a row would otherwise end with a 429 @@ -707,6 +757,70 @@ export default async function globalSetup(config: FullConfig): Promise { ["e2e-level-write"], ); + // ── the backup/restore journey's pair ──────────────────────────────── + // + // The account the journey owns, with the three modules its readings need + // switched on, and the delegate that holds READ over the whole record. + // Both are re-seeded here rather than in the spec so the journey never + // spends a login on a surface the setup already owns. + for (const account of [E2E_BACKUP_ADMIN, E2E_BACKUP_DELEGATE] as const) { + await pool.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, $5, $6, $6, $6, true, $7::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json, + totp_confirmed_at = NULL`, + [ + cuid(), + account.username, + account.email, + await hashPassword(account.password), + account.role, + now, + JSON.stringify({ + medications: true, + mood: true, + inboundDocuments: true, + }), + ], + ); + } + + // Grant hygiene before the grant, for the reason the sharing fixture + // states above: a grant and a switch stamp both outlive a run. + await pool.query( + `DELETE FROM account_grants + WHERE grantor_id IN (SELECT id FROM users WHERE username = ANY($1)) + OR grantee_id IN (SELECT id FROM users WHERE username = ANY($1))`, + [[E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username]], + ); + await pool.query( + `UPDATE sessions SET acting_as_user_id = NULL + WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1))`, + [[E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username]], + ); + // Whole-record READ: `scope_json` NULL is the un-narrowed grant, which is + // the strongest read a delegate can hold and therefore the honest subject + // for "and still cannot take or replay a copy". + await pool.query( + `INSERT INTO account_grants + (id, grantor_id, grantee_id, access, scope_json, invited_at, + accepted_at, created_at) + SELECT $1, owner.id, delegate.id, 'READ', NULL, $2, $2, $2 + FROM users owner, users delegate + WHERE owner.username = $3 AND delegate.username = $4`, + [cuid(), now, E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username], + ); + // Console (instead of structured logging) is intentional here — // global-setup runs outside the app's logging context, and the // line is useful when debugging a CI failure where the seed didn't @@ -727,7 +841,7 @@ export default async function globalSetup(config: FullConfig): Promise { * Log one account in, clearing the login bucket first. * * The ceiling is FIVE attempts per IP per quarter-hour and this setup now - * signs in ten times, so clearing once before the batch is no longer + * signs in twelve times, so clearing once before the batch is no longer * enough — the sixth would be answered by the fixture's own 429 rather * than by the product. Only the auth surfaces' buckets are touched, and * only between logins this setup is itself performing. @@ -766,6 +880,10 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SCOPE_DELEGATE, CROSS_TAB_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SEAM_BANNERS_STORAGE_STATE_PATH); + // The backup/restore journey's pair. + await capture(E2E_BACKUP_ADMIN, BACKUP_STORAGE_STATE_PATH); + await capture(E2E_BACKUP_DELEGATE, BACKUP_DELEGATE_STORAGE_STATE_PATH); + // v1.37.0 — the guardian's jar, and only then its second factor. // // Order is the whole of it. `requireFreshMfa` refuses an account with no diff --git a/playwright.config.ts b/playwright.config.ts index 4bd5961d6..7d7532fb3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -171,6 +171,11 @@ export default defineConfig({ // through stable data attributes, not a mobile layout, so it runs in // one project. "apple-health-import.spec.ts", + // The backup/restore journey, for both reasons already on this list + // at once: it restores a snapshot over its own account, so a second + // project would be two browsers rewriting one record's tables in + // parallel, and its last act switches a session into that record. + "backup-restore-journey.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 613f6799d..c65ea34c5 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -177,6 +177,7 @@ function RestoreRowDialog({