/** * v1.28 (document vault, Phase 3) — clinician document-sharing lifecycle e2e. * * Drives the whole public-surface path against the production server + a real * Postgres (migrations 0228 + 0229): the owner picks documents into a new share * link through the Settings picker, a recipient unlocks the link with the * passphrase and pulls each blob through the public `/c//d/` serve * route, and a revoke closes it. The load-bearing assertions: * * - the serve route confines to the link's FROZEN set — an owned document NOT * attached to the link 404s (token-scoped, never a global document fetch); * - the serving posture matches the owner route — Class A (PDF/JPEG) inline * with its true type + nosniff + no-store, Class B (text) opaque * `application/octet-stream` attachment; * - EXIF/GPS is stripped from the shared JPEG on egress (the served bytes * drop the marker the stored bytes still carry); * - revocation is enforced at serve time — a revoked link serves nothing; * - the clinician view renders inline previews (img/iframe) for Class A and a * download-only affordance for Class B; * - the create reveal promotes a scannable QR (runs under the 390px mobile * project too). * * The share fixtures (`ensureShareDocFixture`) seed the attached trio; the * broader corpus (`ensureVaultFixture`) supplies the ≥50 documents the picker * cap test and the not-attached foreign-id probe need. */ import { expect, test, type Browser, type Page } from "@playwright/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { ensureShareDocFixture, ensureVaultFixture, MRT_DOC_ID, SHARE_DOC_PREFIX, SHARE_JPEG_DOC_ID, SHARE_PDF_DOC_ID, SHARE_TEXT_DOC_ID, } from "./setup/vault-fixture"; const BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000"; /** An owned document from the broad corpus that is NEVER attached to the link * under test — the foreign-id probe against the frozen-set confinement. */ const UNATTACHED_OWNED_DOC_ID = "e2evaultdoc000000000000"; interface CreatedShare { token: string; passphrase: string; label: string; } /** * Drive the owner create flow: open the picker, isolate the share-fixture trio * by search, select all three, create the link, and read the one-time token + * passphrase off the reveal card. Also asserts the QR promotion. */ async function createShareWithDocs(page: Page): Promise { const label = `${SHARE_DOC_PREFIX} ${Date.now()}`; await page.goto("/settings/sharing"); await page.locator("#share-label").fill(label); // Open the document picker and isolate the seeded trio. await page.getByTestId("share-attach-open").click(); const search = page.getByPlaceholder("Search documents"); await search.fill(SHARE_DOC_PREFIX); const list = page.getByTestId("share-doc-picker-list"); await expect(list).toBeVisible(); // Exactly the three share-fixture documents match the prefix. const rows = list.getByRole("button"); await expect(rows).toHaveCount(3); for (let i = 0; i < 3; i++) await rows.nth(i).click(); await page.getByRole("button", { name: "Done" }).click(); // Three chips staged before create. await expect( page.getByTestId("share-attached-chips").getByRole("listitem"), ).toHaveCount(3); await page.getByRole("button", { name: "Create link" }).click(); const reveal = page.getByTestId("share-token-reveal"); await expect(reveal).toBeVisible(); await expect(page.getByTestId("share-created-doc-count")).toContainText("3"); // QR promotion — present and rendered at a scannable size (the UI wave // promoted it as the primary mobile affordance; runs under the 390px project). const qrBlock = page.getByTestId("share-qr-block"); await expect(qrBlock).toBeVisible(); const qrImg = qrBlock.getByRole("img"); await expect(qrImg).toBeVisible(); const box = await qrImg.boundingBox(); expect(box, "QR image has a rendered box").not.toBeNull(); expect(box!.width).toBeGreaterThanOrEqual(160); // The one-time secrets: token from the share URL, passphrase from its cell. const shareUrl = (await reveal.locator("code").first().innerText()).trim(); const token = shareUrl.split("/c/")[1]; expect(token, "token parsed from share URL").toMatch(/^hls_[0-9a-f]{48}$/); const passphrase = ( await page.getByTestId("share-passphrase").innerText() ).trim(); expect(passphrase.length).toBeGreaterThan(0); return { token, passphrase, label }; } /** A recipient browser context (no owner session) that has unlocked `token` * by following the `#k=` deep link the QR encodes. */ async function openUnlockedClinician( browser: Browser, share: CreatedShare, ): Promise { const ctx = await browser.newContext({ baseURL: BASE, serviceWorkers: "block", }); const page = await ctx.newPage(); // The gate reads `#k=` from the fragment, POSTs the unlock, and reloads with // the record — exactly what a clinician scanning the QR would trigger. await page.goto(`${BASE}/c/${share.token}#${"k"}=${share.passphrase}`); await expect( page.getByRole("heading", { name: "Shared health record" }), ).toBeVisible({ timeout: 15_000 }); return page; } test.describe("clinician document sharing", () => { test.use({ storageState: STORAGE_STATE_PATH }); test.beforeAll(async () => { await ensureVaultFixture(); await ensureShareDocFixture(); }); test("share → unlock → serve (posture + EXIF strip) → revoke → 404", async ({ page, browser, }) => { // A full lifecycle across two browser contexts (owner create + picker, // recipient unlock, several document serves, revoke): legitimately heavy, // so it earns the tripled timeout rather than racing the default 30s on a // loaded CI runner. test.slow(); const share = await createShareWithDocs(page); const clinician = await openUnlockedClinician(browser, share); // ── The clinician view lists the documents with the right affordances ── await expect( clinician.getByRole("heading", { name: "Documents" }), ).toBeVisible(); // Class A image → inline at the token-scoped serve route. await expect( clinician.locator(`img[src*="/d/${SHARE_JPEG_DOC_ID}"]`), ).toBeVisible(); // Class A PDF → inline