From c899f5054e03a1b5a829e88b9e9c311bbedbfdfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 14:32:15 +0200 Subject: [PATCH] test(e2e): document-vault acceptance suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the doctor-flow script (type chip, condition chip, text search — three independent routes to one document), the URL deep-link contract (?kind / ?episode / ?year / ?doc), delete with undo, the bulk flows (50-item kind change, per-id partial failure, client-side chunking above 100 ids), keyboard-only navigation over the roving grid, page-wide drop and clipboard-paste intake, the duplicate-upload contract, the admin per-file cap (commit-on-Enter, rejection copy naming the limit), the touch long-press selection on the mobile project, and axe scans over the timeline, the open detail sheet, and bulk mode. The fixture seeds the 60-document corpus straight through Postgres — the upload endpoint's 60/hour bucket cannot carry API seeding across two Playwright projects — with blobs encrypted in the same binary2 layout the server writes, so previews exercise the real decrypt path. Idempotent throughout; mutating tests use disposable namespaces. --- e2e/documents.spec.ts | 583 +++++++++++++++++++++++++++++++++++++ e2e/setup/vault-fixture.ts | 301 +++++++++++++++++++ 2 files changed, 884 insertions(+) create mode 100644 e2e/documents.spec.ts create mode 100644 e2e/setup/vault-fixture.ts diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts new file mode 100644 index 000000000..9d9cfb339 --- /dev/null +++ b/e2e/documents.spec.ts @@ -0,0 +1,583 @@ +/** + * Document vault — the release acceptance suite. + * + * Pins the doctor-flow script (three independent routes to one document: + * type chip, condition chip, text search), the URL deep-link contract + * (`?kind` / `?episode` / `?year` / `?doc`), delete→undo, the bulk flows + * (50-item kind change, partial failure, the >100 client-side chunking), + * keyboard-only navigation, page-wide drop + clipboard paste intake, the + * duplicate-upload contract, the admin per-file cap (commit-on-Enter + + * rejection copy naming the limit), and axe scans over the timeline, the + * open detail sheet, and bulk mode. + * + * Fixture: `e2e/setup/vault-fixture.ts` seeds the 60-document corpus (plus + * the "Knie" episode and the linked MRT trio) straight through Postgres — + * idempotent, so the desktop and mobile projects can both (re-)run it. + * Mutating tests operate on their own seeded namespaces or on documents + * they create, never on the shared doctor-flow corpus. + */ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + ensureVaultFixture, + seedNamespaceDocs, + KNIE_EPISODE_ID, + MRT_DOC_ID, +} from "./setup/vault-fixture"; + +const DEFAULT_MAX_FILE_BYTES = 26_214_400; + +/** Scroll the shell's scroll container until `locator` is visible (the + * timeline is virtualized — off-window rows are not in the DOM at all). */ +async function scrollUntilVisible( + page: Page, + locator: ReturnType, + maxSteps = 40, +): Promise { + for (let i = 0; i < maxSteps; i++) { + if (await locator.isVisible().catch(() => false)) return; + await page + .locator("#main-content") + .evaluate((el) => el.scrollBy(0, el.clientHeight)); + await page.waitForTimeout(150); + } + await expect(locator).toBeVisible(); +} + +/** The invisible whole-card open button for a document titled `title`. */ +function openButton(page: Page, title: string) { + return page.getByRole("button", { name: `Open ${title}` }).first(); +} + +/** The card checkbox for a document titled `title`. */ +function selectBox(page: Page, title: string) { + return page.getByRole("checkbox", { name: `Select ${title}` }).first(); +} + +/** Upload `bytes` through the picker input and return when queued. */ +async function uploadViaPicker( + page: Page, + name: string, + bytes: Buffer, + mimeType = "application/pdf", +): Promise { + await page + .locator('[data-slot="document-upload-zone"] input[type="file"]') + .setInputFiles([{ name, mimeType, buffer: bytes }]); +} + +function uniquePdf(marker: string): Buffer { + return Buffer.from( + `%PDF-1.4\n%${marker}-${Date.now()}-${Math.random()}\n1 0 obj<>endobj\n%%EOF\n`, + "utf8", + ); +} + +async function runAxe(page: Page) { + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + const blocking = results.violations.filter( + (v) => v.impact === "serious" || v.impact === "critical", + ); + if (blocking.length > 0) { + // Pretty-print so failures are actionable in CI logs. + console.log( + "axe violations:\n" + + blocking + .map( + (v) => + ` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes + .map((n) => n.target.join(" ")) + .join("\n ")}`, + ) + .join("\n"), + ); + } + return blocking; +} + +test.describe("document vault", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeAll(async () => { + await ensureVaultFixture(); + }); + + // ── §1A — the 30-second doctor flow, three independent routes ───────── + + test("doctor flow A: the type chip reaches the MRT report", async ({ + page, + }) => { + await page.goto("/documents"); + await expect( + page.getByRole("heading", { name: "Documents" }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Imaging", exact: true }).click(); + // Autumn 2025 sits below the newest sections — scroll the window down. + await scrollUntilVisible(page, openButton(page, "MRT Knie")); + await openButton(page, "MRT Knie").click(); + + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + await expect(sheet.getByText("MRT Knie").first()).toBeVisible(); + // Inline preview (Class A PDF) mounts against the serve route. + await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 }); + }); + + test("doctor flow B: the condition chip reaches the MRT report", async ({ + page, + }) => { + await page.goto("/documents"); + await page + .locator('[data-slot="document-filter-bar"]') + .getByRole("button", { name: "Knie", exact: true }) + .click(); + + // The Knie filter narrows the corpus to the linked MRT trio. + await expect(openButton(page, "MRT Knie")).toBeVisible(); + await expect(openButton(page, "MRT Aufnahme 1")).toBeVisible(); + await openButton(page, "MRT Knie").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + }); + + test("doctor flow C: text search reaches the MRT report", async ({ + page, + }) => { + await page.goto("/documents"); + await page.getByRole("searchbox", { name: "Search documents" }).fill("MRT"); + // 200 ms debounce → URL → refetch. + await expect(openButton(page, "MRT Knie")).toBeVisible(); + await openButton(page, "MRT Knie").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page).toHaveURL(/q=MRT/); + }); + + // ── URL deep links ───────────────────────────────────────────────────── + + test("deep links land filtered: ?kind, ?year, ?episode, ?doc", async ({ + page, + }) => { + await page.goto("/documents?kind=IMAGING&year=2025"); + await expect( + page.getByRole("button", { name: "Imaging", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + await expect( + page.getByRole("button", { name: "2025", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + await scrollUntilVisible(page, openButton(page, "MRT Knie")); + + await page.goto(`/documents?episode=${KNIE_EPISODE_ID}`); + await expect(openButton(page, "MRT Knie")).toBeVisible(); + await expect(openButton(page, "MRT Aufnahme 2")).toBeVisible(); + + // `?doc=` opens the detail sheet; closing it strips the param. + await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&doc=${MRT_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + await expect(sheet.getByText("MRT Knie").first()).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(sheet).not.toBeVisible(); + await expect(page).not.toHaveURL(/doc=/); + }); + + test("the condition page links into the vault and back", async ({ page }) => { + await page.goto(`/illness/${KNIE_EPISODE_ID}`); + const card = page.locator('[data-slot="episode-documents-card"]'); + await expect(card).toBeVisible(); + await card.getByRole("link", { name: /MRT Knie/ }).click(); + // Lands on the vault, episode-filtered, with the detail sheet open. + await expect(page).toHaveURL(/\/documents\?episode=/); + await expect(page.getByRole("dialog")).toBeVisible(); + }); + + // ── Delete → undo ────────────────────────────────────────────────────── + + test("delete then undo restores the document", async ({ page }, testInfo) => { + // Unique per run — a stable name would leave a same-named twin from a + // previous run in the corpus and break the disappearance assertion. + const title = `undo-probe-${testInfo.project.name}-${Date.now()}`; + await page.goto("/documents"); + await uploadViaPicker(page, `${title}.pdf`, uniquePdf(title)); + await page.getByRole("searchbox", { name: "Search documents" }).fill(title); + await expect(openButton(page, `${title}.pdf`)).toBeVisible(); + + await openButton(page, `${title}.pdf`).click(); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + await sheet.getByRole("button", { name: "Delete" }).click(); + + // Undo toast → restore → the card returns. + await expect(page.getByText("Document deleted.")).toBeVisible(); + await expect(openButton(page, `${title}.pdf`)).not.toBeVisible(); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(openButton(page, `${title}.pdf`)).toBeVisible(); + }); + + // ── Bulk flows (desktop-only: shift-click ranges are a pointer flow) ── + + test("bulk: 50-item kind change round-trips", async ({ page, isMobile }) => { + test.skip(isMobile, "range selection is a desktop pointer flow"); + test.setTimeout(90_000); + await seedNamespaceDocs("bulkfifty", 50); + + // Shared filing date → the namespace orders by id desc: "050" first, + // "001" last. Anchor on the first card, shift-click the last. + await page.goto("/documents?q=bulkfifty"); + await expect(openButton(page, "bulkfifty 050")).toBeVisible({ + timeout: 15_000, + }); + await selectBox(page, "bulkfifty 050").click(); + await scrollUntilVisible(page, openButton(page, "bulkfifty 001")); + await selectBox(page, "bulkfifty 001").click({ modifiers: ["Shift"] }); + + const bar = page.locator('[data-slot="document-bulk-bar"]'); + await expect(bar).toBeVisible(); + await expect(bar.getByText("50 selected")).toBeVisible(); + + await bar.getByRole("button", { name: "Change type" }).click(); + await page.getByRole("menuitem", { name: "Referral" }).click(); + await expect(page.getByText("50 document(s) updated")).toBeVisible({ + timeout: 15_000, + }); + + // Server state: all 50 now carry the new kind. + const res = await page.request.get( + "/api/documents/inbound?q=bulkfifty&kind=REFERRAL&limit=100", + ); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.data.documents).toHaveLength(50); + }); + + test("bulk: a partial failure surfaces per-id results", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "bulk selection is a desktop pointer flow"); + await seedNamespaceDocs("bulkpf", 5); + + await page.goto("/documents?q=bulkpf"); + for (let i = 1; i <= 5; i++) { + const box = selectBox(page, `bulkpf 00${i}`); + await scrollUntilVisible(page, box); + await box.click(); + } + await expect(page.getByText("5 selected")).toBeVisible(); + + // Kill one of the five behind the selection's back (tombstone), so the + // bulk call reports exactly one per-id failure. + const list = await page.request.get( + "/api/documents/inbound?q=bulkpf&limit=10", + ); + const victim = (await list.json()).data.documents.find( + (d: { title: string }) => d.title === "bulkpf 003", + ); + const del = await page.request.delete( + `/api/documents/inbound/${victim.id}`, + ); + expect(del.status()).toBe(200); + + const bar = page.locator('[data-slot="document-bulk-bar"]'); + await bar.getByRole("button", { name: "Change type" }).click(); + await page.getByRole("menuitem", { name: "Insurance" }).click(); + await expect(page.getByText("1 of 5 could not be updated")).toBeVisible(); + }); + + test("bulk: a selection above 100 chunks into two requests", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "range selection is a desktop pointer flow"); + test.setTimeout(120_000); + await seedNamespaceDocs("bulkchunk", 101); + + // Newest-first by id tiebreak: "101" renders first, "001" last. Walk + // the infinite query to the end (pages of 50) so the range can span + // all 101, then anchor + shift-click. + await page.goto("/documents?q=bulkchunk"); + await expect(openButton(page, "bulkchunk 101")).toBeVisible(); + await selectBox(page, "bulkchunk 101").click(); + await scrollUntilVisible(page, openButton(page, "bulkchunk 001"), 80); + await selectBox(page, "bulkchunk 001").click({ modifiers: ["Shift"] }); + await expect(page.getByText("101 selected")).toBeVisible(); + + let bulkPosts = 0; + page.on("request", (req) => { + if ( + req.method() === "POST" && + req.url().includes("/api/documents/inbound/bulk") + ) { + bulkPosts += 1; + } + }); + + const bar = page.locator('[data-slot="document-bulk-bar"]'); + await bar.getByRole("button", { name: "Change type" }).click(); + await page.getByRole("menuitem", { name: "Vaccination" }).click(); + await expect(page.getByText("101 document(s) updated")).toBeVisible({ + timeout: 15_000, + }); + expect(bulkPosts).toBe(2); + }); + + // ── Keyboard-only doctor flow ────────────────────────────────────────── + + test("keyboard-only: search, roving grid, Enter opens, Escape closes", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "keyboard navigation is a desktop flow"); + await page.goto("/documents"); + await expect( + page.getByRole("heading", { name: "Documents" }), + ).toBeVisible(); + + // `/` focuses the search from anywhere. + await page.keyboard.press("/"); + await expect( + page.getByRole("searchbox", { name: "Search documents" }), + ).toBeFocused(); + await page.keyboard.type("MRT"); + await expect(openButton(page, "MRT Knie")).toBeVisible(); + + // Tab from the search into the grid's single roving slot (bounded walk + // across the chip rail), then arrows move the active card. + let reachedGrid = false; + for (let i = 0; i < 40; i++) { + await page.keyboard.press("Tab"); + const slot = await page.evaluate( + () => document.activeElement?.getAttribute("data-slot") ?? "", + ); + if (slot === "document-open") { + reachedGrid = true; + break; + } + } + expect(reachedGrid).toBe(true); + + // Arrow keys move the roving slot; the focus lands asynchronously + // (the target row may mount on the next virtualizer paint) — poll. + const activeLabel = () => + page.evaluate( + () => document.activeElement?.getAttribute("aria-label") ?? "", + ); + const first = await activeLabel(); + await page.keyboard.press("ArrowRight"); + await expect.poll(activeLabel).not.toBe(first); + await page.keyboard.press("ArrowLeft"); + await expect.poll(activeLabel).toBe(first); + + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + // Wait for the preview to finish loading — the sheet hands focus back + // to the dialog after Chromium's PDF viewer grabs it, so Escape works + // regardless of when the user presses it. + await expect(dialog.locator("iframe")).toBeVisible(); + await page.waitForTimeout(250); + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + }); + + // ── Touch: long-press selects (mobile project only) ──────────────────── + + test("long-press selects a card on touch", async ({ page, isMobile }) => { + test.skip(!isMobile, "long-press is the touch selection gesture"); + await page.goto("/documents?q=MRT"); + const button = openButton(page, "MRT Knie"); + await expect(button).toBeVisible(); + + await button.dispatchEvent("touchstart"); + await page.waitForTimeout(700); + await button.dispatchEvent("touchend"); + + await expect(page.locator('[data-slot="document-bulk-bar"]')).toBeVisible(); + await expect(page.getByText("1 selected")).toBeVisible(); + }); + + // ── Page-wide drop + clipboard paste intake ──────────────────────────── + + test("dropping a file anywhere on the page uploads it", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "OS file drag is a desktop gesture"); + await page.goto("/documents"); + await expect( + page.getByRole("heading", { name: "Documents" }), + ).toBeVisible(); + + const marker = `drop-probe-${Date.now()}`; + const dataTransfer = await page.evaluateHandle( + ([name, content]) => { + const dt = new DataTransfer(); + dt.items.add( + new File([content], `${name}.pdf`, { type: "application/pdf" }), + ); + return dt; + }, + [marker, `%PDF-1.4\n%${marker}\n%%EOF\n`] as const, + ); + + await page.dispatchEvent("body", "dragenter", { dataTransfer }); + await expect( + page.locator('[data-slot="document-drop-overlay"]'), + ).toBeVisible(); + await page.dispatchEvent("body", "drop", { dataTransfer }); + await expect( + page.locator('[data-slot="document-drop-overlay"]'), + ).not.toBeVisible(); + + // The stored card lands in the timeline once the upload settles. + await page + .getByRole("searchbox", { name: "Search documents" }) + .fill(marker); + await expect(openButton(page, `${marker}.pdf`)).toBeVisible({ + timeout: 10_000, + }); + }); + + test("pasting a file from the clipboard uploads it", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "clipboard file paste is a desktop gesture"); + await page.goto("/documents"); + await expect( + page.getByRole("heading", { name: "Documents" }), + ).toBeVisible(); + + const marker = `paste-probe-${Date.now()}`; + await page.evaluate( + ([name, content]) => { + const dt = new DataTransfer(); + dt.items.add( + new File([content], `${name}.pdf`, { type: "application/pdf" }), + ); + const event = new ClipboardEvent("paste", { + clipboardData: dt, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(event); + }, + [marker, `%PDF-1.4\n%${marker}\n%%EOF\n`] as const, + ); + + await page + .getByRole("searchbox", { name: "Search documents" }) + .fill(marker); + await expect(openButton(page, `${marker}.pdf`)).toBeVisible({ + timeout: 10_000, + }); + }); + + test("a duplicate upload highlights the existing row instead of storing twice", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "runs once on the desktop project (shared bucket)"); + await page.goto("/documents"); + const marker = `dup-probe-${Date.now()}`; + const bytes = uniquePdf(marker); + + await uploadViaPicker(page, `${marker}.pdf`, bytes); + await page + .getByRole("searchbox", { name: "Search documents" }) + .fill(marker); + await expect(openButton(page, `${marker}.pdf`)).toBeVisible(); + + await uploadViaPicker(page, `${marker}-copy.pdf`, bytes); + await expect( + page.getByText("Already stored — highlighting the existing document."), + ).toBeVisible(); + // One row, not two — the copy never landed. + await expect(openButton(page, `${marker}-copy.pdf`)).not.toBeVisible(); + }); + + // ── Admin cap: commit-on-Enter + rejection copy naming the limit ────── + + test("a 1 MB admin cap rejects an oversized upload with the limit in the copy", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "admin surface is a desktop flow"); + try { + await page.goto("/admin/general"); + const capInput = page.getByRole("spinbutton", { + name: "Document upload limit", + }); + await expect(capInput).toBeEnabled(); + await capInput.fill("1"); + const saved = page.waitForResponse( + (res) => + res.url().includes("/api/admin/settings") && + res.request().method() === "PUT", + ); + await capInput.press("Enter"); + expect((await saved).status()).toBe(200); + + await page.goto("/documents"); + await expect( + page.getByRole("heading", { name: "Documents" }), + ).toBeVisible(); + // ~1.2 MB — over the 1 MB cap, far under the default. + const big = Buffer.concat([ + Buffer.from("%PDF-1.4\n"), + Buffer.alloc(1_200_000, 0x20), + ]); + await uploadViaPicker(page, "oversized-scan.pdf", big); + await expect( + page.getByText("This file is larger than the 1 MB limit."), + ).toBeVisible(); + } finally { + const restore = await page.request.put("/api/admin/settings", { + data: { documentMaxFileBytes: DEFAULT_MAX_FILE_BYTES }, + }); + expect(restore.status()).toBe(200); + } + }); + + // ── Accessibility ────────────────────────────────────────────────────── + + test("axe: the vault timeline has no serious or critical violations", async ({ + page, + }) => { + await page.goto("/documents"); + await expect( + page.locator('[data-slot="document-card"]').first(), + ).toBeVisible({ timeout: 15_000 }); + await page.waitForLoadState("networkidle"); + const blocking = await runAxe(page); + expect(blocking).toHaveLength(0); + }); + + test("axe: the open detail sheet has no serious or critical violations", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "scanned once on the desktop project"); + await page.goto(`/documents?doc=${MRT_DOC_ID}`); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.waitForLoadState("networkidle"); + const blocking = await runAxe(page); + expect(blocking).toHaveLength(0); + }); + + test("axe: bulk mode has no serious or critical violations", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "scanned once on the desktop project"); + await page.goto("/documents?q=MRT"); + await expect(openButton(page, "MRT Knie")).toBeVisible(); + await selectBox(page, "MRT Knie").click(); + await expect(page.locator('[data-slot="document-bulk-bar"]')).toBeVisible(); + const blocking = await runAxe(page); + expect(blocking).toHaveLength(0); + }); +}); diff --git a/e2e/setup/vault-fixture.ts b/e2e/setup/vault-fixture.ts new file mode 100644 index 000000000..9cd1538ba --- /dev/null +++ b/e2e/setup/vault-fixture.ts @@ -0,0 +1,301 @@ +/** + * Document-vault e2e fixture — seeded straight through Postgres (`pg`, the + * same transport `global-setup.ts` uses) because the doctor-flow script + * needs 60 documents and the upload endpoint's per-user rate bucket is + * 60/hour: seeding through the API would burn the whole budget before the + * first genuine upload test runs, and the two Playwright projects (desktop + * + mobile) each re-run the seed. + * + * The seed is deterministic and idempotent: + * - fixed row ids (`e2evault…`), upserted with `ON CONFLICT (id) DO + * UPDATE` so a re-run self-heals whatever a previous run mutated; + * - per-document unique plaintext (an id marker inside the bytes) so the + * `(user_id, content_sha256)` live-dedupe partial unique index never + * trips across rows; + * - blobs encrypted with the SAME binary2 layout the server writes + * (`[0x02][keyIdLen][keyId][iv 12][tag 16][ciphertext]`, AES-256-GCM + * under the env key), so the serve route decrypts and previews them + * like real uploads. + * + * Corpus (mirrors the plan's §1A doctor-flow seed): 60 documents across + * 3 years, one IllnessEpisode "Knie", an MRI report (IMAGING, "MRT Knie", + * last autumn, linked to Knie) plus two adjacent image documents on the + * same date and link. `seedNamespaceDocs` adds disposable per-test + * namespaces (bulk flows) that never collide with the doctor-flow corpus. + */ +import { createCipheriv, createHash, randomBytes } from "node:crypto"; + +import pg from "pg"; + +import { E2E_USER } from "./global-setup"; + +// ─── binary2 codec (mirrors src/lib/crypto.ts encryptBytes) ──────────────── + +function resolveActiveKey(): { id: string; key: Buffer } { + const mapRaw = process.env.ENCRYPTION_KEYS; + if (mapRaw) { + const map = JSON.parse(mapRaw) as Record; + const id = + process.env.ENCRYPTION_ACTIVE_KEY_ID ?? Object.keys(map)[0] ?? "v1"; + const hex = map[id]; + if (!hex) { + throw new Error(`[vault-fixture] key id '${id}' not in ENCRYPTION_KEYS`); + } + return { id, key: Buffer.from(hex, "hex") }; + } + const legacy = process.env.ENCRYPTION_KEY; + if (!legacy) { + throw new Error( + "[vault-fixture] neither ENCRYPTION_KEYS nor ENCRYPTION_KEY is set", + ); + } + return { id: "v1", key: Buffer.from(legacy, "hex") }; +} + +function encryptBinary2(plaintext: Buffer): Buffer { + const { id, key } = resolveActiveKey(); + const keyId = Buffer.from(id, "ascii"); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([ + Buffer.from([0x02, keyId.byteLength]), + keyId, + iv, + tag, + ct, + ]); +} + +// ─── Deterministic tiny documents ─────────────────────────────────────────── + +/** Minimal single-page PDF; `marker` lands in a comment so every doc's bytes + * (and therefore sha256) are unique. */ +function tinyPdf(marker: string): Buffer { + return Buffer.from( + `%PDF-1.4\n%${marker}\n1 0 obj<>endobj\n` + + `2 0 obj<>endobj\n` + + `3 0 obj<>endobj\n` + + `xref\n0 4\ntrailer<>\n%%EOF\n`, + "utf8", + ); +} + +const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==", + "base64", +); + +/** 1×1 PNG + a trailing marker (decoders ignore bytes past IEND). */ +function tinyPng(marker: string): Buffer { + return Buffer.concat([PNG_1X1, Buffer.from(marker, "utf8")]); +} + +// ─── Fixture identity ─────────────────────────────────────────────────────── + +export const KNIE_EPISODE_ID = "e2evaultknie000000000001"; +export const MRT_DOC_ID = "e2evaultmrt0000000000001"; +/** Filing date of the MRT report — "last autumn" relative to the fixture. */ +const MRT_DOC_DATE = "2025-10-14"; + +const FIXTURE_KINDS = [ + "DOCTOR_REPORT", + "LAB_RESULT", + "PRESCRIPTION", + "IMAGING", + "OTHER", + "DISCHARGE_LETTER", + "INSURANCE", +] as const; + +interface DocRow { + id: string; + kind: string; + title: string; + filename: string; + mimeType: string; + documentDate: string; // YYYY-MM-DD + bytes: Buffer; +} + +async function getUserId(pool: pg.Pool): Promise { + const res = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [E2E_USER.username], + ); + const id = res.rows[0]?.id; + if (!id) { + throw new Error( + "[vault-fixture] e2e user not seeded — global-setup must run first", + ); + } + return id; +} + +async function upsertDocs( + pool: pg.Pool, + userId: string, + docs: DocRow[], +): Promise { + for (const doc of docs) { + const encrypted = encryptBinary2(doc.bytes); + const sha = createHash("sha256").update(doc.bytes).digest("hex"); + await pool.query( + `INSERT INTO inbound_documents + (id, user_id, kind, title, filename, mime_type, byte_size, + content_encrypted, content_codec, content_sha256, status, + document_date, created_at, updated_at, deleted_at) + VALUES ($1, $2, $3::inbound_document_kind, $4, $5, $6, $7, + $8, 'binary2', $9, 'STORED', + $10::timestamp, NOW(), NOW(), NULL) + ON CONFLICT (id) DO UPDATE SET + kind = EXCLUDED.kind, + title = EXCLUDED.title, + document_date = EXCLUDED.document_date, + status = 'STORED', + deleted_at = NULL, + updated_at = NOW()`, + [ + doc.id, + userId, + doc.kind, + doc.title, + doc.filename, + doc.mimeType, + doc.bytes.byteLength, + encrypted, + sha, + `${doc.documentDate}T00:00:00Z`, + ], + ); + } +} + +/** + * Enable the vault module for the seeded e2e user and plant the doctor-flow + * corpus. Safe to call from every spec's beforeAll — every statement + * upserts. + */ +export async function ensureVaultFixture(): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + const userId = await getUserId(pool); + + // Opt-in module: merge, never overwrite other module preferences. + await pool.query( + `UPDATE users + SET module_preferences_json = + COALESCE(module_preferences_json, '{}'::jsonb) + || '{"inboundDocuments": true}'::jsonb + WHERE id = $1`, + [userId], + ); + + // The "Knie" condition episode the MRT report files under. + await pool.query( + `INSERT INTO illness_episodes + (id, user_id, label, type, lifecycle, onset_at, created_at, updated_at) + VALUES ($1, $2, 'Knie', 'INJURY', 'ACUTE', '2025-09-01T00:00:00Z', NOW(), NOW()) + ON CONFLICT (id) DO UPDATE SET + label = EXCLUDED.label, deleted_at = NULL, updated_at = NOW()`, + [KNIE_EPISODE_ID, userId], + ); + + // 60 background documents spread over 36 months (newest ~current). + const docs: DocRow[] = []; + for (let i = 0; i < 60; i++) { + const monthsBack = Math.floor((i * 36) / 60); + const anchor = new Date(Date.UTC(2026, 5 - monthsBack, 1 + (i % 25))); + const id = `e2evaultdoc${String(i).padStart(12, "0")}`; + docs.push({ + id, + kind: FIXTURE_KINDS[i % FIXTURE_KINDS.length], + title: `Befund ${String(i + 1).padStart(2, "0")}`, + filename: `befund-${i + 1}.pdf`, + mimeType: "application/pdf", + documentDate: anchor.toISOString().slice(0, 10), + bytes: tinyPdf(id), + }); + } + // The MRT report + two adjacent images, same date, all linked to Knie. + docs.push({ + id: MRT_DOC_ID, + kind: "IMAGING", + title: "MRT Knie", + filename: "mrt-knie.pdf", + mimeType: "application/pdf", + documentDate: MRT_DOC_DATE, + bytes: tinyPdf(MRT_DOC_ID), + }); + for (const n of [1, 2]) { + docs.push({ + id: `e2evaultimg000000000000${n}`, + kind: "IMAGING", + title: `MRT Aufnahme ${n}`, + filename: `mrt-aufnahme-${n}.png`, + mimeType: "image/png", + documentDate: MRT_DOC_DATE, + bytes: tinyPng(`e2evaultimg${n}`), + }); + } + await upsertDocs(pool, userId, docs); + + // Condition links for the three MRT-day documents. + const linked = [ + MRT_DOC_ID, + "e2evaultimg0000000000001", + "e2evaultimg0000000000002", + ]; + for (const [i, docId] of linked.entries()) { + await pool.query( + `INSERT INTO document_condition_links + (id, document_id, episode_id, user_id, created_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (document_id, episode_id) DO NOTHING`, + [`e2evaultlink00000000000${i + 1}`, docId, KNIE_EPISODE_ID, userId], + ); + } + } finally { + await pool.end(); + } +} + +/** + * Seed a disposable, namespaced batch of OTHER-kind PDF documents for the + * bulk-flow specs (`title`/`filename` carry the prefix so a search filter + * isolates exactly this namespace). Idempotent; re-runs reset kind/date and + * clear tombstones so a previous run's mutations never leak into this one. + */ +export async function seedNamespaceDocs( + prefix: string, + count: number, +): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + const userId = await getUserId(pool); + const docs: DocRow[] = []; + for (let i = 0; i < count; i++) { + const id = `e2e${prefix}${String(i).padStart(8, "0")}`; + docs.push({ + id, + kind: "OTHER", + title: `${prefix} ${String(i + 1).padStart(3, "0")}`, + filename: `${prefix}-${i + 1}.pdf`, + mimeType: "application/pdf", + // One shared filing date: the timeline then orders the namespace by + // the id tiebreak (desc), so `${prefix} ` renders first and + // `${prefix} 001` last — a deterministic range-selection span. + documentDate: "2026-05-01", + bytes: tinyPdf(id), + }); + } + await upsertDocs(pool, userId, docs); + } finally { + await pool.end(); + } +}