Files
HealthLog/tests/integration/backup-portable-file-restore.test.ts
T
Marc-André Bombeck 47e9f7e81c feat(backup): run a restore as a background job with progress
Restoring a large account was one request: 94 s for 1.25 million readings.
A reverse proxy that cuts a request at 60 s (nginx's default) showed the
operator an error for a restore that went on to finish on the server.

The restore request now checks the typed confirmation, that the backup
exists and that its stored copy opens, admits one job for the account and
answers 202 with its id. The `backup-restore` queue runs the restore and
records its phase, counts and outcome on a `backup_restore_jobs` row
(migration 0347), which `GET /api/admin/backups/restores` and
`GET /api/admin/backups/restores/{id}` read back.

What a restore checks and writes is unchanged. The body moved from the
route into `restoreBackup` (src/lib/export/restore-backup.ts): every
refusal still happens before the first delete and the replacement is still
one transaction, so a failure part-way leaves the account as it was. While
it runs the account keeps showing its current data, and other writes are
neither refused nor held, as before.

- One restore per account: a partial unique index admits one queued or
  running job, and a second request gets 409 `backup.restore.active` naming
  the running job. A per-account advisory lock inside the transaction holds
  even if a worker thought gone is still writing.
- Time budget: the job's expiry is two hours and a file whose transaction
  limit would run past three quarters of it is refused before anything is
  deleted (`jobDeadline`, beside `jobBudget`).
- Resuming: the job writes a heartbeat every two seconds. A boot sweep
  queues a job whose worker stopped again from the start, at most twice,
  then fails it as interrupted. The admission check releases an abandoned
  job so it never holds the account's slot.
- The staged file is the stored `DataBackup` row, already encrypted at
  rest. It stays whatever the outcome, so a retry needs no new upload. The
  job records a digest of the copy and refuses one that was replaced while
  it waited.
- Failures carry a stable code and a sentence that says what to do.
- The console follows the job: phase and counts while it runs, the outcome
  after, and after a reload it finds the job again from the server. A poll
  that fails on the network is never shown as a failed restore. A web
  process separate from the worker evicts the account's caches when it
  first reports the restore finished. The success line goes through the
  outcome module, so an empty file is not drawn as a tick.

The model is registered in the backup plan (not carried) and the wipe plan
(wiped with the account); the queue in the maintenance registrar, the
subsystem surface and the job-fact allowlist; the new code in the error
catalogue; the routes in the OpenAPI contract. The tests that pin restore
semantics now drive the real route and the real job through one helper, and
`backup-restore-job.test.ts` pins the asynchronous contract itself.
2026-09-25 07:39:06 +02:00

214 lines
8.5 KiB
TypeScript

/**
* 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 "./restore-job-driver";
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<typeof POST>[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);
});
});