test(e2e): meet the recent-proof gate from a stale session, as a person does

Connecting an assistant and making a share link ask for a sign-in or
re-proof within five minutes. The shared jar is signed in once in global
setup, so the specs passed early in a run and failed later. They now start
from a session of their own signed in ten minutes ago: the consent page
withholds Allow and links to the confirm page, where the password re-proof
returns to the consent request; the share form opens the re-proof dialog
and creates the link after the password is given.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 03:00:36 +02:00
1 parent dd9efb5066
commit a6daa84c17
3 files changed
+160 -9

No files matched your search

+37 -8
View File
@@ -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_/);
+102
View File
@@ -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<void>> {
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<void> {
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();
}
+21 -1
View File
@@ -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<CreatedShare> {
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<void>) | 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(