Files
HealthLog/tests/integration/backup-missing-section-restore.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
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.
2026-11-03 20:32:35 +01:00

235 lines
8.6 KiB
TypeScript

/**
* A file that claims a section it does not carry is refused whole (#237).
*
* The restore's first act is to delete the class of records it is about to
* rebuild. So a payload whose documents section is simply absent does not
* "restore everything else": it empties the vault, reports success, and leaves
* the operator to discover the hole later. The maintainer's decision is to
* refuse the file instead — above the transaction, like the metadata-only
* check beside it, so the account survives the refusal.
*
* The hard part is not the refusal. It is the boundary, and both halves are
* asserted here against the real route and real rows:
*
* MISSING — the manifest says the file carries documents, and the key is not
* there. Refused, named, nothing deleted.
*
* DECLARED OMITTED — the manifest says the file does NOT carry the
* mental-health screeners or the consent receipts, and the keys are not
* there. That is what every portable export writes, on purpose, and it has to
* restore. The two cases below are the same builder, the same account and the
* same deleted-key trick; the only difference is what the manifest said, which
* is exactly the claim being made.
*
* Mutation check: drop the `included === "omitted"` skip in
* `findMissingBackupSections` and the second case goes red. Move the refusal
* below the `prisma.$transaction(...)` call and the first case's surviving-rows
* assertions go red while its status assertion stays green — which is why the
* rows are asserted separately rather than trusted to follow from the status.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { encrypt } from "@/lib/crypto";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
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" }),
});
}
/**
* An admin account with one measurement and nothing else, signed in.
*
* The measurement is the witness. It references nothing the refusal is about,
* so it can only be lost to the wipe — which is what tells a refused restore
* apart from a destroyed account.
*/
async function seedOwner(username: string) {
const prisma = getPrismaClient();
const owner = await prisma.user.create({
data: { username, email: `${username}@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);
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"),
},
});
return owner;
}
/**
* A real portable export of that account, with the named keys cut out of the
* JSON before it is sealed.
*
* Cutting them from the RAW object rather than from a parsed payload is the
* whole point: every section in `backupPayloadSchema` carries `.default([])`,
* so a payload that has been through the schema no longer knows the difference
* between a section that was absent and one that was empty.
*/
async function sealPortableWithout(
ownerId: string,
keys: string[],
): Promise<{ backupId: string; manifest: Record<string, unknown> }> {
const prisma = getPrismaClient();
// No `purpose`, so the builder takes the portable branch — the file a person
// gets from "export my data".
const { payload } = await buildFullBackupPayload(prisma, ownerId);
const raw = JSON.parse(JSON.stringify(payload)) as Record<string, unknown>;
for (const key of keys) delete raw[key];
const backup = await prisma.dataBackup.create({
data: {
userId: ownerId,
type: "MISSING_SECTION_RESTORE",
data: encrypt(JSON.stringify(raw)),
},
});
return {
backupId: backup.id,
manifest: raw.manifest as Record<string, unknown>,
};
}
async function restore(backupId: string) {
return await POST(
makeRequest(backupId) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backupId }) },
);
}
describe("a backup whose manifest claims a section the file does not carry", () => {
it("refuses the whole file, names the section, and changes nothing", async () => {
const prisma = getPrismaClient();
const owner = await seedOwner("missing-section-owner");
const { backupId, manifest } = await sealPortableWithout(owner.id, [
"documents",
]);
// Prove the premise rather than assume it: the file still SAYS it carries
// documents. Without this the test would keep passing if the builder ever
// stopped writing the manifest entry.
expect(
(manifest.documents as { included?: string } | undefined)?.included,
).toBe("metadata-only");
const response = await restore(backupId);
expect(response.status).toBe(422);
const body = (await response.json()) as {
error: string;
meta?: { errorCode?: string; sections?: string[] };
};
expect(
body.error,
"the message names the section, so the operator can act on it",
).toContain("documents");
expect(
body.error,
"and says the account is intact, because that is the decision they need",
).toContain("Nothing was changed");
expect(body.meta?.errorCode).toBe("backup.section.missing");
expect(body.meta?.sections).toEqual(["documents"]);
// The refusal is only worth anything above the wipe.
expect(
await prisma.measurement.count({ where: { userId: owner.id } }),
"a refused restore must not have run the wipe",
).toBe(1);
// A second, independent witness: the invalidation only fires at the end of
// a restore that actually happened.
expect(vi.mocked(invalidateUserData)).not.toHaveBeenCalled();
const auditRow = await prisma.auditLog.findFirstOrThrow({
where: { action: "admin.backups.restore.failed" },
orderBy: { createdAt: "desc" },
});
const details = JSON.parse(auditRow.details ?? "{}") as {
reason?: string;
sections?: string[];
};
expect(details.reason).toBe("section_missing");
expect(details.sections).toEqual(["documents"]);
});
it("restores a file whose manifest DECLARES the same keys omitted", async () => {
const prisma = getPrismaClient();
const owner = await seedOwner("declared-omission-owner");
// The two sections a portable export leaves out on purpose, cut from the
// file exactly the way `documents` was cut above.
const { backupId, manifest } = await sealPortableWithout(owner.id, [
"mentalHealthAssessments",
"consentReceipts",
]);
expect(
(manifest.mentalHealth as { included?: string } | undefined)?.included,
).toBe("omitted");
expect(
(manifest.consent as { included?: string } | undefined)?.included,
).toBe("omitted");
const response = await restore(backupId);
expect(
response.status,
"a declared omission is not a missing section",
).toBe(200);
const body = (await response.json()) as { data: { restored: boolean } };
expect(body.data.restored).toBe(true);
// The restore genuinely ran: the measurement came back out of the file it
// was exported into, and the cache was evicted.
expect(
await prisma.measurement.count({ where: { userId: owner.id } }),
).toBe(1);
expect(vi.mocked(invalidateUserData)).toHaveBeenCalledWith(owner.id);
});
});