mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +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.
487 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|