test(share-links): end-to-end document share, serve, and revoke lifecycle

Adds a Playwright spec that drives the public sharing surface against the
production server and a real Postgres: the owner picks documents into a new
link through the Settings picker, a recipient unlocks it with the passphrase
and pulls each blob through the token-scoped serve route, and a revoke closes
it. Pins the load-bearing guarantees end to end — frozen-set confinement (an
owned but un-attached document 404s), the serving posture (Class A inline with
true type + nosniff + no-store, Class B opaque octet-stream attachment), the
EXIF/GPS strip on the shared image, revocation enforced at serve time, and the
scannable QR promotion at the mobile width.

Seeds a fixed share trio (inline PDF, Exif-bearing JPEG, download-only text)
through the same Postgres transport the vault fixture already uses.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 22:00:27 +02:00
1 parent 788996b934
commit 1face234cc
2 files changed
+347

No files matched your search

+93
View File
@@ -160,6 +160,30 @@ 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";
@@ -173,6 +197,21 @@ export const AI_PROBE_DOC_ID = "e2evaultaiprobe000000001";
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/<token>/d/<id>` route:
* - a PDF (Class A, inline preview via <iframe>),
* - a JPEG carrying an Exif GPS marker (Class A image, inline <img>; the
* serve route strips the marker on egress — the stored bytes keep it),
* - a text/plain document (Class B, download-only opaque attachment).
* Titles carry the `Share e2e` prefix so the owner picker's search isolates
* exactly this set. Idempotent (fixed ids upserted).
*/
export const SHARE_PDF_DOC_ID = "e2eshare0000000000000pdf";
export const SHARE_JPEG_DOC_ID = "e2eshare000000000000jpeg";
export const SHARE_TEXT_DOC_ID = "e2eshare000000000000text";
export const SHARE_DOC_PREFIX = "Share e2e";
/** Filing date of the MRT report — "last autumn" relative to the fixture. */
const MRT_DOC_DATE = "2025-10-14";
@@ -439,3 +478,57 @@ export async function seedNamespaceDocs(
await pool.end();
}
}
/**
* v1.28 — seed the clinician-share document trio (see the `SHARE_*_DOC_ID`
* constants). Enables the vault module for the e2e user and upserts one
* inline-PDF, one Exif/GPS-bearing JPEG, and one download-only text document,
* all owned by the seeded account. Idempotent; safe from a spec `beforeAll`.
*/
export async function ensureShareDocFixture(): Promise<void> {
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);
await pool.query(
`UPDATE users
SET module_preferences_json =
COALESCE(module_preferences_json, '{}'::jsonb)
|| '{"inboundDocuments": true}'::jsonb
WHERE id = $1`,
[userId],
);
await upsertDocs(pool, userId, [
{
id: SHARE_PDF_DOC_ID,
kind: "DOCTOR_REPORT",
title: `${SHARE_DOC_PREFIX} report`,
filename: "share-report.pdf",
mimeType: "application/pdf",
documentDate: "2026-05-20",
bytes: tinyPdf(SHARE_PDF_DOC_ID),
},
{
id: SHARE_JPEG_DOC_ID,
kind: "IMAGING",
title: `${SHARE_DOC_PREFIX} scan`,
filename: "share-scan.jpg",
mimeType: "image/jpeg",
documentDate: "2026-05-21",
bytes: jpegWithGps(SHARE_JPEG_DOC_ID),
},
{
id: SHARE_TEXT_DOC_ID,
kind: "OTHER",
title: `${SHARE_DOC_PREFIX} notes`,
filename: "share-notes.txt",
mimeType: "text/plain",
documentDate: "2026-05-22",
bytes: Buffer.from(`share e2e class-B payload ${SHARE_TEXT_DOC_ID}`),
},
]);
} finally {
await pool.end();
}
}
+254
View File
@@ -0,0 +1,254 @@
/**
* 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/<token>/d/<id>` 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,
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<CreatedShare> {
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=<passphrase>` deep link the QR encodes. */
async function openUnlockedClinician(
browser: Browser,
share: CreatedShare,
): Promise<Page> {
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,
}) => {
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 <img> at the token-scoped serve route.
await expect(
clinician.locator(`img[src*="/d/${SHARE_JPEG_DOC_ID}"]`),
).toBeVisible();
// Class A PDF → inline <iframe> at the same route family.
await expect(
clinician.locator(`iframe[src*="/d/${SHARE_PDF_DOC_ID}"]`),
).toHaveCount(1);
// Class B text → NO inline frame; the download-only hint is shown.
await expect(
clinician.locator(`iframe[src*="/d/${SHARE_TEXT_DOC_ID}"]`),
).toHaveCount(0);
await expect(
clinician.getByText("This file downloads to your device"),
).toBeVisible();
const serve = (id: string) => `${BASE}/c/${share.token}/d/${id}`;
// ── Class A PDF: inline, true type, nosniff, no-store ──
const pdf = await clinician.request.get(serve(SHARE_PDF_DOC_ID));
expect(pdf.status()).toBe(200);
expect(pdf.headers()["content-type"]).toBe("application/pdf");
expect(pdf.headers()["content-disposition"]).toContain("inline");
expect(pdf.headers()["x-content-type-options"]).toBe("nosniff");
// The proxy's `/c/` edge block wins over the route header (middleware
// headers override route headers), yielding a stricter no-store posture.
expect(pdf.headers()["cache-control"]).toContain("no-store");
// The proxy owns the CSP for this path (SAMEORIGIN framing + document CSP).
expect(pdf.headers()["content-security-policy"]).toContain(
"frame-ancestors 'self'",
);
expect(pdf.headers()["x-frame-options"]).toBe("SAMEORIGIN");
// ── Class A JPEG: inline image, EXIF/GPS stripped on egress ──
const jpeg = await clinician.request.get(serve(SHARE_JPEG_DOC_ID));
expect(jpeg.status()).toBe(200);
expect(jpeg.headers()["content-type"]).toBe("image/jpeg");
expect(jpeg.headers()["content-disposition"]).toContain("inline");
const jpegBody = await jpeg.body();
expect(
jpegBody.includes(Buffer.from("GPSLatitude")),
"served JPEG must not carry the stored GPS marker",
).toBe(false);
// ── Class B text: opaque octet-stream attachment (never inline) ──
const txt = await clinician.request.get(serve(SHARE_TEXT_DOC_ID));
expect(txt.status()).toBe(200);
expect(txt.headers()["content-type"]).toBe("application/octet-stream");
expect(txt.headers()["content-disposition"]).toContain("attachment");
expect(txt.headers()["x-content-type-options"]).toBe("nosniff");
// ── Frozen-set confinement: an OWNED but un-attached document 404s ──
const foreign = await clinician.request.get(serve(UNATTACHED_OWNED_DOC_ID));
expect(foreign.status()).toBe(404);
// A syntactically-plausible but unknown id also 404s (same blunt miss).
const guessed = await clinician.request.get(
serve("e2edoesnotexist00000001"),
);
expect(guessed.status()).toBe(404);
// ── Revoke through the owner UI, then the serve route stops cold ──
await page.reload();
const activeItem = page
.getByTestId("share-active-list")
.getByRole("listitem")
.filter({ hasText: share.label })
.first();
await expect(activeItem).toBeVisible();
await activeItem.getByRole("button", { name: "Revoke" }).click();
// Confirm in the alert dialog.
await page
.getByRole("alertdialog")
.getByRole("button", { name: "Revoke" })
.click();
// The already-unlocked recipient can no longer pull any blob — revocation
// is re-enforced at serve time, before any decrypt.
await expect(async () => {
const afterRevoke = await clinician.request.get(serve(SHARE_PDF_DOC_ID));
expect(afterRevoke.status()).toBe(404);
}).toPass({ timeout: 10_000 });
await clinician.context().close();
});
test("picker enforces the document cap at the limit", async ({ page }) => {
await page.goto("/settings/sharing");
await page.locator("#share-label").fill(`${SHARE_DOC_PREFIX} cap`);
await page.getByTestId("share-attach-open").click();
const list = page.getByTestId("share-doc-picker-list");
await expect(list).toBeVisible();
// The picker loads one page (server `limit=50`), which equals the cap; the
// corpus fixture guarantees a full page.
const rows = list.getByRole("button");
const count = await rows.count();
expect(count).toBeGreaterThanOrEqual(50);
// Select up to the cap. Each click below the cap is enabled; the click that
// reaches the cap flips `atCap`, after which the max-reached banner shows.
for (let i = 0; i < 50; i++) await rows.nth(i).click();
await expect(
page.getByText("You've reached the maximum of 50"),
).toBeVisible();
// The footer count is pinned at the cap ("50 of 50 selected").
await expect(page.getByText("50 of 50 selected").first()).toBeVisible();
});
});