mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
210 lines
8.4 KiB
TypeScript
210 lines
8.4 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/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);
|
|
});
|
|
});
|