/** * 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, createHmac, hkdfSync, 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, ]); } /** * Encrypt a plaintext string into the `encrypt()`-string-as-UTF-8 Bytes shape * the content index stores `text_encrypted` in (`.` * → UTF-8 bytes). Mirrors src/lib/crypto.ts `encrypt` + the coach bytes codec. */ function encryptStringToBytes(plaintext: string): Buffer { const { id, key } = resolveActiveKey(); const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", key, iv); const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); const payload = Buffer.concat([iv, tag, enc]).toString("base64"); return Buffer.from(`${id}.${payload}`, "utf8"); } // ─── Blind content-index tokens (mirrors src/lib/documents/content-index) ──── // // HKDF-derived index subkey off the ACTIVE encryption key + domain label, then // each normalised token HMAC-SHA256'd and truncated to a 16-char hex tag. The // server hashes a search query the same way, so a body-only word seeded here is // findable through the real GIN union without ever storing the word in clear. const CONTENT_INDEX_INFO = "healthlog:document-content-index:v1"; function indexSubkey(): Buffer { const { key } = resolveActiveKey(); return Buffer.from( hkdfSync("sha256", key, new Uint8Array(0), CONTENT_INDEX_INFO, 32), ); } /** Tokenise like the server: NFKD de-accent, lowercase, alnum runs, len 3..64. * The seeded words are deliberately non-stopwords, so the stopword drop the * server also applies is a no-op here and the tag sets match exactly. */ function tokenise(text: string): string[] { const normalised = text .normalize("NFKD") .replace(/\p{Diacritic}/gu, "") .toLowerCase(); const seen = new Set(); for (const raw of normalised.split(/[^\p{L}\p{N}]+/u)) { const token = raw.trim(); if (token.length < 3 || token.length > 64) continue; seen.add(token); } return [...seen]; } function tokeniseAndHash(text: string): string[] { const subkey = indexSubkey(); const hashes = new Set(); for (const token of tokenise(text)) { hashes.add( createHmac("sha256", subkey) .update(token, "utf8") .digest("hex") .slice(0, 16), ); } return [...hashes]; } // ─── 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")]); } /** * A minimal JPEG carrying a GPS marker inside an APP1 (Exif) segment so the * share serve route's egress EXIF strip is observable end-to-end: the stored * bytes contain `GPSLatitude`, the served bytes must not. SOI → APP1(Exif) → * SOS(+entropy). Not a decodable photo — the serve route sniffs the STORED * mime type, never re-decodes pixels, so this is enough to exercise the strip. */ function jpegWithGps(marker: string): Buffer { const payload = Buffer.concat([ Buffer.from("Exif\0\0"), Buffer.from(`GPSLatitude=48.137 ${marker}`), ]); const lenField = Buffer.alloc(2); lenField.writeUInt16BE(payload.length + 2); const app1 = Buffer.concat([Buffer.from([0xff, 0xe1]), lenField, payload]); const sos = Buffer.concat([ Buffer.from([0xff, 0xda]), Buffer.from([0x00, 0x08]), Buffer.from([0x01, 0x01, 0x00, 0x3f, 0x00, 0x00]), Buffer.from([0xaa, 0xbb]), ]); return Buffer.concat([Buffer.from([0xff, 0xd8]), app1, sos]); } // ─── Fixture identity ─────────────────────────────────────────────────────── export const KNIE_EPISODE_ID = "e2evaultknie000000000001"; export const MRT_DOC_ID = "e2evaultmrt0000000000001"; /** A stored PDF the AI-assist specs drive (mocked provider); title/kind are * bland so an applied suggestion is visibly different. */ export const AI_PROBE_DOC_ID = "e2evaultaiprobe000000001"; /** A stored document whose searchable body word lives ONLY in its content * index — never in the title or filename — so content search is the only * route to it. */ export const CONTENT_DOC_ID = "e2evaultcontent00000001"; /** The whole word that appears only in `CONTENT_DOC_ID`'s indexed body. */ export const CONTENT_BODY_WORD = "pneumothorax"; /** * v1.28 (Phase 3) — a fixed trio the clinician-share e2e attaches to a link and * serves through the public `/c//d/` route: * - a PDF (Class A, inline preview via