diff --git a/e2e/mcp-oauth-consent-redirect.spec.ts b/e2e/mcp-oauth-consent-redirect.spec.ts index 8477e2181..7863d6648 100644 --- a/e2e/mcp-oauth-consent-redirect.spec.ts +++ b/e2e/mcp-oauth-consent-redirect.spec.ts @@ -4,7 +4,11 @@ import type { AddressInfo } from "node:net"; import { expect, test } from "./setup/test"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + completeReproofWithPassword, + useStaleSession, +} from "./setup/recent-proof"; import { SSR_PREFETCH_BASE_URL } from "./setup/ssr-prefetch-server"; /** @@ -20,6 +24,11 @@ import { SSR_PREFETCH_BASE_URL } from "./setup/ssr-prefetch-server"; * It runs against the second server, the one started with `APP_URL`: the MCP * surface fails closed without a configured origin. The callback is a local * loopback listener, which is also what desktop MCP clients register. + * + * Allowing a connection asks for a sign-in or re-proof within five minutes, so + * the journey starts from a session signed in ten minutes ago: the consent + * page must withhold the Allow form and link to the confirm page, and the + * person confirms with their password there before they can allow. */ test.describe("MCP OAuth consent", () => { test.use({ @@ -94,14 +103,34 @@ test.describe("MCP OAuth consent", () => { authorize.searchParams.set(key, value); } - await page.goto(authorize.toString()); - const allow = page.getByRole("button", { name: "Allow" }); - await expect(allow).toBeVisible(); - await allow.click(); + const endSession = await useStaleSession(page, E2E_USER.username); + try { + // Without a recent proof there is no form to allow with, only the way to + // give one. + await page.goto(authorize.toString()); + const allow = page.getByRole("button", { name: "Allow" }); + await expect( + page.getByRole("heading", { name: "Confirm it is you" }), + ).toBeVisible(); + await expect(allow).toHaveCount(0); + + await page.getByRole("link", { name: "Confirm and continue" }).click(); + await page.waitForURL((url) => url.pathname === "/confirm-access"); + await page.getByRole("button", { name: "Confirm", exact: true }).click(); + await completeReproofWithPassword(page, E2E_USER.password); + + // The confirm page returns to the exact consent request it came from. + await page.waitForURL((url) => url.pathname === authorize.pathname); + await expect(allow).toBeVisible(); + await allow.click(); + + await page.waitForURL((url) => url.toString().startsWith(callbackUrl), { + timeout: 15_000, + }); + } finally { + await endSession(); + } - await page.waitForURL((url) => url.toString().startsWith(callbackUrl), { - timeout: 15_000, - }); const hit = hits.at(-1); expect(hit, "the callback listener was never reached").toBeDefined(); expect(hit!.searchParams.get("code")).toMatch(/^hlac_/); diff --git a/e2e/setup/recent-proof.ts b/e2e/setup/recent-proof.ts new file mode 100644 index 000000000..27925f854 --- /dev/null +++ b/e2e/setup/recent-proof.ts @@ -0,0 +1,102 @@ +/** + * A browser session whose sign-in is older than the recent-proof window. + * + * Exports of the whole record, share links, token minting and connecting an + * assistant ask for a sign-in or re-proof within five minutes. The shared jar + * every authenticated spec reads is minted once, in global setup, so whether a + * spec meets that gate depends on how far into the run it happens to start: + * early it passes untouched, a few minutes later the web opens the re-proof + * dialog. A spec that assumes either one is timing-dependent. + * + * `useStaleSession` removes the timing. It inserts a session row of its own + * for the account, signed in ten minutes ago and never re-proved, and points + * the page's session cookie at it. The row belongs to this one test, so a + * re-proof in a spec running in the other worker cannot make it fresh, and its + * own re-proof cannot make the shared session fresh for anyone else. The + * returned function removes the row. + * + * The cookie secret is hashed the way the server hashes it (HMAC-SHA256 under + * `API_TOKEN_HMAC_KEY`); the web server inherits the runner's environment, so + * both sides hold the same key. + */ +import { createHmac, randomBytes } from "node:crypto"; + +import type { Page } from "@playwright/test"; +import pg from "pg"; + +import { expect } from "./test"; + +const SESSION_COOKIE = "healthlog_session"; + +/** Older than the five-minute recent-proof window, with room for a slow run. */ +const STALE_SIGN_IN_MINUTES = 10; + +function env(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`[recent-proof] ${name} is not set`); + return value; +} + +export async function useStaleSession( + page: Page, + username: string, +): Promise<() => Promise> { + const secret = `hls_${randomBytes(32).toString("hex")}`; + const tokenHash = createHmac("sha256", env("API_TOKEN_HMAC_KEY")) + .update(secret) + .digest("hex"); + const id = `e2estale${randomBytes(8).toString("hex")}`; + + const pool = new pg.Pool({ connectionString: env("DATABASE_URL") }); + try { + const inserted = await pool.query( + `INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) + SELECT $1, id, $2, NOW() + INTERVAL '1 day', + NOW() - make_interval(mins => $4) + FROM users WHERE username = $3`, + [id, tokenHash, username, STALE_SIGN_IN_MINUTES], + ); + if ((inserted.rowCount ?? 0) !== 1) { + throw new Error(`[recent-proof] no account named ${username}`); + } + } finally { + await pool.end(); + } + + // Replace the jar's session cookie in place: same name, domain and path, so + // the browser holds exactly one and cannot send the shared one alongside. + const context = page.context(); + const current = (await context.cookies()).filter( + (cookie) => cookie.name === SESSION_COOKIE, + ); + if (current.length === 0) { + throw new Error("[recent-proof] the page carries no session cookie"); + } + await context.addCookies( + current.map((cookie) => ({ ...cookie, value: secret })), + ); + + return async () => { + const cleanup = new pg.Pool({ connectionString: env("DATABASE_URL") }); + try { + await cleanup.query(`DELETE FROM sessions WHERE id = $1`, [id]); + } finally { + await cleanup.end(); + } + }; +} + +/** + * Answer the re-proof dialog with the account's password, the way a person + * does, and wait for it to close. The action behind it is retried by the page. + */ +export async function completeReproofWithPassword( + page: Page, + password: string, +): Promise { + const dialog = page.getByTestId("factor-reauth-dialog"); + await expect(dialog).toBeVisible(); + await dialog.getByTestId("factor-reauth-password").fill(password); + await dialog.getByTestId("factor-reauth-submit").click(); + await expect(dialog).toBeHidden(); +} diff --git a/e2e/share-documents.spec.ts b/e2e/share-documents.spec.ts index 862f4a426..72ed059b5 100644 --- a/e2e/share-documents.spec.ts +++ b/e2e/share-documents.spec.ts @@ -27,7 +27,11 @@ import type { Browser, Page } from "@playwright/test"; import { expect, test } from "./setup/test"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + completeReproofWithPassword, + useStaleSession, +} from "./setup/recent-proof"; import { ensureShareDocFixture, ensureVaultFixture, @@ -96,6 +100,9 @@ async function createShareWithDocs(page: Page): Promise { await expect(page.getByRole("button", { name: "Create link" })).toBeEnabled(); await page.getByRole("button", { name: "Create link" }).click(); + // Making a link asks for a recent proof, and the session is a stale one + // (see the `beforeEach`), so the owner confirms it is them first. + await completeReproofWithPassword(page, E2E_USER.password); const reveal = page.getByTestId("share-token-reveal"); await expect(reveal).toBeVisible(); @@ -146,6 +153,18 @@ async function openUnlockedClinician( test.describe("clinician document sharing", () => { test.use({ storageState: STORAGE_STATE_PATH }); + // Every test here starts from a session signed in ten minutes ago, so making + // a link always meets the re-proof dialog rather than only when the run has + // been going for five minutes. + let endStaleSession: (() => Promise) | null = null; + test.beforeEach(async ({ page }) => { + endStaleSession = await useStaleSession(page, E2E_USER.username); + }); + test.afterEach(async () => { + await endStaleSession?.(); + endStaleSession = null; + }); + test.beforeAll(async () => { await ensureVaultFixture(); await ensureShareDocFixture(); @@ -298,6 +317,7 @@ test.describe("clinician document sharing", () => { // Create the link — the one-time reveal (with the scannable QR) lands in // the same sheet, carrying the one attached document. await shareSheet.getByRole("button", { name: "Create link" }).click(); + await completeReproofWithPassword(page, E2E_USER.password); const reveal = shareSheet.getByTestId("share-token-reveal"); await expect(reveal).toBeVisible(); await expect(