From b1004958c32a04defec3392586eef9e7c19e14e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 11:06:33 +0200 Subject: [PATCH] test(documents): end-to-end vault contract against Postgres Pins what the unit mocks cannot: the binary2 round-trip through upload and /original with class-correct headers, the policy error contract, sha256 dedupe per user with the partial unique index live, Idempotency-Key replay without a second row, tombstone-inclusive quota accounting, pre-link + replace-set + kind/episode/year list filters, restore incl. both 409 shapes, bulk per-id semantics, usage with a per-user override, and the purge job claiming only past-grace tombstones. Migration 0227 applies clean on the fresh testcontainer as part of the ordered chain. Tasks: W1-T1, W1-T5, W1-T6, W1-T7, W1-T8, W1-T9, W1-T10, W1-T12. --- tests/integration/documents-vault.test.ts | 640 ++++++++++++++++++++++ 1 file changed, 640 insertions(+) create mode 100644 tests/integration/documents-vault.test.ts diff --git a/tests/integration/documents-vault.test.ts b/tests/integration/documents-vault.test.ts new file mode 100644 index 000000000..0325a9f68 --- /dev/null +++ b/tests/integration/documents-vault.test.ts @@ -0,0 +1,640 @@ +/** + * Document vault — end-to-end contract against a real Postgres. + * + * Covers the Wave-1 hardenings the unit mocks cannot pin: + * + * 1. Upload → binary2-encrypted row + plaintext sha256; /original + * round-trips the exact bytes with the class-correct headers. + * 2. Policy error contract: 413 fileTooLarge / quotaExceeded (with limits + * in meta), 415 unsupportedType. + * 3. sha256 dedupe: a same-user re-upload returns 200 + meta.duplicate and + * the existing row id; the partial unique index holds at the DB level. + * 4. Idempotency-Key replay returns the cached response without a second + * row. + * 5. Quota accounting is tombstone-inclusive (a soft-deleted row still + * counts against usage until purged). + * 6. episodeIds pre-link + PATCH replace-set + list filters (kind[], + * episodeId, year) — and the list never leaks the blob. + * 7. Restore: clears the tombstone; 409 after purge; 409 on a live + * duplicate of the same bytes. + * 8. Bulk endpoint per-id semantics. + * 9. Usage endpoint reflects overrides + tombstone-inclusive usage. + * 10. The purge job hard-deletes only past-grace tombstones. + */ +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", +); +const PDF_SMALL = Buffer.from("%PDF-1.7\n1 0 obj\n<<>>\nendobj\n%%EOF\n"); + +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", + // Opt-in module: the vault is dark until the user flips it on. + modulePreferencesJson: { inboundDocuments: 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, + fields: Record = {}, + headers: Record = {}, +): Request { + const payload = new Uint8Array(bytes.byteLength); + payload.set(bytes); + const formData = new FormData(); + formData.append("file", new Blob([payload]), filename); + for (const [k, v] of Object.entries(fields)) { + for (const item of Array.isArray(v) ? v : [v]) formData.append(k, item); + } + return new Request("http://localhost/api/documents/inbound", { + method: "POST", + body: formData, + headers, + }); +} + +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 original = + await import("@/app/api/documents/inbound/[id]/original/route"); + const restore = + await import("@/app/api/documents/inbound/[id]/restore/route"); + const bulk = await import("@/app/api/documents/inbound/bulk/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, + patch: byId.PATCH as unknown as ( + r: Request, + c: RouteCtx, + ) => Promise, + del: byId.DELETE as unknown as ( + r: Request, + c: RouteCtx, + ) => Promise, + getOriginal: original.GET as unknown as ( + r: Request, + c: RouteCtx, + ) => Promise, + postRestore: restore.POST as unknown as ( + r: Request, + c: RouteCtx, + ) => Promise, + postBulk: bulk.POST as unknown as (r: Request) => Promise, + getUsage: usage.GET as unknown as (r: Request) => Promise, + }; +} + +function jsonRequest(url: string, method: string, body: unknown): Request { + return new Request(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("document vault — upload + serve round-trip", () => { + it("stores binary2-encrypted with sha256 and serves the exact bytes back", async () => { + await seedVaultUser("vault-roundtrip"); + const { post, getOriginal } = await routes(); + + const res = await post( + uploadRequest(PNG_1X1, "scan.png", { title: "Röntgen Knie" }), + ); + expect(res.status).toBe(201); + const { data } = await res.json(); + expect(data.servingClass).toBe("inline"); + + // At rest: binary2 codec, plaintext sha256, ciphertext ≠ plaintext. + const row = await getPrismaClient().inboundDocument.findFirstOrThrow(); + expect(row.contentCodec).toBe("binary2"); + expect(row.contentSha256).toMatch(/^[0-9a-f]{64}$/); + expect(Buffer.from(row.contentEncrypted).equals(PNG_1X1)).toBe(false); + + // Round-trip through the serve route: exact bytes + Class A posture. + const orig = await getOriginal( + new Request(`http://localhost/api/documents/inbound/${data.id}/original`), + ctx(data.id), + ); + expect(orig.status).toBe(200); + expect(orig.headers.get("Content-Type")).toBe("image/png"); + expect(orig.headers.get("Content-Disposition")).toContain("inline"); + expect(orig.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(orig.headers.get("Content-Security-Policy")).toBe("sandbox"); + const served = Buffer.from(await orig.arrayBuffer()); + expect(served.equals(PNG_1X1)).toBe(true); + }); + + it("serves a Class B upload attachment-only as octet-stream", async () => { + await seedVaultUser("vault-classb"); + const { post, getOriginal } = await routes(); + + const res = await post( + uploadRequest(Buffer.from("Blutdruck 120/80 morgens\n"), "werte.csv"), + ); + expect(res.status).toBe(201); + const { data } = await res.json(); + expect(data.mimeType).toBe("text/csv"); + expect(data.servingClass).toBe("attachment"); + + const orig = await getOriginal( + new Request(`http://localhost/api/documents/inbound/${data.id}/original`), + ctx(data.id), + ); + expect(orig.status).toBe(200); + expect(orig.headers.get("Content-Type")).toBe("application/octet-stream"); + expect( + (orig.headers.get("Content-Disposition") ?? "").startsWith("attachment;"), + ).toBe(true); + expect(orig.headers.get("Content-Security-Policy")).toBeNull(); + }); +}); + +describe("document vault — policy error contract", () => { + it("415s an unsupported type with meta.reason", async () => { + await seedVaultUser("vault-415"); + const { post } = await routes(); + const res = await post( + uploadRequest(Buffer.from([0x01, 0x02, 0x03, 0x04]), "blob.bin"), + ); + expect(res.status).toBe(415); + const body = await res.json(); + expect(body.meta.reason).toBe("unsupportedType"); + }); + + it("413s over the admin-set per-file cap with the limit in meta", async () => { + await seedVaultUser("vault-cap"); + await getPrismaClient().appSettings.upsert({ + where: { id: "singleton" }, + update: { documentMaxFileBytes: 20 }, + create: { id: "singleton", documentMaxFileBytes: 20 }, + }); + const { post } = await routes(); + const res = await post(uploadRequest(PNG_1X1, "big.png")); + expect(res.status).toBe(413); + const body = await res.json(); + expect(body.meta.reason).toBe("fileTooLarge"); + expect(body.meta.maxFileBytes).toBe(20); + expect(await getPrismaClient().inboundDocument.count()).toBe(0); + }); + + it("413s past the quota — and tombstoned rows still count against it", async () => { + const user = await seedVaultUser("vault-quota"); + // Quota fits exactly one PNG (70 B) but not two. + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { documentQuotaBytes: BigInt(PNG_1X1.byteLength + 10) }, + }); + const { post, del } = await routes(); + + const first = await post(uploadRequest(PNG_1X1, "one.png")); + expect(first.status).toBe(201); + const firstId = (await first.json()).data.id as string; + + // Soft-delete the first upload: the tombstone KEEPS holding quota. + const delRes = await del( + new Request(`http://localhost/api/documents/inbound/${firstId}`, { + method: "DELETE", + }), + ctx(firstId), + ); + expect(delRes.status).toBe(200); + + const second = await post(uploadRequest(PDF_SMALL, "two.pdf")); + expect(second.status).toBe(413); + const body = await second.json(); + expect(body.meta.reason).toBe("quotaExceeded"); + expect(body.meta.usedBytes).toBe(PNG_1X1.byteLength); + expect(body.meta.quotaBytes).toBe(PNG_1X1.byteLength + 10); + }); +}); + +describe("document vault — dedupe + idempotency", () => { + it("returns the existing live row + meta.duplicate on a same-bytes re-upload", async () => { + await seedVaultUser("vault-dedupe"); + const { post } = await routes(); + + const first = await post(uploadRequest(PNG_1X1, "scan.png")); + expect(first.status).toBe(201); + const firstId = (await first.json()).data.id as string; + + const second = await post(uploadRequest(PNG_1X1, "scan-copy.png")); + expect(second.status).toBe(200); + const body = await second.json(); + expect(body.meta.duplicate).toBe(true); + expect(body.data.id).toBe(firstId); + expect(await getPrismaClient().inboundDocument.count()).toBe(1); + }); + + it("dedupes per user, not globally", async () => { + await seedVaultUser("vault-user-a"); + const { post } = await routes(); + expect((await post(uploadRequest(PNG_1X1, "a.png"))).status).toBe(201); + + cookieJar.clear(); + await seedVaultUser("vault-user-b"); + expect((await post(uploadRequest(PNG_1X1, "b.png"))).status).toBe(201); + expect(await getPrismaClient().inboundDocument.count()).toBe(2); + }); + + it("replays an Idempotency-Key without creating a second row", async () => { + await seedVaultUser("vault-idem"); + const { post } = await routes(); + const key = "vault-upload-abc123"; + + const first = await post( + uploadRequest(PDF_SMALL, "brief.pdf", {}, { "Idempotency-Key": key }), + ); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const replay = await post( + uploadRequest(PDF_SMALL, "brief.pdf", {}, { "Idempotency-Key": key }), + ); + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + const replayBody = await replay.json(); + expect(replayBody.data.id).toBe(firstBody.data.id); + expect(await getPrismaClient().inboundDocument.count()).toBe(1); + }); +}); + +describe("document vault — links + list filters", () => { + it("pre-links at upload, replace-sets via PATCH, filters by kind/episode/year", async () => { + const user = await seedVaultUser("vault-links"); + const prisma = getPrismaClient(); + const knie = await prisma.illnessEpisode.create({ + data: { + userId: user.id, + label: "Knie", + type: "OTHER", + onsetAt: new Date("2025-09-01T00:00:00.000Z"), + }, + }); + const ruecken = await prisma.illnessEpisode.create({ + data: { + userId: user.id, + label: "Rücken", + type: "OTHER", + onsetAt: new Date("2025-01-01T00:00:00.000Z"), + }, + }); + const { post, get, patch } = await routes(); + + const created = await post( + uploadRequest(PDF_SMALL, "mrt-knie.pdf", { + title: "MRT Knie", + kind: "IMAGING", + documentDate: "2025-10-04", + episodeIds: [knie.id], + }), + ); + expect(created.status).toBe(201); + const doc = (await created.json()).data; + expect(doc.conditionLinks).toEqual([{ episodeId: knie.id, name: "Knie" }]); + + // Second (unlinked, different year) document for filter contrast. + const other = await post( + uploadRequest(PNG_1X1, "impfpass.png", { + kind: "VACCINATION", + documentDate: "2024-03-01", + }), + ); + expect(other.status).toBe(201); + + // kind[] facet (OR inside), episode filter, year filter — AND across. + const filtered = await get( + new Request( + `http://localhost/api/documents/inbound?kind=IMAGING,LAB_RESULT&episodeId=${knie.id}&year=2025`, + ), + ); + expect(filtered.status).toBe(200); + const page = (await filtered.json()).data; + expect(page.documents).toHaveLength(1); + expect(page.documents[0].title).toBe("MRT Knie"); + // The list DTO never carries blob material. + expect("contentEncrypted" in page.documents[0]).toBe(false); + + // Year filter alone excludes the 2025 document. + const y2024 = await get( + new Request("http://localhost/api/documents/inbound?year=2024"), + ); + expect((await y2024.json()).data.documents).toHaveLength(1); + expect( + ( + await ( + await get(new Request("http://localhost/api/documents/inbound")) + ).json() + ).data.documents, + ).toHaveLength(2); + + // PATCH replace-set: Knie → Rücken (relink), then [] (unlink-all). + const relinked = await patch( + jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", { + episodeIds: [ruecken.id], + }), + ctx(doc.id), + ); + expect(relinked.status).toBe(200); + expect((await relinked.json()).data.conditionLinks).toEqual([ + { episodeId: ruecken.id, name: "Rücken" }, + ]); + + const unlinked = await patch( + jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", { + episodeIds: [], + }), + ctx(doc.id), + ); + expect((await unlinked.json()).data.conditionLinks).toEqual([]); + + // Foreign episode id → 404-shaped refusal. + cookieJar.clear(); + const stranger = await seedVaultUser("vault-links-stranger"); + void stranger; + const foreign = await patch( + jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", { + episodeIds: [knie.id], + }), + ctx(doc.id), + ); + expect(foreign.status).toBe(404); + }); +}); + +describe("document vault — delete / restore / purge", () => { + it("restore clears the tombstone; purge-then-restore 409s; duplicate restore 409s", async () => { + await seedVaultUser("vault-restore"); + const prisma = getPrismaClient(); + const { post, del, postRestore } = await routes(); + + const created = await post(uploadRequest(PNG_1X1, "scan.png")); + const docId = (await created.json()).data.id as string; + + await del( + new Request(`http://localhost/api/documents/inbound/${docId}`, { + method: "DELETE", + }), + ctx(docId), + ); + expect( + (await prisma.inboundDocument.findFirstOrThrow()).deletedAt, + ).not.toBeNull(); + + // Restore inside the grace window succeeds and the row is live again. + const restored = await postRestore( + new Request(`http://localhost/api/documents/inbound/${docId}/restore`, { + method: "POST", + }), + ctx(docId), + ); + expect(restored.status).toBe(200); + const row = await prisma.inboundDocument.findFirstOrThrow(); + expect(row.deletedAt).toBeNull(); + expect(row.status).toBe("STORED"); + + // Tombstone again, upload the same bytes fresh (allowed — the partial + // index only guards LIVE rows), then the tombstone's restore conflicts. + await del( + new Request(`http://localhost/api/documents/inbound/${docId}`, { + method: "DELETE", + }), + ctx(docId), + ); + const reupload = await post(uploadRequest(PNG_1X1, "scan-again.png")); + expect(reupload.status).toBe(201); + + const conflicted = await postRestore( + new Request(`http://localhost/api/documents/inbound/${docId}/restore`, { + method: "POST", + }), + ctx(docId), + ); + expect(conflicted.status).toBe(409); + expect((await conflicted.json()).meta.reason).toBe("duplicateExists"); + + // Hard-purge the tombstone → restore now answers 409 (undo window over). + await prisma.inboundDocument.delete({ where: { id: docId } }); + const gone = await postRestore( + new Request(`http://localhost/api/documents/inbound/${docId}/restore`, { + method: "POST", + }), + ctx(docId), + ); + expect(gone.status).toBe(409); + expect((await gone.json()).meta.reason).toBe("purged"); + }); + + it("the purge job hard-deletes only past-grace tombstones", async () => { + const user = await seedVaultUser("vault-purge"); + const prisma = getPrismaClient(); + const { post, del } = await routes(); + const { purgeExpiredDocumentTombstones } = + await import("@/lib/jobs/document-purge"); + + const a = (await (await post(uploadRequest(PNG_1X1, "old.png"))).json()) + .data.id as string; + const b = (await (await post(uploadRequest(PDF_SMALL, "fresh.pdf"))).json()) + .data.id as string; + const c = ( + await ( + await post(uploadRequest(Buffer.from("text file\n"), "live.txt")) + ).json() + ).data.id as string; + + // a: tombstoned 31 days ago (past grace). b: tombstoned now. c: live. + for (const id of [a, b]) { + await del( + new Request(`http://localhost/api/documents/inbound/${id}`, { + method: "DELETE", + }), + ctx(id), + ); + } + await prisma.inboundDocument.update({ + where: { id: a }, + data: { deletedAt: new Date(Date.now() - 31 * 86_400_000) }, + }); + + const purged = await purgeExpiredDocumentTombstones(prisma); + expect(purged).toBe(1); + + const remaining = await prisma.inboundDocument.findMany({ + where: { userId: user.id }, + select: { id: true }, + orderBy: { id: "asc" }, + }); + expect(remaining.map((r) => r.id).sort()).toEqual([b, c].sort()); + }); +}); + +describe("document vault — bulk + usage", () => { + it("bulk applies per-id with partial-failure results", async () => { + const user = await seedVaultUser("vault-bulk"); + const prisma = getPrismaClient(); + const episode = await prisma.illnessEpisode.create({ + data: { + userId: user.id, + label: "Knie", + type: "OTHER", + onsetAt: new Date("2025-09-01T00:00:00.000Z"), + }, + }); + const { post, postBulk } = await routes(); + + const id1 = (await (await post(uploadRequest(PNG_1X1, "a.png"))).json()) + .data.id as string; + const id2 = (await (await post(uploadRequest(PDF_SMALL, "b.pdf"))).json()) + .data.id as string; + + // setKind over one real + one unknown id → per-id ok/notFound. + const setKind = await postBulk( + jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", { + ids: [id1, "does-not-exist"], + action: "setKind", + kind: "LAB_RESULT", + }), + ); + expect(setKind.status).toBe(200); + const { results } = (await setKind.json()).data; + expect(results).toEqual([ + { id: id1, ok: true, error: null }, + { id: "does-not-exist", ok: false, error: "notFound" }, + ]); + expect( + (await prisma.inboundDocument.findUniqueOrThrow({ where: { id: id1 } })) + .kind, + ).toBe("LAB_RESULT"); + + // linkEpisode + delete + restore round-trip through bulk. + const link = await postBulk( + jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", { + ids: [id1, id2], + action: "linkEpisode", + episodeId: episode.id, + }), + ); + expect( + (await link.json()).data.results.every((r: { ok: boolean }) => r.ok), + ).toBe(true); + expect(await prisma.documentConditionLink.count()).toBe(2); + + const bulkDelete = await postBulk( + jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", { + ids: [id1, id2], + action: "delete", + }), + ); + expect(bulkDelete.status).toBe(200); + expect( + await prisma.inboundDocument.count({ where: { deletedAt: null } }), + ).toBe(0); + + const bulkRestore = await postBulk( + jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", { + ids: [id1, id2], + action: "restore", + }), + ); + expect(bulkRestore.status).toBe(200); + expect( + await prisma.inboundDocument.count({ where: { deletedAt: null } }), + ).toBe(2); + + // Cap: 101 ids → 422 (schema bound). + const over = await postBulk( + jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", { + ids: Array.from({ length: 101 }, (_, i) => `id-${i}`), + action: "delete", + }), + ); + expect(over.status).toBe(422); + }); + + it("usage reflects the override + tombstone-inclusive accounting", async () => { + const user = await seedVaultUser("vault-usage"); + const prisma = getPrismaClient(); + await prisma.user.update({ + where: { id: user.id }, + data: { documentQuotaBytes: BigInt(123_456_789) }, + }); + const { post, del, getUsage } = await routes(); + + const id = (await (await post(uploadRequest(PNG_1X1, "a.png"))).json()).data + .id as string; + await del( + new Request(`http://localhost/api/documents/inbound/${id}`, { + method: "DELETE", + }), + ctx(id), + ); + + const res = await getUsage( + new Request("http://localhost/api/documents/inbound/usage"), + ); + expect(res.status).toBe(200); + const { data } = await res.json(); + // Tombstoned bytes still count until the purge reclaims them. + expect(data.usedBytes).toBe(PNG_1X1.byteLength); + expect(data.quotaBytes).toBe(123_456_789); + expect(data.maxFileBytes).toBe(26_214_400); + expect(data.acceptedExtensions).toContain(".pdf"); + expect(data.acceptedExtensions).not.toContain(".heic"); + }); +});