mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #812 from MBombeck/test/portable-restore-refusal-ordering
Pin that a refused restore has not already wiped the account
This commit is contained in:
1 file changed
+213
@@ -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<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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user