Files
HealthLog/tests/integration/practitioner-contact-encryption.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

382 lines
11 KiB
TypeScript

/**
* v1.39.4 — a practitioner's phone number and address are encrypted at rest,
* against real Postgres.
*
* - a create and an edit store ciphertext only: the readable columns stay
* NULL and the stored bytes do not contain the number;
* - the wire shape is unchanged, and a row the backfill has not reached yet
* still reads back from its legacy columns;
* - an edit's audit row names the changed contact field without its value;
* - a disaster-recovery backup carries the fields as ciphertext only, a
* portable export carries them readable, and a restore from either (or
* from a file written before this release) writes ciphertext only.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { GET as listPractitioners, POST } from "@/app/api/practitioners/route";
import { PATCH } from "@/app/api/practitioners/[id]/route";
import {
buildVisitsBackupSection,
restoreVisitsData,
} from "@/lib/export/visits-backup";
import { readNote } from "@/lib/crypto/note-cipher";
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),
})),
};
});
const OWNER_ID = "practitioner-contact-owner";
const PHONE = "+49 30 9876543";
const ADDRESS = "Lindenweg 12, 10115 Berlin";
async function seedSession() {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: OWNER_ID,
username: OWNER_ID,
email: `${OWNER_ID}@example.test`,
timezone: "Europe/Berlin",
},
});
const session = await prisma.session.create({
data: {
userId: OWNER_ID,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
mfaVerifiedAt: new Date(),
},
});
cookieJar.set("healthlog_session", session.id);
}
function jsonRequest(url: string, method: string, body: unknown) {
return new Request(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never;
}
/** The stored row as SQL sees it: readable columns and a leak probe. */
async function onDisk(id: string, needle: string) {
const rows = await getPrismaClient().$queryRaw<
{
phone: string | null;
location: string | null;
phone_leaks: boolean;
location_leaks: boolean;
}[]
>`
SELECT phone, location,
COALESCE(position(convert_to(${needle}, 'UTF8') IN phone_encrypted) > 0, false)
AS phone_leaks,
COALESCE(position(convert_to(${needle}, 'UTF8') IN location_encrypted) > 0, false)
AS location_leaks
FROM practitioners WHERE id = ${id}`;
return rows[0];
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await seedSession();
});
describe("practitioner phone and address at rest", () => {
it("stores a created practitioner's contact fields as ciphertext only and serves them unchanged", async () => {
const res = await POST(
jsonRequest("http://localhost/api/practitioners", "POST", {
name: "Dr. Weber",
specialty: "Kardiologie",
phone: PHONE,
location: ADDRESS,
}),
);
expect(res.status).toBe(201);
const created = (await res.json()).data as {
id: string;
phone: string | null;
location: string | null;
};
expect(created).toMatchObject({ phone: PHONE, location: ADDRESS });
expect(await onDisk(created.id, "9876543")).toEqual({
phone: null,
location: null,
phone_leaks: false,
location_leaks: false,
});
expect(await onDisk(created.id, "Lindenweg")).toMatchObject({
location_leaks: false,
});
const list = await listPractitioners(
new Request("http://localhost/api/practitioners") as never,
);
const rows = (await list.json()).data as {
name: string;
phone: string | null;
location: string | null;
}[];
expect(rows).toEqual([
expect.objectContaining({
name: "Dr. Weber",
phone: PHONE,
location: ADDRESS,
}),
]);
});
it("reads a row the backfill has not reached from its legacy columns", async () => {
await getPrismaClient().practitioner.create({
data: {
userId: OWNER_ID,
name: "Dr. Alt",
phone: PHONE,
location: ADDRESS,
},
});
const list = await listPractitioners(
new Request("http://localhost/api/practitioners") as never,
);
const rows = (await list.json()).data as {
phone: string | null;
location: string | null;
}[];
expect(rows[0]).toMatchObject({ phone: PHONE, location: ADDRESS });
});
it("seals an edited field, clears its legacy column, and audits the change without the value", async () => {
const prisma = getPrismaClient();
const legacy = await prisma.practitioner.create({
data: {
userId: OWNER_ID,
name: "Dr. Alt",
phone: "+49 30 1111111",
location: ADDRESS,
},
});
const res = await PATCH(
jsonRequest(`http://localhost/api/practitioners/${legacy.id}`, "PATCH", {
phone: PHONE,
}),
{ params: Promise.resolve({ id: legacy.id }) },
);
expect(res.status).toBe(200);
expect((await res.json()).data).toMatchObject({
phone: PHONE,
// Untouched by the edit: still served from the legacy column.
location: ADDRESS,
});
const row = await prisma.practitioner.findUniqueOrThrow({
where: { id: legacy.id },
});
expect(row.phone).toBeNull();
expect(readNote(row.phoneEncrypted, null)).toBe(PHONE);
expect(row.location).toBe(ADDRESS);
const audit = await prisma.auditLog.findFirstOrThrow({
where: { userId: OWNER_ID, action: "practitioner.contact.update" },
});
const details = JSON.parse(audit.details ?? "{}") as {
fields?: string[];
previous?: Record<string, unknown>;
};
expect(details.fields).toEqual(["phone"]);
expect(details.previous ?? {}).not.toHaveProperty("phone");
expect(audit.details).not.toContain("1111111");
expect(audit.details).not.toContain("9876543");
});
it("carries the contact fields as ciphertext in a disaster-recovery backup, readable in a portable one, and restores ciphertext only", async () => {
const prisma = getPrismaClient();
// One legacy row, one sealed through the route.
await prisma.practitioner.create({
data: {
id: "pr-legacy",
userId: OWNER_ID,
name: "Dr. Alt",
phone: PHONE,
location: ADDRESS,
},
});
const created = await POST(
jsonRequest("http://localhost/api/practitioners", "POST", {
name: "Dr. Neu",
phone: PHONE,
}),
);
const sealedId = ((await created.json()).data as { id: string }).id;
const dr = await buildVisitsBackupSection(prisma, OWNER_ID, {
purpose: "disaster-recovery",
});
const drFile = JSON.stringify(dr);
expect(drFile).not.toContain("9876543");
expect(drFile).not.toContain("Lindenweg");
for (const entry of dr.practitioners) {
expect(entry.phone).toBeNull();
expect(entry.location).toBeNull();
expect(typeof entry.phoneEncrypted).toBe("string");
}
const portable = await buildVisitsBackupSection(prisma, OWNER_ID, {
purpose: "portable-export",
});
expect(
portable.practitioners.map((p) => ({
id: p.id,
phone: p.phone,
location: p.location,
})),
).toEqual(
expect.arrayContaining([
{ id: "pr-legacy", phone: PHONE, location: ADDRESS },
{ id: sealedId, phone: PHONE, location: null },
]),
);
// A file written before this release: readable fields, no ciphertext.
const older = portable.practitioners.map((p) => ({
id: p.id,
name: p.name,
phone: p.phone,
location: p.location,
noteEncrypted: p.noteEncrypted,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
}));
for (const payload of [
dr.practitioners,
portable.practitioners,
older,
] as const) {
await prisma.$transaction(async (tx) => {
await restoreVisitsData(
tx,
OWNER_ID,
{
practitioners: payload,
encounters: [],
encounterDocumentLinks: [],
encounterLabLinks: [],
encounterConditionLinks: [],
},
[],
);
});
const restored = await prisma.practitioner.findMany({
where: { userId: OWNER_ID },
orderBy: { name: "asc" },
});
expect(
restored.map((p) => ({
id: p.id,
phone: p.phone,
location: p.location,
phoneText: readNote(p.phoneEncrypted, null),
locationText: readNote(p.locationEncrypted, null),
})),
).toEqual([
{
id: "pr-legacy",
phone: null,
location: null,
phoneText: PHONE,
locationText: ADDRESS,
},
{
id: sealedId,
phone: null,
location: null,
phoneText: PHONE,
locationText: null,
},
]);
}
});
it("carries a deleted practitioner a live visit names in a portable file, and no other tombstone", async () => {
const prisma = getPrismaClient();
const gone = new Date("2026-09-01T00:00:00.000Z");
for (const id of ["pr-live", "pr-named", "pr-orphan"]) {
await prisma.practitioner.create({
data: {
id,
userId: OWNER_ID,
name: id,
deletedAt: id === "pr-live" ? null : gone,
},
});
}
await prisma.encounter.create({
data: {
id: "visit-named",
userId: OWNER_ID,
occurredAt: new Date("2026-08-01T08:00:00.000Z"),
practitionerId: "pr-named",
},
});
const portable = await buildVisitsBackupSection(prisma, OWNER_ID, {
purpose: "portable-export",
});
expect(
portable.practitioners
.map((p) => ({ id: p.id, deleted: Boolean(p.deletedAt) }))
.sort((a, b) => a.id.localeCompare(b.id)),
).toEqual([
{ id: "pr-live", deleted: false },
{ id: "pr-named", deleted: true },
]);
expect(portable.encounters[0].practitionerId).toBe("pr-named");
// The restore brings the named entry back as a tombstone: on the visit,
// not in the address book.
await prisma.$transaction(async (tx) => {
await restoreVisitsData(
tx,
OWNER_ID,
{
practitioners: portable.practitioners,
encounters: portable.encounters,
encounterDocumentLinks: [],
encounterLabLinks: [],
encounterConditionLinks: [],
},
[],
);
});
const visit = await prisma.encounter.findFirstOrThrow({
where: { userId: OWNER_ID },
select: { practitioner: { select: { id: true, deletedAt: true } } },
});
expect(visit.practitioner?.id).toBe("pr-named");
expect(visit.practitioner?.deletedAt).not.toBeNull();
});
});