diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bbf7e832f..a5e1f056c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -19940,6 +19940,119 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /api/admin/backups/{id}/download: + get: + tags: + - Admin + summary: Download one stored backup as JSON + description: "Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment` + and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact + an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot + reach admin endpoints." + parameters: + - in: path + name: id + schema: + type: string + description: "`DataBackup.id`." + required: true + description: "`DataBackup.id`." + responses: + "200": + description: The decrypted backup document. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminBackupDocument" + "403": &a43 + description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": &a44 + description: No stored copy with this id. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "The stored copy could not be opened: either the key that wrote it is no longer in `ENCRYPTION_KEYS` (a + rotation that dropped the legacy entry too early), or the stored bytes are not the ones that were written. + `meta.errorCode` = `backup.payload.undecryptable`. Nothing was changed." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "500": + description: The copy decrypted and then failed schema validation — a document this instance wrote and cannot parse. + This one genuinely is a server fault, and is reported as one. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/admin/backups/{id}/restore: + post: + tags: + - Admin + summary: Restore one stored backup over its owner's record + description: "Replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file + carries. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the + rest of its class. The target is the account the snapshot was taken for, never the admin running it, and a + payload declaring a different owner than the stored row is refused. Admin session cookie required; Bearer tokens + cannot reach admin endpoints." + parameters: + - in: path + name: id + schema: + type: string + description: "`DataBackup.id`." + required: true + description: "`DataBackup.id`." + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminBackupRestoreRequest" + responses: + "200": + description: The restore ran. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminBackupRestoreResponse" + "400": + description: Body was not JSON. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": *a43 + "404": *a44 + "409": + description: Either the payload declares a different owner than the stored row, or a request under the same + `Idempotency-Key` is still in flight. Nothing was changed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: Body exceeds 64 KiB. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "The request or the file was refused before anything was written: `confirm` missing, the copy undecryptable + (`meta.errorCode` = `backup.payload.undecryptable`), the payload failing schema validation, an unsupported + `schemaVersion`, a section the file's own manifest says it carries and does not (`meta.errorCode` = + `backup.section.missing`), a metadata-only document entry, or an owner who no longer exists here." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/bugreport: post: security: [] @@ -29097,6 +29210,21 @@ components: the same plausibility range the interactive create paths use. `idempotencyKey` is an identity, not a windowed replay token: it is stored on the row forever and matched by equality, so a key that rotates per client launch never matches its own earlier row and every re-sync logs the dose again." + AdminBackupRestoreRequest: + type: object + properties: + confirm: + type: string + const: RESTORE + restoreInstanceSettings: + type: boolean + required: + - confirm + description: "`confirm` is the literal typed gate; anything else is refused with 422 and nothing is read. + `restoreInstanceSettings` defaults to false: the restore then replaces the snapshot owner's data tables and + leaves the singleton instance settings — registration, MFA requirement, default locale and timezone, module + availability, notification and AI configuration, document cap and quota — exactly as they are. Send `true` only + when rebuilding a host from a snapshot, because those settings apply to every account on the installation." MedicationTreatmentClass: type: string enum: @@ -68903,6 +69031,90 @@ components: - data - error additionalProperties: false + AdminBackupDocument: + type: object + propertyNames: + type: string + additionalProperties: {} + description: "The backup payload as the writer serialised it: `schemaVersion`, `exportedAt`, `userId`, and one array or + object per serialised class." + AdminBackupRestoreResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AdminBackupRestoreResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AdminBackupRestoreResult: + type: object + properties: + restored: + type: boolean + const: true + summary: + type: object + propertyNames: + type: string + additionalProperties: {} + skipped: + type: object + properties: + catalogueKeys: + type: array + items: + $ref: "#/components/schemas/AdminBackupRestoreSkipEntry" + links: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - catalogueKeys + - links + additionalProperties: false + cleared: + type: object + propertyNames: + type: string + additionalProperties: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - restored + - summary + - skipped + - cleared + additionalProperties: false + description: What the transaction wrote. `cleared` counts the rows each class held before it was rebuilt from the file, + `summary` counts what the file carried, and `skipped.links` is the number of links dropped because a catalogue + key in the file does not exist here — zero is the normal answer. + AdminBackupRestoreSkipEntry: + type: object + properties: + catalogue: + type: string + key: + type: string + links: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - catalogue + - key + - links + additionalProperties: false + description: One catalogue key the file referenced and this instance could not resolve, with the number of links it cost. __schema0: anyOf: - type: string diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 64e7854e9..2b6fe2af9 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -124,6 +124,24 @@ decompresses to a few hundred megabytes, and `NODE_OPTIONS=--max-old-space-size=2048` in front of the command is enough for a 445 000-measurement account. +### Restoring from the backups console + +An admin can restore a stored snapshot from the browser instead of the shell. +`/admin/backups` lists one row per account and per backup type: **Backup now** +enqueues the `data-backup` pass for every account on the instance, **Download** +decrypts one snapshot and hands back the JSON, and **Restore** opens a dialog +that first shows what the file carries and then requires the word `RESTORE` to +be typed before it will run. The restore targets the account the snapshot was +taken for, never the admin running it, and it refuses a file whose declared +owner does not match the stored row. Instance-wide settings ride along only +when you tick _Also restore instance settings_ in the dialog; leave it clear to +restore the account alone. + +A stored copy this instance can no longer open — the key that wrote it dropped +from `ENCRYPTION_KEYS` one rotation too early, or bytes that are not the ones +written — is refused by all three buttons with `422` and +`meta.errorCode = backup.payload.undecryptable`, before anything is read back. + ### What a backup deliberately does not carry Every credential-shaped row is left out, and this is not an oversight to fix: @@ -264,6 +282,16 @@ shape, and worse: a restore parses the whole document, so the read path needs several times the blob in heap. An operator restoring a very large account should give the container more memory for the duration. +### What a restore replaces + +A restore replaces the account's data tables; it does not merge into them. +Every row the file carries is written back under its original id, and every row +the account gained after the snapshot was taken — readings, doses, mood +entries, documents — is deleted with the rest of the class before the file is +read back. Restoring Monday's copy on Wednesday therefore costs the account +everything it recorded on Tuesday. If the current state is worth keeping, take +a fresh snapshot from the backups console before restoring the old one. + ## Monthly restore drill (automatic) Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` — diff --git a/e2e/backup-restore-journey.spec.ts b/e2e/backup-restore-journey.spec.ts new file mode 100644 index 000000000..ec8cb61bc --- /dev/null +++ b/e2e/backup-restore-journey.spec.ts @@ -0,0 +1,701 @@ +/** + * 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. + * + * One act reaches past this account, and by design: "Backup now" enqueues the + * `data-backup` pass, which writes a `WEEKLY_AUTO` row for EVERY account on the + * instance. The journey reads only its own row and deletes only its own rows, + * but it is not true that it touches nothing else. The restore is the other + * direction and is confined: the transaction is scoped to the snapshot's owner, + * and the instance-wide settings a disaster-recovery payload also carries are + * written only when the dialog's opt-in is ticked, which this file never ticks + * and asserts it never ticks. + * + * 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 { EXPORT_ARGON2_PARAMS } from "@/lib/export/passphrase-archive"; + +import { + BACKUP_DELEGATE_STORAGE_STATE_PATH, + BACKUP_STORAGE_STATE_PATH, + E2E_BACKUP_ADMIN, +} from "./setup/global-setup"; +import { + backupAccountId, + deleteStoredCopy, + 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; +/** The deliberately corrupted copy, held so the file can take it away again. */ +let tamperedCopyId: 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. This file runs in the desktop project alone (see the + * `chromium-mobile` ignore list in `playwright.config.ts`), so the table row is + * the one that answers; the card's identical attributes are what let the same + * handle hold if it ever runs narrow, and are not exercised here. + */ +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(); + // The instance-wide opt-in, left exactly where the dialog puts it. Asserted + // rather than merely not clicked: a default that flipped would hand this + // journey the power to rewrite the host's settings — the document size cap + // among them — for every other spec sharing the server. + await expect( + page.getByTestId("backup-restore-instance-settings"), + ).not.toBeChecked(); + 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; + tamperedCopyId = null; + await resetBackupJourney(); + }); + + test.afterAll(async () => { + if (tamperedCopyId) await deleteStoredCopy(tamperedCopyId); + tamperedCopyId = null; + }); + + 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. The attribute holds the + // DISPLAYED value, after the account's unit preference is applied — the + // same number as the stored one under this account's default metric + // preference, and deliberately not a claim about the stored row. The + // database-side assertions later in the file are that claim. + 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); + // The cost travels in the file so it may change, so it is asserted against + // the constant the writer uses rather than against today's numbers: a + // legitimate cost bump is a correct product change, not a red spec. + expect(bytes.readUInt32BE(6), "Argon2 memory cost, KiB").toBe( + EXPORT_ARGON2_PARAMS.memoryCost, + ); + expect(bytes.readUInt32BE(10), "Argon2 time cost").toBe( + EXPORT_ARGON2_PARAMS.timeCost, + ); + expect(bytes[14], "Argon2 parallelism").toBe( + EXPORT_ARGON2_PARAMS.parallelism, + ); + 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); + // The row this account had before the click, or none. The poll below asks + // "is the stored copy a different one", and the server's own clock is the + // only one both sides share — comparing a server timestamp against the + // runner's would pass on a stale row under any clock skew. + const previous = await readConsoleRow(page); + const enqueued = page.waitForResponse( + (response) => + response.url().includes("/api/admin/backups/run") && + response.request().method() === "POST", + ); + // The list is populated by now, so it is the card action that runs the + // pass; the empty state's CTA carries its own id. + await page.getByTestId("backup-run-now").click(); + // Named here rather than left to the poll: with no pg-boss instance in the + // process the route answers 503 and nothing is ever enqueued, and the poll + // would spend two minutes to report that the pass stored no copy — true, + // and about the wrong subsystem. 429 is the same story on a reset that + // stopped clearing the run bucket. + expect( + (await enqueued).status(), + "the manual pass was enqueued (503 = no worker in this process, 429 = rate limit)", + ).toBe(200); + + // 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 && + (previous === null || + row.id !== previous.id || + row.createdAt !== previous.createdAt); + 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.find((d) => d.id === record.documentId) + ?.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()); + // Dropped again as soon as the control is done with it, rather than at the + // next repetition's reset: until then it is a broken row in the console's + // list for anyone who opens `/admin/backups`. + tamperedCopyId = tamperedId; + + 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. 422 and + // `backup.payload.undecryptable` are the refusal the route documents — + // `docs/api/openapi.yaml`, and `docs/ops/backup-restore.md` for the + // operator reading it after a key rotation — rather than whatever the + // handler happened to answer. The code is asserted and not the sentence: + // the sentence is prose that may be reworded, the code is the contract. + expect(response.status()).toBe(422); + expect(await response.json()).toMatchObject({ + data: null, + meta: { errorCode: "backup.payload.undecryptable" }, + }); + + // 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" }); + // The dashboard, by path. `/\/$/` also matches an admin URL that merely + // ends in a slash, which is the one outcome this assertion exists to rule + // out. + await expect + .poll(() => new URL(page.url()).pathname, { timeout: 30_000 }) + .toBe("/"); + await expect(page.getByTestId("backup-run-now")).toHaveCount(0); + await expect(page.getByTestId("backup-run-now-empty")).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..4588c41d7 --- /dev/null +++ b/e2e/setup/backup-journey-fixture.ts @@ -0,0 +1,222 @@ +/** + * 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; + }); +} + +/** + * Drop one stored copy again. + * + * The tampered copy the refusal control writes is the journey's own litter: it + * is not deleted until the next repetition's `beforeAll`, and until then it + * sits in the console's list for anyone who opens `/admin/backups`. + */ +export async function deleteStoredCopy(backupId: string): Promise { + await withPool(async (pool) => { + await pool.query(`DELETE FROM data_backups WHERE id = $1`, [backupId]); + }); +} + +/** 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; + }>( + // `id` after the timestamp: the journey compares two whole arrays to + // prove a refusal changed nothing, and two readings sharing a timestamp + // could otherwise come back in either order and present as a change. + `SELECT id, type, value, deleted_at + FROM measurements WHERE user_id = $1 + ORDER BY measured_at ASC, id 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, id 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 9e9f8fe01..5babbdaef 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. * @@ -300,6 +333,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 doctor-report delegate journey's jar. Its own session row, for the reason * every switching spec has one: it opens somebody else's record to prove the @@ -795,7 +845,7 @@ export default async function globalSetup(config: FullConfig): Promise { WHERE key LIKE 'sharing:%'`, ); - // The login bucket, for the same reason. This setup signs in FOURTEEN times now + // The login bucket, for the same reason. This setup signs in SIXTEEN 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 @@ -855,6 +905,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 @@ -875,7 +989,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 fourteen times, so clearing once before the batch is no longer + * signs in sixteen 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. @@ -923,6 +1037,10 @@ export default async function globalSetup(config: FullConfig): Promise { // E2E_REPORT_OWNER: generating a report writes to the account row. await capture(E2E_REPORT_OWNER, REPORT_OWNER_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/messages/de.json b/messages/de.json index 78d23f259..fca6667b9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6924,7 +6924,7 @@ "backups": { "title": "Backups", "subtitle": "Wöchentliche Daten-Snapshots pro Nutzer in PostgreSQL.", - "description": "Jeder Nutzer hat einen rollierenden Snapshot pro Backup-Typ. Größen zeigen den verschlüsselten Blob — die Inhalte sind hier nicht herunterladbar.", + "description": "Jeder Nutzer hat einen rollierenden Snapshot pro Backup-Typ. Größen zeigen den verschlüsselten Blob. Download entschlüsselt den Snapshot für den anfragenden Admin; der Chiffretext bleibt in der Datenbank.", "runNow": "Jetzt sichern", "runEnqueued": "Backup-Job eingeplant", "runFailed": "Backup konnte nicht eingeplant werden", @@ -6959,7 +6959,9 @@ "restore": "Wiederherstellen", "restoreAria": "Backup für {username} wiederherstellen", "restoreTitle": "Aus Backup wiederherstellen", - "restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt. Falls im Snapshot enthalten, überschreiben instanzweite Einstellungen die Konfiguration der gesamten Installation.", + "restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt.", + "restoreInstanceSettingsLabel": "Instanzweite Einstellungen mitwiederherstellen", + "restoreInstanceSettingsHint": "Registrierung, MFA-Pflicht, Standardsprache, Modulverfügbarkeit, Benachrichtigungs- und KI-Konfiguration — aus dem Snapshot, gültig für alle auf dieser Installation. Leer lassen, um nur dieses Konto wiederherzustellen.", "restorePromptLabel": "Tippe RESTORE zum Fortfahren", "previewTitle": "Inhalt der Sicherung", "previewLoading": "Inhalt wird gelesen…", diff --git a/messages/en.json b/messages/en.json index d3500b9e6..5891d7ddf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6924,7 +6924,7 @@ "backups": { "title": "Backups", "subtitle": "Weekly per-user data snapshots stored in PostgreSQL.", - "description": "Each user gets one rolling snapshot per backup type. Sizes show the encrypted blob — payloads are not downloadable from this page.", + "description": "Each user gets one rolling snapshot per backup type. Sizes show the encrypted blob. Download decrypts the snapshot for the admin who asks; the ciphertext stays in the database.", "runNow": "Backup now", "runEnqueued": "Backup job enqueued", "runFailed": "Failed to enqueue backup", @@ -6959,7 +6959,9 @@ "restore": "Restore", "restoreAria": "Restore from backup for {username}", "restoreTitle": "Restore from backup", - "restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.", + "restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted.", + "restoreInstanceSettingsLabel": "Also restore instance settings", + "restoreInstanceSettingsHint": "Registration, MFA requirement, default locale, module availability, notification and AI configuration — taken from the snapshot and applied to everybody on this installation. Leave it clear to restore this account alone.", "restorePromptLabel": "Type RESTORE to continue", "previewTitle": "What this backup contains", "previewLoading": "Reading contents…", diff --git a/messages/es.json b/messages/es.json index c5ddb36b1..990f5c414 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6924,7 +6924,7 @@ "backups": { "title": "Copias de seguridad", "subtitle": "Instantáneas de datos semanales por usuario almacenadas en PostgreSQL.", - "description": "Cada usuario tiene una instantánea rotativa por tipo de copia. Los tamaños muestran el blob cifrado; los contenidos no se pueden descargar desde esta página.", + "description": "Cada usuario tiene una instantánea rotativa por tipo de copia. Los tamaños muestran el blob cifrado. Descargar descifra la instantánea para el administrador que la pide; el texto cifrado permanece en la base de datos.", "runNow": "Hacer copia ahora", "runEnqueued": "Tarea de copia de seguridad en cola", "runFailed": "No se pudo poner en cola la copia de seguridad", @@ -6960,6 +6960,8 @@ "restoreAria": "Restaurar desde copia de seguridad de {username}", "restoreTitle": "Restaurar desde copia de seguridad", "restoreDescription": "Esto reemplazará TODOS los datos actuales de {username} con la instantánea del {when}. Se eliminarán las mediciones, los medicamentos, los eventos de toma, las entradas de ánimo, los canales de notificación, las suscripciones push y las eliminaciones de Telegram en cola existentes.", + "restoreInstanceSettingsLabel": "Restaurar también los ajustes de la instancia", + "restoreInstanceSettingsHint": "Registro, requisito de MFA, idioma predeterminado, disponibilidad de módulos, configuración de notificaciones e IA: se toman de la instantánea y se aplican a todos en esta instalación. Déjalo sin marcar para restaurar solo esta cuenta.", "restorePromptLabel": "Escribe RESTORE para continuar", "previewTitle": "Contenido de la copia", "previewLoading": "Leyendo el contenido…", diff --git a/messages/fr.json b/messages/fr.json index fa956d815..eee2b3cbd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6924,7 +6924,7 @@ "backups": { "title": "Sauvegardes", "subtitle": "Instantanés de données hebdomadaires par utilisateur stockés dans PostgreSQL.", - "description": "Chaque utilisateur dispose d'un instantané glissant par type de sauvegarde. Les tailles indiquent le blob chiffré — les contenus ne sont pas téléchargeables depuis cette page.", + "description": "Chaque utilisateur dispose d'un instantané glissant par type de sauvegarde. Les tailles indiquent le blob chiffré. Télécharger déchiffre l'instantané pour l'administrateur qui le demande ; le texte chiffré reste dans la base de données.", "runNow": "Sauvegarder maintenant", "runEnqueued": "Tâche de sauvegarde mise en file d'attente", "runFailed": "Échec de la mise en file d'attente de la sauvegarde", @@ -6960,6 +6960,8 @@ "restoreAria": "Restaurer à partir de la sauvegarde de {username}", "restoreTitle": "Restaurer à partir d'une sauvegarde", "restoreDescription": "Cette action remplacera TOUTES les données actuelles de {username} par l'instantané du {when}. Les mesures, médicaments, événements de prise, entrées d'humeur, canaux de notification, abonnements push et suppressions Telegram en file d'attente existants seront supprimés.", + "restoreInstanceSettingsLabel": "Restaurer aussi les paramètres de l'instance", + "restoreInstanceSettingsHint": "Inscription, exigence MFA, langue par défaut, disponibilité des modules, configuration des notifications et de l'IA — repris de l'instantané et appliqués à tout le monde sur cette installation. Laissez décoché pour ne restaurer que ce compte.", "restorePromptLabel": "Tapez RESTORE pour continuer", "previewTitle": "Contenu de la sauvegarde", "previewLoading": "Lecture du contenu…", diff --git a/messages/it.json b/messages/it.json index 4e4964486..f550c93a0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6924,7 +6924,7 @@ "backups": { "title": "Backup", "subtitle": "Istantanee settimanali dei dati per utente archiviate in PostgreSQL.", - "description": "Ogni utente ha un'istantanea a rotazione per tipo di backup. Le dimensioni mostrano il blob cifrato: i contenuti non sono scaricabili da questa pagina.", + "description": "Ogni utente ha un'istantanea a rotazione per tipo di backup. Le dimensioni mostrano il blob cifrato. Il download decifra l'istantanea per l'amministratore che la richiede; il testo cifrato resta nel database.", "runNow": "Esegui backup ora", "runEnqueued": "Processo di backup messo in coda", "runFailed": "Impossibile mettere in coda il backup", @@ -6960,6 +6960,8 @@ "restoreAria": "Ripristina dal backup di {username}", "restoreTitle": "Ripristina dal backup", "restoreDescription": "Questa operazione sostituirà TUTTI i dati attuali di {username} con l'istantanea del {when}. Misurazioni, farmaci, eventi di assunzione, voci dell'umore, canali di notifica, sottoscrizioni push ed eliminazioni Telegram in coda esistenti verranno eliminati.", + "restoreInstanceSettingsLabel": "Ripristina anche le impostazioni dell'istanza", + "restoreInstanceSettingsHint": "Registrazione, obbligo di MFA, lingua predefinita, disponibilità dei moduli, configurazione di notifiche e IA: presi dall'istantanea e applicati a tutti su questa installazione. Lascia deselezionato per ripristinare solo questo account.", "restorePromptLabel": "Digita RESTORE per continuare", "previewTitle": "Contenuto del backup", "previewLoading": "Lettura del contenuto…", diff --git a/messages/ko.json b/messages/ko.json index ee79d4cc3..641082b67 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6904,7 +6904,7 @@ "backups": { "title": "백업", "subtitle": "PostgreSQL에 저장되는 주간 사용자별 데이터 스냅샷이에요.", - "description": "사용자마다 백업 유형별로 최신 스냅샷 하나를 굴려가며 보관해요. 크기는 암호화된 blob 기준이고, 이 페이지에서 내용을 내려받을 수는 없어요.", + "description": "사용자마다 백업 유형별로 최신 스냅샷 하나를 굴려가며 보관해요. 크기는 암호화된 blob 기준이에요. 내려받기는 요청한 관리자에게만 스냅샷을 복호화해 주고, 암호문은 데이터베이스에 그대로 남아요.", "runNow": "지금 백업", "runEnqueued": "백업 작업을 큐에 넣었어요", "runFailed": "백업을 큐에 넣지 못했어요", @@ -6939,7 +6939,9 @@ "restore": "복원", "restoreAria": "{username}의 백업에서 복원", "restoreTitle": "백업에서 복원", - "restoreDescription": "{username}의 현재 데이터 전체를 {when} 스냅샷으로 바꿔요. 기존 측정, 약, 복용 기록, 기분 기록, 알림 채널, 푸시 구독, 대기 중인 Telegram 삭제 항목이 지워져요. 스냅샷에 인스턴스 전체 설정이 들어 있으면 설치본 전체 설정을 덮어써요.", + "restoreDescription": "{username}의 현재 데이터 전체를 {when} 스냅샷으로 바꿔요. 기존 측정, 약, 복용 기록, 기분 기록, 알림 채널, 푸시 구독, 대기 중인 Telegram 삭제 항목이 지워져요.", + "restoreInstanceSettingsLabel": "인스턴스 설정도 함께 복원", + "restoreInstanceSettingsHint": "가입, MFA 필수 여부, 기본 언어, 모듈 사용 여부, 알림과 AI 설정 — 스냅샷에서 가져와 이 설치본의 모든 사용자에게 적용돼요. 이 계정만 복원하려면 선택하지 마세요.", "restorePromptLabel": "계속하려면 RESTORE를 입력해 주세요", "previewTitle": "이 백업에 담긴 내용", "previewLoading": "내용을 읽는 중…", diff --git a/messages/pl.json b/messages/pl.json index e90f04dfd..1caaad0a3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6924,7 +6924,7 @@ "backups": { "title": "Kopie zapasowe", "subtitle": "Cotygodniowe migawki danych dla każdego użytkownika przechowywane w PostgreSQL.", - "description": "Każdy użytkownik ma jedną przesuwaną migawkę na typ kopii. Rozmiary pokazują zaszyfrowany blob — zawartości nie można pobrać z tej strony.", + "description": "Każdy użytkownik ma jedną przesuwaną migawkę na typ kopii. Rozmiary pokazują zaszyfrowany blob. Pobieranie odszyfrowuje migawkę dla proszącego administratora; szyfrogram zostaje w bazie danych.", "runNow": "Utwórz kopię teraz", "runEnqueued": "Zadanie kopii zapasowej dodano do kolejki", "runFailed": "Nie udało się dodać kopii do kolejki", @@ -6960,6 +6960,8 @@ "restoreAria": "Przywróć z kopii zapasowej dla {username}", "restoreTitle": "Przywróć z kopii zapasowej", "restoreDescription": "Spowoduje to zastąpienie WSZYSTKICH bieżących danych {username} migawką z {when}. Istniejące pomiary, leki, zdarzenia przyjęcia, wpisy nastroju, kanały powiadomień, subskrypcje push i zakolejkowane usunięcia Telegram zostaną usunięte.", + "restoreInstanceSettingsLabel": "Przywróć też ustawienia instancji", + "restoreInstanceSettingsHint": "Rejestracja, wymóg MFA, domyślny język, dostępność modułów, konfiguracja powiadomień i AI — brane z migawki i stosowane do wszystkich w tej instalacji. Zostaw niezaznaczone, aby przywrócić tylko to konto.", "restorePromptLabel": "Wpisz RESTORE, aby kontynuować", "previewTitle": "Zawartość kopii", "previewLoading": "Odczytywanie zawartości…", diff --git a/playwright.config.ts b/playwright.config.ts index 4911f5074..92e209faa 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -191,6 +191,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/__tests__/idempotency-contract-publication.test.ts b/src/__tests__/idempotency-contract-publication.test.ts index 442aadbbd..74af2402a 100644 --- a/src/__tests__/idempotency-contract-publication.test.ts +++ b/src/__tests__/idempotency-contract-publication.test.ts @@ -124,10 +124,7 @@ function idempotentOperationsFromContract(): Set { * Listed rather than filtered silently, so the exclusion stays visible in the * file that would otherwise claim full coverage. */ -const UNPUBLISHED_PATHS: Readonly> = { - "/api/admin/backups/{id}/restore": - "Admin surface: cookie-only by construction, so arguably out of scope for a client contract.", -}; +const UNPUBLISHED_PATHS: Readonly> = {}; describe("idempotency — the contract matches the wrapper", () => { it("finds idempotent operations in the source tree at all", () => { diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 82480859c..2b28e2a3b 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -217,14 +217,10 @@ const UNPUBLISHED: Readonly> = { "/api/admin/backups": { kind: "adminConsole", methods: ["GET"] }, "/api/admin/backups/run": { kind: "adminConsole", methods: ["POST"] }, "/api/admin/backups/upload": { kind: "adminConsole", methods: ["POST"] }, - "/api/admin/backups/{id}/download": { - kind: "adminConsole", - methods: ["GET"], - }, - "/api/admin/backups/{id}/restore": { - kind: "adminConsole", - methods: ["POST"], - }, + // `/api/admin/backups/{id}/download` and `/api/admin/backups/{id}/restore` + // are published rather than exempted: both open the encrypted stored copy, + // and the refusal they answer when it will not open is a promise an operator + // needs to be able to look up. See `src/lib/openapi/routes/admin-backups.ts`. "/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] }, "/api/admin/central-codex": { kind: "adminConsole", diff --git a/src/app/api/admin/backups/[id]/download/route.ts b/src/app/api/admin/backups/[id]/download/route.ts index 0e55b6f7c..644313726 100644 --- a/src/app/api/admin/backups/[id]/download/route.ts +++ b/src/app/api/admin/backups/[id]/download/route.ts @@ -19,7 +19,11 @@ import { prisma } from "@/lib/db"; import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; import { apiError, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; -import { unpackBackupBlob } from "@/lib/export/backup-blob"; +import { + BACKUP_UNDECRYPTABLE_CODE, + BACKUP_UNDECRYPTABLE_ERROR, + unpackBackupBlob, +} from "@/lib/export/backup-blob"; import { annotate } from "@/lib/logging/context"; import { parseBackupPayload } from "@/lib/validations/backup"; @@ -54,9 +58,10 @@ export const GET = apiHandler( try { plaintext = unpackBackupBlob(backup.data); } catch (err) { - // Decryption failure is rare but real — a rotated/missing key, or a - // corrupted blob from a partially-failed write. Surface it as a 500 - // and audit the event so the admin has something to investigate. + // A rotated or dropped key, or a stored copy that is no longer the one + // that was written. Both are bad stored input rather than a fault in + // this process, so the answer is 422 with a reason the operator can act + // on — and the audit row keeps the underlying message. await auditLog("admin.backups.download.failed", { userId: admin.id, ipAddress: getClientIp(request), @@ -66,7 +71,9 @@ export const GET = apiHandler( reason: err instanceof Error ? err.message : "decrypt_failed", }, }); - return apiError("Failed to decrypt backup payload", 500); + return apiError(BACKUP_UNDECRYPTABLE_ERROR, 422, { + errorCode: BACKUP_UNDECRYPTABLE_CODE, + }); } // Validate the payload BEFORE handing it back. This catches the diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3deab49d4..409a5b345 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -11,6 +11,10 @@ * Metadata-only portable document exports are rejected before mutation; the * importer never fabricates content. Audit rows remain outside the wipe, and * cache invalidation runs only after the complete restore transaction. + * + * Everything above is scoped to ONE account. The instance-wide settings a + * disaster-recovery payload also carries are not, and they are opt-in for that + * reason: see the `restoreInstanceSettings` block below. */ import { Buffer } from "node:buffer"; @@ -19,7 +23,11 @@ import { prisma, toJson } from "@/lib/db"; import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; -import { unpackBackupBlob } from "@/lib/export/backup-blob"; +import { + BACKUP_UNDECRYPTABLE_CODE, + BACKUP_UNDECRYPTABLE_ERROR, + unpackBackupBlob, +} from "@/lib/export/backup-blob"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; @@ -142,17 +150,26 @@ const handler = apiHandler( const { id } = await params; annotate({ action: { name: "admin.backups.restore" }, meta: { id } }); - let body: { confirm?: string } = {}; + let body: { confirm?: string; restoreInstanceSettings?: boolean } = {}; try { const raw = await request.text(); if (raw.length > 64 * 1024) { return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); } - body = JSON.parse(raw) as { confirm?: string }; + body = JSON.parse(raw) as { + confirm?: string; + restoreInstanceSettings?: boolean; + }; } catch { return apiError("Invalid JSON body", 400); } + // Restoring one account's data and reconfiguring the whole host are two + // different decisions, so they are two different answers. Anything but a + // literal `true` restores the account alone. + const restoreInstanceSettings = body.restoreInstanceSettings === true; + annotate({ meta: { restore_instance_settings: restoreInstanceSettings } }); + if (body.confirm !== "RESTORE") { await auditLog("admin.backups.restore.denied", { userId: admin.id, @@ -160,7 +177,7 @@ const handler = apiHandler( details: { reason: "missing_confirmation", backupId: id }, }); return apiError( - "Confirmation token missing — restoring user data and included instance-wide settings requires confirm: 'RESTORE'", + "Confirmation token missing — replacing an account's data requires confirm: 'RESTORE'", 422, ); } @@ -191,7 +208,14 @@ const handler = apiHandler( reason: err instanceof Error ? err.message : "decrypt_failed", }, }); - return apiError("Failed to decrypt backup payload", 500); + // Bad stored input, not a broken server: a copy written under a key the + // operator has since dropped, or one whose bytes have changed. Every + // other bad-input arm on this route answers 4xx, and a 500 here would + // also page the error reporter for a rotation mistake. Refused above the + // transaction, so nothing was touched. + return apiError(BACKUP_UNDECRYPTABLE_ERROR, 422, { + errorCode: BACKUP_UNDECRYPTABLE_CODE, + }); } // Parsed once and kept, because the schema's per-section `.default([])` @@ -292,6 +316,7 @@ const handler = apiHandler( ownerId, ownerUsername: owner.username, snapshotExportedAt: payload.exportedAt, + restoreInstanceSettings, }, }); @@ -441,7 +466,20 @@ const handler = apiHandler( where: { userId: ownerId }, }); - if (payload.appSettings) { + // The one section of the file that is not this account's. + // + // A disaster-recovery payload carries the singleton `app_settings` + // row — registration, the MFA requirement, the default locale and + // timezone, module availability, the notification and AI + // configuration, the document size cap and quota. Writing it back as + // a side effect of restoring ONE account reconfigures the host for + // everybody on it, silently and hours or weeks out of date: an + // operator putting a single record back on Wednesday has no reason to + // expect Monday's registration switch and Monday's upload cap to come + // with it. Rebuilding a host from a snapshot is a real case, so the + // write stays available — as an answer the operator gives, not one + // the account restore assumes. + if (payload.appSettings && restoreInstanceSettings) { const settings = payload.appSettings; const settingsData = { registrationEnabled: settings.registrationEnabled, @@ -2084,6 +2122,10 @@ const handler = apiHandler( backupId: id, ownerId, ownerUsername: owner.username, + // Whether this restore also rewrote the host's own settings. The one + // effect of a restore that reaches accounts other than `ownerId`, so + // the trail has to be able to answer it later. + restoreInstanceSettings, cleared, // The durable half of the report. The response reaches whoever was // looking at the screen; the audit row is still here next week when diff --git a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts index 51441643c..766e0d4de 100644 --- a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts +++ b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts @@ -81,12 +81,18 @@ describe("GET /api/admin/backups/[id]/summary", () => { await expect(GET(request, params)).rejects.toMatchObject({ status: 404 }); }); - it("degrades to an error response when the file cannot be decrypted", async () => { + it("refuses a file it cannot decrypt with the documented 422", async () => { decryptMock.mockImplementation(() => { throw new Error("bad key"); }); const res = await GET(request, params); - expect(res.status).toBe(500); + // Bad stored input — a key dropped from `ENCRYPTION_KEYS`, or bytes that + // are not the ones written — and not a fault in this process, so it does + // not answer 500 and does not reach the error reporter as one. + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ + meta: { errorCode: "backup.payload.undecryptable" }, + }); }); it("422s a file that fails schema validation", async () => { diff --git a/src/app/api/admin/backups/[id]/summary/route.ts b/src/app/api/admin/backups/[id]/summary/route.ts index 3ae840d80..4b79fcc79 100644 --- a/src/app/api/admin/backups/[id]/summary/route.ts +++ b/src/app/api/admin/backups/[id]/summary/route.ts @@ -16,7 +16,11 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; -import { unpackBackupBlob } from "@/lib/export/backup-blob"; +import { + BACKUP_UNDECRYPTABLE_CODE, + BACKUP_UNDECRYPTABLE_ERROR, + unpackBackupBlob, +} from "@/lib/export/backup-blob"; import { annotate } from "@/lib/logging/context"; import { isCompatibleSchemaVersion, @@ -46,7 +50,12 @@ export const GET = apiHandler( try { plaintext = unpackBackupBlob(backup.data); } catch { - return apiError("Failed to decrypt backup payload", 500); + // Same refusal the restore and the download give, for the same reason: + // a copy this instance cannot open is bad stored input, and the preview + // is the first place an operator meets it. + return apiError(BACKUP_UNDECRYPTABLE_ERROR, 422, { + errorCode: BACKUP_UNDECRYPTABLE_CODE, + }); } let payload; diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index 985e82e22..04a8c2139 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -2,9 +2,11 @@ * GET /api/admin/backups — admin-only listing of `DataBackup` rows. * * Returns one row per (userId, type) pair. The `data` payload is NOT - * shipped — only metadata (id, userId, username, type, size in bytes, - * createdAt). The encrypted blob remains server-side; admins can trigger - * a re-snapshot but not download another user's payload from the UI. + * shipped by THIS route — only metadata (id, userId, username, type, size in + * bytes, createdAt), so the listing costs a count and not a decrypt. The + * payload has its own route: `/api/admin/backups//download` decrypts one + * snapshot for the admin who asks for it, and the ciphertext stays in the + * database either way. * * Alongside the rows, the response says whether the SCHEDULE is still alive: * how old the newest scheduled copy is and how the last scheduled run ended. diff --git a/src/components/admin/__tests__/backups-section-empty.test.tsx b/src/components/admin/__tests__/backups-section-empty.test.tsx index 6a8b2afd5..a29f39741 100644 --- a/src/components/admin/__tests__/backups-section-empty.test.tsx +++ b/src/components/admin/__tests__/backups-section-empty.test.tsx @@ -140,12 +140,21 @@ describe("BackupsSection — empty state", () => { expect(className).not.toMatch(/\b(?:opacity-\d+|text-\S+\/\d+)\b/); }); - it("warns in both catalogs that restore overwrites instance-wide settings", () => { - expect(de.admin.section.backups.restoreDescription).toMatch( + it("names the instance-wide effect on the opt-in that carries it, in both catalogs", () => { + // The restore rewrites the host's own settings only when the operator + // ticks the box, so the warning belongs to the box rather than to the + // sentence above it — and the box has to say what it turns on. + expect(de.admin.section.backups.restoreInstanceSettingsLabel).toMatch( /instanzweite Einstellungen/i, ); - expect(en.admin.section.backups.restoreDescription).toMatch( - /instance-wide settings/i, + expect(de.admin.section.backups.restoreInstanceSettingsHint).toMatch( + /Installation/, + ); + expect(en.admin.section.backups.restoreInstanceSettingsLabel).toMatch( + /instance settings/i, + ); + expect(en.admin.section.backups.restoreInstanceSettingsHint).toMatch( + /installation/i, ); }); }); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 613f6799d..f6f448a0a 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -39,6 +39,7 @@ import { Badge } from "@/components/ui/badge"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -124,13 +125,18 @@ function RestoreRowDialog({ }: { row: BackupRow; pending: boolean; - onConfirm: () => void; + onConfirm: (options: { restoreInstanceSettings: boolean }) => void; }) { const { t } = useTranslations(); const fmt = useFormatters(); const [open, setOpen] = useState(false); const [typed, setTyped] = useState(""); const matched = typed.trim() === "RESTORE"; + // Off unless the operator says otherwise. A disaster-recovery snapshot + // carries the host's own settings alongside the account's data, and putting + // one account back is not a reason to reconfigure the installation for + // everybody on it — so the second effect is a second decision. + const [withInstanceSettings, setWithInstanceSettings] = useState(false); // v1.37.20 — restore preview: fetch what the file contains the moment the // dialog opens, so the typed confirmation is an informed one. Derived from @@ -170,13 +176,17 @@ function RestoreRowDialog({ open={open} onOpenChange={(next) => { setOpen(next); - if (!next) setTyped(""); + if (!next) { + setTyped(""); + setWithInstanceSettings(false); + } }} >