Files
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

487 lines
16 KiB
TypeScript

/**
* Body sites on conditions and the body-site view, against real Postgres and
* through the real routes (v1.39.2).
*
* What only a real database can answer:
*
* - the two new columns exist (migration 0354) and the site is ciphertext on
* disk, not the words the person typed;
* - an edit from the shipped iPhone app, whose body never names the site,
* leaves the stored site and side exactly as they were;
* - the view groups sites across procedures and conditions and walks one hop
* to what is linked to each, through the link service's real tables;
* - a delegate learns nothing a grant does not cover: without the illness
* section there is no condition, no condition-only site and no condition
* name; a link into an ungranted section is a placeholder with no label.
*
* Mutation checks (each run, each seen red):
* - pass `conditionsReadable: true` unconditionally in the route → both
* "profile-only delegate" cases go red (the condition-only site appears);
* - pass `() => true` as the condition document redaction predicate →
* "profile + illness delegate" goes red (the letter's title leaks);
* - write `bodySiteEncrypted: null` unconditionally in the PATCH → "an edit
* from the shipped iPhone app" goes red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import type { ShareDomain } from "@/lib/sharing/scope";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import type { BodySiteListDTO } from "@/lib/body-sites/dto";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables, switchSessionTo } 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 = "body-site-owner";
const DELEGATE_ID = "body-site-delegate";
function daysFromNow(days: number): Date {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: daysFromNow(1), mfaVerifiedAt: new Date() },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
async function switchInto(scope: ShareDomain[] | null) {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: OWNER_ID,
granteeId: DELEGATE_ID,
access: "READ",
scope,
});
await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID });
const session = await signIn(DELEGATE_ID);
await switchSessionTo(session.id, OWNER_ID);
}
async function seedRecord() {
const prisma = getPrismaClient();
for (const [id, name] of [
[OWNER_ID, "owner"],
[DELEGATE_ID, "delegate"],
] as const) {
await prisma.user.create({
data: {
id,
username: `body-site-${name}`,
email: `body-site-${name}@example.test`,
timezone: "Europe/Berlin",
locale: "en",
},
});
}
const mri = await prisma.inboundDocument.create({
data: {
userId: OWNER_ID,
kind: "OTHER",
title: "MRI knee report",
mimeType: "application/pdf",
byteSize: 10,
contentEncrypted: new Uint8Array([1, 2, 3]),
contentCodec: "binary2",
documentDate: daysFromNow(-40),
},
});
const physio = await prisma.inboundDocument.create({
data: {
userId: OWNER_ID,
kind: "OTHER",
title: "Physio letter",
mimeType: "application/pdf",
byteSize: 10,
contentEncrypted: new Uint8Array([1, 2, 3]),
contentCodec: "binary2",
documentDate: daysFromNow(-20),
},
});
const crp = await prisma.labResult.create({
data: {
userId: OWNER_ID,
analyte: "CRP",
value: 4,
unit: "mg/L",
takenAt: daysFromNow(-30),
},
});
const tear = await prisma.illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Meniscus tear",
type: "INJURY",
onsetAt: daysFromNow(-60),
bodySiteEncrypted: encryptToBytes("knee"),
laterality: "LEFT",
},
});
const lumbago = await prisma.illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Lumbago",
type: "CHRONIC",
onsetAt: daysFromNow(-400),
bodySiteEncrypted: encryptToBytes("Lower back"),
},
});
const arthroscopy = await prisma.encounter.create({
data: {
userId: OWNER_ID,
occurredAt: daysFromNow(-30),
status: "DONE",
kind: "PROCEDURE",
reasonEncrypted: encryptToBytes("Arthroscopy"),
bodySiteEncrypted: encryptToBytes("Knee"),
laterality: "LEFT",
},
});
await prisma.encounter.create({
data: {
userId: OWNER_ID,
occurredAt: daysFromNow(-500),
status: "DONE",
kind: "PROCEDURE",
reasonEncrypted: encryptToBytes("Rotator cuff repair"),
bodySiteEncrypted: encryptToBytes("Shoulder"),
laterality: "RIGHT",
},
});
// A visit with no site never appears in the view.
await prisma.encounter.create({
data: {
userId: OWNER_ID,
occurredAt: daysFromNow(-5),
status: "DONE",
kind: "ROUTINE",
},
});
await prisma.encounterDocumentLink.create({
data: { userId: OWNER_ID, encounterId: arthroscopy.id, documentId: mri.id },
});
await prisma.encounterLabLink.create({
data: {
userId: OWNER_ID,
encounterId: arthroscopy.id,
labResultId: crp.id,
},
});
await prisma.encounterConditionLink.create({
data: { userId: OWNER_ID, encounterId: arthroscopy.id, episodeId: tear.id },
});
await prisma.documentConditionLink.create({
data: { userId: OWNER_ID, documentId: physio.id, episodeId: tear.id },
});
return { mri, physio, crp, tear, lumbago, arthroscopy };
}
async function bodySites(
query = "",
): Promise<{ status: number; body: { data: BodySiteListDTO } }> {
const { GET } = await import("@/app/api/body-sites/route");
const res = await GET(
new Request(
`http://localhost/api/body-sites${query ? `?${query}` : ""}`,
) as never,
);
return { status: res.status, body: await res.json() };
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("GET /api/body-sites — the owner", () => {
it("groups sites across procedures and conditions", async () => {
await seedRecord();
await signIn(OWNER_ID);
const { status, body } = await bodySites();
expect(status).toBe(200);
expect(body.data.selection).toBeUndefined();
expect(body.data.sites).toEqual([
{
bodySite: "Knee",
procedures: 1,
conditions: 1,
sides: [{ laterality: "LEFT", count: 2 }],
},
{
bodySite: "Shoulder",
procedures: 1,
conditions: 0,
sides: [{ laterality: "RIGHT", count: 1 }],
},
{
bodySite: "Lower back",
procedures: 0,
conditions: 1,
sides: [{ laterality: null, count: 1 }],
},
]);
});
it("walks from a site to its procedures, conditions and their links", async () => {
const seeded = await seedRecord();
await signIn(OWNER_ID);
const { status, body } = await bodySites("site=KNEE&laterality=LEFT");
expect(status).toBe(200);
const selection = body.data.selection!;
expect(selection.bodySite).toBe("Knee");
expect(selection.laterality).toBe("LEFT");
expect(selection.visits.map((v) => v.id)).toEqual([seeded.arthroscopy.id]);
const links = selection.visits[0].links!;
expect(links.documents).toEqual([
expect.objectContaining({ label: "MRI knee report", redacted: false }),
]);
expect(links.labResults).toEqual([
expect.objectContaining({ label: "CRP", redacted: false }),
]);
expect(links.conditions).toEqual([
expect.objectContaining({ label: "Meniscus tear", redacted: false }),
]);
expect(selection.conditions).toHaveLength(1);
const condition = selection.conditions![0];
expect(condition).toMatchObject({
id: seeded.tear.id,
label: "Meniscus tear",
bodySite: "knee",
laterality: "LEFT",
});
expect(condition.links.documents).toEqual([
expect.objectContaining({ label: "Physio letter", redacted: false }),
]);
expect(condition.links.visits).toEqual([
expect.objectContaining({
id: seeded.arthroscopy.id,
label: "PROCEDURE",
redacted: false,
}),
]);
});
it("a picked side leaves out the other side", async () => {
await seedRecord();
await signIn(OWNER_ID);
const { body } = await bodySites("site=Knee&laterality=RIGHT");
expect(body.data.selection!.visits).toEqual([]);
expect(body.data.selection!.conditions).toEqual([]);
});
it("with the illness module off, conditions are not read", async () => {
await seedRecord();
await getPrismaClient().user.update({
where: { id: OWNER_ID },
data: { modulePreferencesJson: { illness: false } },
});
await signIn(OWNER_ID);
const { body } = await bodySites("site=Knee");
expect(body.data.sites.map((s) => s.bodySite)).toEqual([
"Knee",
"Shoulder",
]);
expect(body.data.selection!.conditions).toBeNull();
expect(body.data.selection!.visits).toHaveLength(1);
});
it("refuses a side without a site", async () => {
await seedRecord();
await signIn(OWNER_ID);
const { status } = await bodySites("laterality=LEFT");
expect(status).toBe(422);
});
});
describe("GET /api/body-sites — delegates", () => {
it("profile-only delegate: no condition, no condition site, placeholders for links", async () => {
await seedRecord();
await switchInto(["profile"]);
const list = await bodySites();
expect(list.status).toBe(200);
// The condition-only site is not among the choices, and the knee counts
// only the procedure.
expect(list.body.data.sites).toEqual([
expect.objectContaining({
bodySite: "Knee",
procedures: 1,
conditions: 0,
}),
expect.objectContaining({ bodySite: "Shoulder" }),
]);
const { status, body } = await bodySites("site=Knee&laterality=LEFT");
expect(status).toBe(200);
const selection = body.data.selection!;
expect(selection.conditions).toBeNull();
const links = selection.visits[0].links!;
for (const family of [
links.documents,
links.labResults,
links.conditions,
]) {
expect(family).toHaveLength(1);
expect(family[0]).toMatchObject({
label: null,
date: null,
redacted: true,
});
}
// Nothing a grant did not cover appears anywhere in the answer.
const text = JSON.stringify(body);
for (const secret of [
"Meniscus",
"Lumbago",
"Lower back",
"MRI knee report",
"Physio letter",
"CRP",
]) {
expect(text).not.toContain(secret);
}
});
it("profile + illness delegate: conditions, with their documents as placeholders", async () => {
await seedRecord();
await switchInto(["profile", "illness"]);
const { status, body } = await bodySites("site=Knee");
expect(status).toBe(200);
const selection = body.data.selection!;
expect(selection.conditions).toHaveLength(1);
expect(selection.conditions![0].label).toBe("Meniscus tear");
expect(selection.conditions![0].links.documents).toEqual([
expect.objectContaining({ label: null, date: null, redacted: true }),
]);
// The visit's condition link is readable now; its document still is not.
const links = selection.visits[0].links!;
expect(links.conditions[0]).toMatchObject({
label: "Meniscus tear",
redacted: false,
});
expect(links.documents[0]).toMatchObject({ label: null, redacted: true });
const text = JSON.stringify(body);
expect(text).not.toContain("Physio letter");
expect(text).not.toContain("MRI knee report");
});
it("a grant without the visits' section is refused", async () => {
await seedRecord();
await switchInto(["illness"]);
const { status } = await bodySites();
expect(status).toBe(403);
});
});
describe("condition body site on the episode routes", () => {
async function patchEpisode(id: string, body: unknown) {
const { PATCH } = await import("@/app/api/illness/episodes/[id]/route");
return PATCH(
new Request(`http://localhost/api/illness/episodes/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never,
{ params: Promise.resolve({ id }) } as never,
);
}
it("stores the site as ciphertext", async () => {
await seedRecord();
await signIn(OWNER_ID);
const { POST } = await import("@/app/api/illness/episodes/route");
const res = await POST(
new Request("http://localhost/api/illness/episodes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
label: "Tennis elbow",
type: "INJURY",
bodySite: "Elbow",
laterality: "RIGHT",
}),
}) as never,
);
expect(res.status).toBe(201);
const created = (await res.json()).data as { id: string };
const row = await getPrismaClient().illnessEpisode.findUniqueOrThrow({
where: { id: created.id },
select: { bodySiteEncrypted: true, laterality: true },
});
expect(Buffer.from(row.bodySiteEncrypted!).toString("utf8")).not.toContain(
"Elbow",
);
expect(decryptFromBytes(row.bodySiteEncrypted!)).toBe("Elbow");
expect(row.laterality).toBe("RIGHT");
});
it("an edit from the shipped iPhone app leaves the site and side alone", async () => {
const { tear } = await seedRecord();
await signIn(OWNER_ID);
// What `IllnessEpisodePatch` encodes for "mark recovered with a note":
// optional scalars only when set, the three full-value keys always.
const res = await patchEpisode(tear.id, {
resolvedAt: daysFromNow(-1).toISOString(),
parentConditionId: null,
note: "Back to running",
});
expect(res.status).toBe(200);
const body = (await res.json()).data as {
bodySite: string | null;
laterality: string | null;
};
expect(body.bodySite).toBe("knee");
expect(body.laterality).toBe("LEFT");
const row = await getPrismaClient().illnessEpisode.findUniqueOrThrow({
where: { id: tear.id },
select: { bodySiteEncrypted: true, laterality: true, resolvedAt: true },
});
expect(decryptFromBytes(row.bodySiteEncrypted!)).toBe("knee");
expect(row.laterality).toBe("LEFT");
expect(row.resolvedAt).not.toBeNull();
});
it("the web client can change and clear it", async () => {
const { tear } = await seedRecord();
await signIn(OWNER_ID);
await patchEpisode(tear.id, { bodySite: "Left knee", laterality: null });
let row = await getPrismaClient().illnessEpisode.findUniqueOrThrow({
where: { id: tear.id },
select: { bodySiteEncrypted: true, laterality: true },
});
expect(decryptFromBytes(row.bodySiteEncrypted!)).toBe("Left knee");
expect(row.laterality).toBeNull();
await patchEpisode(tear.id, { bodySite: null });
row = await getPrismaClient().illnessEpisode.findUniqueOrThrow({
where: { id: tear.id },
select: { bodySiteEncrypted: true, laterality: true },
});
expect(row.bodySiteEncrypted).toBeNull();
});
});