Files
HealthLog/tests/integration/practitioner-contact-encryption.test.ts
T
Marc-André Bombeck 5eb7956bad fix(privacy): encrypt workout GPS tracks and practitioner contact details at rest
The GPS track of an outdoor workout was stored readable, and its first and
last points are usually the person's front door. The phone number and address
in the address book of doctors and practices were readable too, while the
note beside them was already encrypted.

Migration 0361 adds workout_routes.geometry_encrypted (the geometry loses
its NOT NULL) and practitioners.phone_encrypted / location_encrypted, plus
partial indexes on the backfill's discovery predicates. A track is sealed as
one binary AES-256-GCM value bound to its purpose; the contact fields use the
same Bytes codec as the note. The practitioner's name, practice and specialty
stay readable, because the picker searches and sorts on them.

Every writer stores ciphertext only: workout batch ingest, practitioner
create and edit, and both restore formats. Readers prefer the ciphertext and
fall back to the old column for rows not yet migrated: the workout detail
(map, splits, GPX), the activity insight's climb figure, the practitioner
DTO behind the address book, visits and vaccinations. The wire shape does not
change. An edit's audit row now names a changed phone number or address
without recording either value.

free-text-encryption-backfill covers the three columns in the same pass,
finding a route through its workout. All three are registered for key
rotation (registry, CLI script, and the in-app rotation). A disaster-recovery
backup carries the contact fields as ciphertext only; a portable export
carries them readable. Workout routes are still not part of a backup, as
before. The old columns are marked for removal in a later release.
2026-09-27 09:59:34 +02:00

326 lines
9.8 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),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
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,
},
]);
}
});
});