diff --git a/tests/integration/documents-content-index.test.ts b/tests/integration/documents-content-index.test.ts new file mode 100644 index 000000000..76856c5fc --- /dev/null +++ b/tests/integration/documents-content-index.test.ts @@ -0,0 +1,245 @@ +/** + * Document vault P2 — content-search index end-to-end against a real Postgres. + * + * Pins what the unit mocks cannot: + * 1. Migration 0228 applied clean (the table + GIN index exist) — implied by + * any successful upsert/search below. + * 2. The TEXT-mode index route persists ONLY ciphertext + opaque HMAC tokens + * (A4): the stored `text_encrypted` never contains the plaintext body and + * `search_tokens` are 16-char hex tags, not words. + * 3. Content search: a whole word that appears ONLY in the body (not the + * title/filename) finds the document through the GIN array-overlap union; + * a word that appears nowhere does not; the list still omits the blob. + * 4. `hasContentIndex` + the usage `contentIndex` gauge reflect the index. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +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 PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==", + "base64", +); + +async function seedVaultUser(username: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role: "USER", + timezone: "Europe/Berlin", + modulePreferencesJson: { inboundDocuments: true }, + // TEXT-mode indexing rides the existing local-OCR opt-in (no provider). + labsLocalOcrEnabled: true, + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +function uploadRequest( + bytes: Buffer, + filename: string, + title?: string, +): Request { + const payload = new Uint8Array(bytes.byteLength); + payload.set(bytes); + const formData = new FormData(); + formData.append("file", new Blob([payload]), filename); + if (title) formData.append("title", title); + return new Request("http://localhost/api/documents/inbound", { + method: "POST", + body: formData, + }); +} + +type RouteCtx = { params: Promise<{ id: string }> }; +const ctx = (id: string): RouteCtx => ({ params: Promise.resolve({ id }) }); + +async function routes() { + const list = await import("@/app/api/documents/inbound/route"); + const byId = await import("@/app/api/documents/inbound/[id]/route"); + const index = await import("@/app/api/documents/inbound/[id]/index/route"); + const usage = await import("@/app/api/documents/inbound/usage/route"); + return { + post: list.POST as unknown as (r: Request) => Promise, + get: list.GET as unknown as (r: Request) => Promise, + getById: byId.GET as unknown as ( + r: Request, + c: RouteCtx, + ) => Promise, + postIndex: index.POST as unknown as ( + r: Request, + c: RouteCtx, + ) => Promise, + getUsage: usage.GET as unknown as (r: Request) => Promise, + }; +} + +function textIndexRequest(id: string, text: string): Request { + return new Request(`http://localhost/api/documents/inbound/${id}/index`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "text", text }), + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("document vault — content index (text mode)", () => { + it("persists only ciphertext + opaque tokens and finds a body-only word", async () => { + await seedVaultUser("vault-index"); + const { post, get, getById, postIndex, getUsage } = await routes(); + + // Title/filename deliberately share NO word with the indexed body. + const up = await post(uploadRequest(PNG_1X1, "scan.png", "Arztbrief")); + expect(up.status).toBe(201); + const { data: doc } = await up.json(); + + const bodyWord = "leukozyten"; + const idx = await postIndex( + textIndexRequest( + doc.id, + `Befund: ${bodyWord} erhoeht, Kontrolle empfohlen`, + ), + ctx(doc.id), + ); + expect(idx.status).toBe(200); + const idxBody = await idx.json(); + expect(idxBody.data.indexed).toBe(true); + expect(idxBody.data.tokenCount).toBeGreaterThan(0); + + // A4 — at rest: ciphertext text (never the plaintext body) + opaque tags. + const row = await getPrismaClient().documentContentIndex.findFirstOrThrow(); + expect(row.source).toBe("text-ocr"); + expect(Buffer.from(row.textEncrypted).toString("utf8")).not.toContain( + bodyWord, + ); + expect(row.searchTokens.length).toBeGreaterThan(0); + for (const token of row.searchTokens) { + expect(token).toMatch(/^[0-9a-f]{16}$/); + expect(token).not.toContain(bodyWord); + } + + // Content search: the body-only word finds the document … + const hit = await get( + new Request(`http://localhost/api/documents/inbound?q=${bodyWord}`), + ); + expect(hit.status).toBe(200); + const hitBody = await hit.json(); + expect(hitBody.data.documents.map((d: { id: string }) => d.id)).toContain( + doc.id, + ); + // … the list still never leaks the blob … + expect("contentEncrypted" in hitBody.data.documents[0]).toBe(false); + expect(hitBody.data.documents[0].hasContentIndex).toBe(true); + + // … and a word that appears nowhere does not. + const miss = await get( + new Request("http://localhost/api/documents/inbound?q=zznonexistentzz"), + ); + const missBody = await miss.json(); + expect(missBody.data.documents).toHaveLength(0); + + // Detail + usage reflect the index. + const detail = await getById( + new Request(`http://localhost/api/documents/inbound/${doc.id}`), + ctx(doc.id), + ); + expect((await detail.json()).data.hasContentIndex).toBe(true); + + const usage = await getUsage( + new Request("http://localhost/api/documents/inbound/usage"), + ); + const usageBody = await usage.json(); + expect(usageBody.data.contentIndex).toMatchObject({ + indexedCount: 1, + totalCount: 1, + }); + }); + + it("re-indexing the same document is idempotent (upsert in place)", async () => { + await seedVaultUser("vault-reindex"); + const { post, postIndex } = await routes(); + + const up = await post(uploadRequest(PNG_1X1, "scan.png")); + const { data: doc } = await up.json(); + + await postIndex( + textIndexRequest(doc.id, "erste fassung glukose"), + ctx(doc.id), + ); + await postIndex( + textIndexRequest(doc.id, "zweite fassung cholesterin"), + ctx(doc.id), + ); + + const rows = await getPrismaClient().documentContentIndex.findMany({ + where: { documentId: doc.id }, + }); + expect(rows).toHaveLength(1); + }); + + it("422s TEXT mode when local OCR is not enabled", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "vault-no-ocr", + email: "vault-no-ocr@example.test", + role: "USER", + timezone: "Europe/Berlin", + modulePreferencesJson: { inboundDocuments: true }, + labsLocalOcrEnabled: false, + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + const { post, postIndex } = await routes(); + const up = await post(uploadRequest(PNG_1X1, "scan.png")); + const { data: doc } = await up.json(); + + const idx = await postIndex( + textIndexRequest(doc.id, "irgendein text"), + ctx(doc.id), + ); + expect(idx.status).toBe(422); + const body = await idx.json(); + expect(body.meta?.errorCode).toBe("documents.inbound.localOcrDisabled"); + }); +});