From a8898bbee7fb75d91830e5926153b00af9a8ddb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 00:01:52 +0200 Subject: [PATCH] Pin that a refused restore has not already wiped the account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restore refuses a portable backup, because a portable export carries document metadata without the stored bytes and `contentEncrypted` is NOT NULL. That refusal is deliberate and documented at the guard. What nothing pinned is the half that makes it survivable: the check runs above the transaction that wipes the account. The guard reads like input validation, and input validation drifts toward wherever the value is first used. Move it below the wipe and the operator loses everything to a file the server was always going to reject — and the non-null assertions further down are safe only because it stands where it does. The test drives a real portable payload through the real route and reads rows, not return values: the refusal happens, the account still has every row it had, the cache invalidation never fired, and the audit row names the document. Asserting the status alone would stay green through exactly the edit this is here to catch, so the surviving rows are asserted separately rather than inferred from it. Verified by breaking it: disabling the guard turns the 422 into a 500, which is the raw TypeError from decoding bytes that are not there. --- .../backup-portable-file-restore.test.ts | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 tests/integration/backup-portable-file-restore.test.ts diff --git a/tests/integration/backup-portable-file-restore.test.ts b/tests/integration/backup-portable-file-restore.test.ts new file mode 100644 index 000000000..8ed6ceb21 --- /dev/null +++ b/tests/integration/backup-portable-file-restore.test.ts @@ -0,0 +1,213 @@ +/** + * A portable file is refused BEFORE the wipe, and the account survives it. + * + * `buildFullBackupPayload` has two modes. Disaster-recovery carries a + * document's stored bytes as base64. Portable — what the two user-facing export + * routes produce — carries metadata only: title, kind, size, dates, a decrypted + * summary. The bytes stay on the server. + * + * Both files are valid. `contentEncrypted` is optional in + * `backupPayloadSchema` precisely because a portable file honestly has none, so + * both pass validation and both are accepted by + * `POST /api/admin/backups/upload`, which checks the schema version and the + * owner and nothing about the purpose. An operator can therefore point the + * restore at the file a user downloaded from "export my data". + * + * `InboundDocument.contentEncrypted` is `Bytes` and NOT NULL, so there is no + * honest metadata-only row to write, and the restore refuses the file rather + * than inventing empty content. That refusal is deliberate and documented at + * the guard. What was NOT pinned anywhere is the half that makes it + * survivable: the check runs before the `$transaction` that wipes the account. + * + * That ordering is the whole difference between a refused restore and a + * destroyed account, and it is the kind of thing a later edit moves without + * noticing — the guard reads like input validation, and input validation + * drifts toward wherever the value is first used. If it ever ends up below the + * wipe, the operator loses everything to a file the server was always going to + * reject. The `!` assertions on `document.contentEncrypted` in the restore body + * are safe only because this guard stands above them. + * + * So this test asserts, against ROWS and against the real route: + * + * - the refusal happens, with 422 and the document named in the message, + * - every row the account had is still there afterwards, + * - the refusal is on the record, so it is not a silent no-op. + * + * Mutation check: move the `incompleteDocument` check below the + * `prisma.$transaction(...)` call and this fails on the measurement count. The + * status assertion alone stays green, which is why the surviving rows are + * asserted separately rather than trusted to follow from the status. + * + * Not asserted here, deliberately: whether refusing the WHOLE file for one + * metadata-only document is the right trade. Skipping the documents and + * restoring the other hundred models is the shape the retired-catalogue-key + * work settled on elsewhere in this same route, and it would give an operator + * whose only file is the portable export something back instead of nothing. + * That is a change to what a restore means, not a defect in what it does. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt, encryptBytes } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { invalidateUserData } from "@/lib/cache/invalidate"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + vi.mocked(invalidateUserData).mockClear(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +describe("restoring a portable backup that carries no document bytes", () => { + it("refuses the file and leaves the account exactly as it was", async () => { + const prisma = getPrismaClient(); + + const owner = await prisma.user.create({ + data: { + username: "portable-restore-owner", + email: "portable-restore-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + // Carries no document reference at all, so it can only be lost to the wipe. + // This is the row that tells a refusal apart from a destroyed account. + await prisma.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 79.2, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-07-11T06:15:00.000Z"), + }, + }); + + const document = await prisma.inboundDocument.create({ + data: { + userId: owner.id, + kind: "LAB_RESULT", + title: "Blood panel, July", + filename: "panel.pdf", + mimeType: "application/pdf", + byteSize: 4096, + contentEncrypted: new Uint8Array( + encryptBytes(Buffer.from("%PDF-1.4 fixture")), + ), + contentCodec: "bytes-v1", + status: "STORED", + }, + }); + + /* ── the portable file, exactly as the export route writes it ──────── */ + + // No `purpose`, so the builder takes the metadata-only branch. The point of + // the fixture is that this file is VALID, not corrupt. + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const parsed = parseBackupPayload(JSON.stringify(built)); + + // Prove the premise rather than assume it. If the portable mode ever starts + // carrying bytes, this test would otherwise keep passing while testing + // nothing at all. + const carried = parsed.documents.find((d) => d.id === document.id); + expect(carried, "the portable file describes the document").toBeDefined(); + expect( + carried?.contentEncrypted, + "the portable file carries no bytes for it", + ).toBeUndefined(); + + const backup = await prisma.dataBackup.create({ + data: { + userId: owner.id, + type: "PORTABLE_FILE_RESTORE", + data: encrypt(JSON.stringify(parsed)), + }, + }); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + /* ── the refusal ───────────────────────────────────────────────────── */ + + expect(response.status).toBe(422); + const body = (await response.json()) as { error: string }; + expect( + body.error, + "the message names the document, so the operator can act on it", + ).toContain(document.id); + + /* ── and the account is untouched ──────────────────────────────────── */ + + expect( + await prisma.measurement.count({ where: { userId: owner.id } }), + "a refused restore must not have run the wipe", + ).toBe(1); + expect( + await prisma.inboundDocument.count({ where: { userId: owner.id } }), + ).toBe(1); + + // The cache invalidation fires at the end of a successful restore. Its + // absence is a second, independent witness that nothing was rewritten. + expect(vi.mocked(invalidateUserData)).not.toHaveBeenCalled(); + + /* ── and it is on the record ───────────────────────────────────────── */ + + const auditRow = await prisma.auditLog.findFirstOrThrow({ + where: { action: "admin.backups.restore.failed" }, + orderBy: { createdAt: "desc" }, + }); + const details = JSON.parse(auditRow.details ?? "{}") as { + reason?: string; + documentId?: string; + }; + expect(details.reason).toBe("document_ciphertext_missing"); + expect(details.documentId).toBe(document.id); + }); +});