mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +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.
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
/**
|
|
* v1.42.2 (#1117) — `POST /api/search` against real Postgres.
|
|
*
|
|
* What the unit tests cannot show: that encrypted text is found after the
|
|
* decrypt whichever way the umlaut is typed, that the reads are narrowed to
|
|
* the record the session acts for (two accounts hold the same words here),
|
|
* that a switched-off module's rows are not read, that the per-source row cap
|
|
* really bounds a large record and says so, and that a delegate reads the
|
|
* owner's record only through a whole-record grant.
|
|
*
|
|
* Mutation checks (each run, each seen red):
|
|
* - drop `userId: ctx.recordId` from the mood-note read → "never returns the
|
|
* other account's records" goes red;
|
|
* - drop the `search:moodNote` surface entry → "a switched-off module
|
|
* contributes nothing" goes red;
|
|
* - let `scanNewest` read past `MAX_SCAN_ROWS_PER_SOURCE` → "stops at the
|
|
* scan cap" goes red.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { NextRequest } from "next/server";
|
|
|
|
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import { encryptNote } from "@/lib/crypto/note-cipher";
|
|
import type { SearchHit, SearchResponse } from "@/lib/search/contract";
|
|
import { MAX_SCAN_ROWS_PER_SOURCE } from "@/lib/search/search-records";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, switchSessionTo, 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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
let counter = 0;
|
|
|
|
async function makeUser(label: string, modules: Record<string, boolean> = {}) {
|
|
const suffix = `${label}-${counter++}`;
|
|
return getPrismaClient().user.create({
|
|
data: {
|
|
username: `rs-${suffix}`,
|
|
email: `rs-${suffix}@example.test`,
|
|
displayName: `RS ${suffix}`,
|
|
role: "USER",
|
|
timezone: "Europe/Berlin",
|
|
locale: "de",
|
|
modulePreferencesJson: modules,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function signIn(userId: string) {
|
|
const session = await getPrismaClient().session.create({
|
|
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return session;
|
|
}
|
|
|
|
async function search(q: string, limit?: number): Promise<Response> {
|
|
const { POST } = await import("@/app/api/search/route");
|
|
return (
|
|
POST as unknown as (
|
|
req: NextRequest,
|
|
ctx: { params: Promise<object> },
|
|
) => Promise<Response>
|
|
)(
|
|
new NextRequest("http://localhost/api/search", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(limit ? { q, limit } : { q }),
|
|
}),
|
|
{ params: Promise.resolve({}) },
|
|
);
|
|
}
|
|
|
|
async function hits(q: string, limit?: number): Promise<SearchResponse> {
|
|
const res = await search(q, limit);
|
|
expect(res.status).toBe(200);
|
|
return ((await res.json()) as { data: SearchResponse }).data;
|
|
}
|
|
|
|
function all(result: SearchResponse): SearchHit[] {
|
|
return result.groups.flatMap((g) => g.hits);
|
|
}
|
|
|
|
/** One account's record holding "Schulter" in every source that can hold it. */
|
|
async function seedRecord(userId: string, tag: string) {
|
|
const db = getPrismaClient();
|
|
const visit = await db.encounter.create({
|
|
data: {
|
|
userId,
|
|
occurredAt: new Date("2026-03-02T09:00:00Z"),
|
|
kind: "PROCEDURE",
|
|
reasonEncrypted: encryptToBytes(`Arthroskopie ${tag}`),
|
|
bodySiteEncrypted: encryptToBytes("Schulter"),
|
|
laterality: "LEFT",
|
|
},
|
|
});
|
|
const condition = await db.illnessEpisode.create({
|
|
data: {
|
|
userId,
|
|
label: `Schulterprellung ${tag}`,
|
|
type: "INJURY",
|
|
onsetAt: new Date("2026-02-20T09:00:00Z"),
|
|
},
|
|
});
|
|
const mood = await db.moodEntry.create({
|
|
data: {
|
|
userId,
|
|
date: "2026-03-05",
|
|
mood: "OKAY",
|
|
score: 3,
|
|
moodLoggedAt: new Date("2026-03-05T18:00:00Z"),
|
|
noteEncrypted: encryptNote(`Die SCHULTER zwickt ${tag}`),
|
|
},
|
|
});
|
|
const reading = await db.measurement.create({
|
|
data: {
|
|
userId,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-03-06T07:00:00Z"),
|
|
notesEncrypted: encryptNote(`nach der Schulter-OP ${tag}`),
|
|
},
|
|
});
|
|
const medication = await db.medication.create({
|
|
data: { userId, name: `Ibuprofen ${tag}`, dose: "400 mg" },
|
|
});
|
|
const lab = await db.labResult.create({
|
|
data: {
|
|
userId,
|
|
analyte: "Hämoglobin",
|
|
value: 14,
|
|
unit: "g/dL",
|
|
takenAt: new Date("2026-03-01T08:00:00Z"),
|
|
},
|
|
});
|
|
const symptom = await db.symptomDefinition.create({
|
|
data: { userId, labelEncrypted: encryptToBytes(`Schulterschmerz ${tag}`) },
|
|
});
|
|
const document = await db.inboundDocument.create({
|
|
data: {
|
|
userId,
|
|
title: `MRT Schulter ${tag}`,
|
|
mimeType: "application/pdf",
|
|
byteSize: 4,
|
|
contentEncrypted: encryptToBytes("%PDF"),
|
|
kind: "IMAGING",
|
|
},
|
|
});
|
|
return {
|
|
visit,
|
|
condition,
|
|
mood,
|
|
reading,
|
|
medication,
|
|
lab,
|
|
symptom,
|
|
document,
|
|
};
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
describe("the record search", () => {
|
|
it("finds encrypted and plaintext records, case and umlaut blind, each with a path that opens it", async () => {
|
|
const owner = await makeUser("owner");
|
|
const mine = await seedRecord(owner.id, "eigen");
|
|
await signIn(owner.id);
|
|
|
|
const result = await hits("schulter", 20);
|
|
const byId = new Map(all(result).map((h) => [h.id, h]));
|
|
|
|
expect(byId.get(`visit:${mine.visit.id}`)?.path).toBe(
|
|
`/checkups?visit=${mine.visit.id}`,
|
|
);
|
|
expect(byId.get(`condition:${mine.condition.id}`)?.path).toBe(
|
|
`/illness/${mine.condition.id}`,
|
|
);
|
|
expect(byId.get(`document:${mine.document.id}`)?.path).toBe(
|
|
`/documents?doc=${mine.document.id}`,
|
|
);
|
|
expect(byId.get(`moodNote:${mine.mood.id}`)?.path).toBe(
|
|
"/mood?day=2026-03-05",
|
|
);
|
|
expect(byId.get(`measurementNote:${mine.reading.id}`)?.path).toBe(
|
|
"/measurements?day=2026-03-06",
|
|
);
|
|
expect(byId.get(`symptom:${mine.symptom.id}`)?.path).toBe(
|
|
`/illness?symptom=${mine.symptom.id}`,
|
|
);
|
|
|
|
// The note's snippet marks the word as the person wrote it.
|
|
const mood = byId.get(`moodNote:${mine.mood.id}`)!;
|
|
expect(mood.source).toBe("mood");
|
|
const [[start, end]] = mood.snippet!.marks;
|
|
expect(mood.snippet!.text.slice(start, end)).toBe("SCHULTER");
|
|
|
|
// The visit names its site in context, in the reader's language.
|
|
const visit = byId.get(`visit:${mine.visit.id}`)!;
|
|
expect(visit.title?.text).toBe("Arthroskopie eigen");
|
|
expect(visit.context.map((c) => c.text).join(" ")).toContain("Schulter");
|
|
|
|
// Umlauts typed either way, in either direction.
|
|
const lab = all(await hits("haemoglobin"));
|
|
expect(lab.map((h) => h.path)).toContain(
|
|
`/labs?analyte=${encodeURIComponent("Hämoglobin")}`,
|
|
);
|
|
expect(all(await hits("HÄMOGLOBIN")).length).toBeGreaterThan(0);
|
|
|
|
const meds = all(await hits("ibuprofen"));
|
|
expect(meds.map((h) => h.path)).toEqual([
|
|
`/medications/${mine.medication.id}`,
|
|
]);
|
|
});
|
|
|
|
it("never returns the other account's records", async () => {
|
|
const owner = await makeUser("owner");
|
|
const other = await makeUser("other");
|
|
await seedRecord(owner.id, "eigen");
|
|
const theirs = await seedRecord(other.id, "fremd");
|
|
await signIn(owner.id);
|
|
|
|
const found = all(await hits("schulter", 20));
|
|
expect(found.length).toBeGreaterThan(5);
|
|
const theirIds = new Set(
|
|
Object.values(theirs).map((row) => (row as { id: string }).id),
|
|
);
|
|
for (const hit of found) {
|
|
expect(theirIds.has(hit.id.slice(hit.id.indexOf(":") + 1))).toBe(false);
|
|
expect(JSON.stringify(hit)).not.toContain("fremd");
|
|
}
|
|
expect(all(await hits("fremd"))).toEqual([]);
|
|
});
|
|
|
|
it("a switched-off module contributes nothing", async () => {
|
|
const owner = await makeUser("owner", {
|
|
mood: false,
|
|
illness: false,
|
|
medications: false,
|
|
});
|
|
const mine = await seedRecord(owner.id, "eigen");
|
|
await signIn(owner.id);
|
|
|
|
const found = all(await hits("schulter", 20));
|
|
const ids = found.map((h) => h.id);
|
|
expect(ids).toContain(`visit:${mine.visit.id}`);
|
|
expect(ids).not.toContain(`moodNote:${mine.mood.id}`);
|
|
expect(ids).not.toContain(`condition:${mine.condition.id}`);
|
|
expect(ids).not.toContain(`symptom:${mine.symptom.id}`);
|
|
expect(found.map((h) => h.kind)).not.toContain("condition");
|
|
expect(all(await hits("ibuprofen"))).toEqual([]);
|
|
});
|
|
|
|
it("stops at the scan cap on a large record and says so", async () => {
|
|
const db = getPrismaClient();
|
|
const owner = await makeUser("owner");
|
|
// The one matching note is the oldest; newer than it sit more noted
|
|
// readings than one search reads.
|
|
await db.measurement.create({
|
|
data: {
|
|
userId: owner.id,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: new Date("2020-01-01T07:00:00Z"),
|
|
notesEncrypted: encryptNote("Schulter"),
|
|
},
|
|
});
|
|
const filler = Array.from(
|
|
{ length: MAX_SCAN_ROWS_PER_SOURCE + 10 },
|
|
(_, i) => ({
|
|
userId: owner.id,
|
|
type: "WEIGHT" as const,
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: new Date(Date.UTC(2021, 0, 1) + i * 3_600_000),
|
|
notesEncrypted: encryptNote(`Waage ${i}`),
|
|
}),
|
|
);
|
|
await db.measurement.createMany({ data: filler });
|
|
await signIn(owner.id);
|
|
|
|
const capped = await hits("schulter");
|
|
expect(capped.truncated).toBe(true);
|
|
expect(all(capped)).toEqual([]);
|
|
|
|
// A newer match is found inside the cap, and the answer is not cut short
|
|
// when there is no more to read.
|
|
expect(all(await hits("Waage", 3)).length).toBe(3);
|
|
});
|
|
|
|
it("caps each group and says when there is more", async () => {
|
|
const db = getPrismaClient();
|
|
const owner = await makeUser("owner");
|
|
for (let i = 0; i < 4; i++) {
|
|
await db.moodEntry.create({
|
|
data: {
|
|
userId: owner.id,
|
|
date: `2026-01-0${i + 1}`,
|
|
mood: "OKAY",
|
|
score: 3,
|
|
moodLoggedAt: new Date(`2026-01-0${i + 1}T18:00:00Z`),
|
|
noteEncrypted: encryptNote(`Kopfweh ${i}`),
|
|
},
|
|
});
|
|
}
|
|
await signIn(owner.id);
|
|
const result = await hits("kopfweh", 2);
|
|
const notes = result.groups.find((g) => g.kind === "note")!;
|
|
expect(notes.hits).toHaveLength(2);
|
|
expect(notes.more).toBe(true);
|
|
// Newest first on a tie.
|
|
expect(notes.hits[0].day).toBe("2026-01-04");
|
|
});
|
|
|
|
it("a delegate searches the owner's record through a whole-record grant only", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
const mine = await seedRecord(owner.id, "eigen");
|
|
await seedRecord(delegate.id, "selbst");
|
|
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
|
|
|
const whole = await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
});
|
|
await acceptGrant({ grantId: whole.id, granteeId: delegate.id });
|
|
const session = await signIn(delegate.id);
|
|
await switchSessionTo(session.id, owner.id);
|
|
|
|
const found = all(await hits("schulter", 20));
|
|
expect(found.map((h) => h.id)).toContain(`visit:${mine.visit.id}`);
|
|
expect(JSON.stringify(found)).not.toContain("selbst");
|
|
});
|
|
|
|
it("a grant scoped to some sections does not open it", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await seedRecord(owner.id, "eigen");
|
|
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
|
const scoped = await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: ["profile"],
|
|
});
|
|
await acceptGrant({ grantId: scoped.id, granteeId: delegate.id });
|
|
const session = await signIn(delegate.id);
|
|
await switchSessionTo(session.id, owner.id);
|
|
|
|
const res = await search("schulter");
|
|
expect(res.status).toBe(403);
|
|
expect(await res.text()).not.toContain("Schulter");
|
|
});
|
|
|
|
it("refuses a query too short to search", async () => {
|
|
const owner = await makeUser("owner");
|
|
await signIn(owner.id);
|
|
expect((await search("s")).status).toBe(422);
|
|
});
|
|
|
|
it("reads the noted readings through the partial index", async () => {
|
|
const db = getPrismaClient();
|
|
const owner = await makeUser("owner");
|
|
await db.measurement.createMany({
|
|
data: Array.from({ length: 2000 }, (_, i) => ({
|
|
userId: owner.id,
|
|
type: "PULSE" as const,
|
|
value: 60,
|
|
unit: "bpm",
|
|
measuredAt: new Date(Date.UTC(2024, 0, 1) + i * 60_000),
|
|
})),
|
|
});
|
|
await db.$executeRawUnsafe("ANALYZE measurements");
|
|
const plan = await db.$queryRawUnsafe<Array<{ "QUERY PLAN": string }>>(
|
|
`EXPLAIN SELECT id FROM measurements
|
|
WHERE user_id = $1 AND deleted_at IS NULL
|
|
AND (notes_encrypted IS NOT NULL OR notes IS NOT NULL)
|
|
ORDER BY measured_at DESC, id DESC LIMIT 250`,
|
|
owner.id,
|
|
);
|
|
expect(plan.map((r) => r["QUERY PLAN"]).join("\n")).toContain(
|
|
"measurements_noted_live_idx",
|
|
);
|
|
});
|
|
});
|