mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
dd9efb5066
commit
a6daa84c17
3 files changed
+160
-9
No files matched your search
@@ -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_/);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user