mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
214 lines
8.5 KiB
TypeScript
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);
|
|
});
|
|
});
|