Files
HealthLog/e2e/v142-managed-profile-handover.spec.ts
Marc-André Bombeck d67032db96 test(e2e): wait for the document title before scanning the handover screens
The decision screen is reached by a client navigation, during which the old
route's title unmounts and the new one mounts from a suspended metadata
boundary that can reveal after the body has painted. Under load the scan ran
in that stretch and reported document-title. The helper now asserts the
title first, so a screen that never gets one still fails.

Refs #959
2026-10-17 10:43:59 +02:00

270 lines
10 KiB
TypeScript

/**
* v1.42 (#959) — a managed profile handed over to the person it describes,
* end to end in a browser: the Guardian mints the link, the person opens it in
* a browser that has never signed in, chooses a sign-in, lands on the decision
* about their former Guardian, and decides.
*
* The link is never mailed (by design: it is shown once and handed over in
* person or scanned). This journey "mails" it by reading it off the panel's
* `data-handover-url` and opening it in a second, cookie-less browser context,
* which is exactly the hop a person makes with a phone.
*
* Its own Guardian account, per project, seeded here: the shared
* `e2e-guardian` belongs to the managed-profile journey, which clears every
* profile that account looks after, and the two would delete each other's
* records mid-run. Every assertion addresses a stable `data-*` attribute or
* the database, never rendered copy.
*/
import AxeBuilder from "@axe-core/playwright";
import { hash } from "@node-rs/argon2";
import type { Page } from "@playwright/test";
import pg from "pg";
import { expect, test } from "./setup/test";
const PASSWORD = "Handover!Journey-2042";
function connect(): pg.Pool {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[handover-e2e] DATABASE_URL is not set");
return new pg.Pool({ connectionString: url });
}
/**
* WCAG 2.1 AA on the screen as it stands.
*
* The decision screen is reached by a client navigation, and during one the
* framework unmounts the old route's `<title>` and mounts the new one from a
* suspended metadata boundary, which can reveal after the page body has
* painted. Measured under CPU throttling: the decision is on screen for about
* 200 ms before the title arrives. A scan in that stretch audits the
* navigation, not the screen, so the title is waited for first, and asserted:
* a screen that never gets one still fails here.
*/
async function expectNoAxeViolations(page: Page): Promise<void> {
await expect(page).toHaveTitle("HealthLog");
const result = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(result.violations).toEqual([]);
}
function names(project: string) {
const slug = project
.replace(/[^a-z0-9]/gi, "")
.toLowerCase()
.slice(0, 12);
return {
guardian: `e2e-hg-${slug}`,
claimant: `e2e-hc-${slug}`,
};
}
/**
* A Guardian with a password, signed in through the real login route, then
* given a second factor and a session that has just proved it — the state
* `requireFreshMfa` needs and the only state in which a handover can be made.
*/
async function seedGuardian(
pool: pg.Pool,
username: string,
claimant: string,
): Promise<void> {
// Whatever a previous run left: the claimed account (no longer a managed
// profile, so it does not cascade with the guardian) and the guardian.
await pool.query(`DELETE FROM users WHERE username = $1 OR email = $2`, [
claimant,
`${claimant}@healthlog.test`,
]);
await pool.query(
`DELETE FROM users WHERE managed_profile_at IS NOT NULL AND id IN (
SELECT g.grantor_id FROM account_grants g JOIN users u ON u.id = g.grantee_id
WHERE u.username = $1)`,
[username],
);
await pool.query(`DELETE FROM users WHERE username = $1`, [username]);
const passwordHash = await hash(PASSWORD, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
disclaimer_acknowledged_at, disclaimer_acknowledged_version)
VALUES ($1, $2, $3, $4, 'USER', NOW(), NOW(), NOW(), true, NOW(), '1')`,
[
`c${Date.now().toString(36)}${username}`,
username,
`${username}@healthlog.test`,
passwordHash,
],
);
// The claim and login buckets for this address, so a local re-run inside the
// window meets the product rather than its rate limit.
await pool.query(
`DELETE FROM rate_limits WHERE key LIKE 'auth:claim%' OR key LIKE 'managed-profile:%'`,
);
}
async function stampSecondFactor(pool: pg.Pool, username: string) {
await pool.query(
`UPDATE users SET totp_confirmed_at = NOW() WHERE username = $1`,
[username],
);
await pool.query(
`UPDATE sessions SET mfa_verified_at = NOW()
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
[username],
);
}
test.describe("managed profile handover", () => {
test("guardian hands over, the person claims and decides", async ({
page,
browser,
baseURL,
}, testInfo) => {
test.setTimeout(120_000);
const { guardian, claimant } = names(testInfo.project.name);
const pool = connect();
try {
await seedGuardian(pool, guardian, claimant);
// ── The Guardian ─────────────────────────────────────────────────
const login = await page.request.post("/api/auth/login", {
data: { email: guardian, password: PASSWORD },
});
expect(login.status()).toBe(200);
await stampSecondFactor(pool, guardian);
const created = await page.request.post("/api/managed-profiles", {
data: {
displayName: "Robin",
dateOfBirth: "2012-03-04",
locale: "en",
timezone: "Europe/Berlin",
gender: null,
},
});
expect(created.status()).toBe(201);
const profileId = ((await created.json()) as { data: { id: string } })
.data.id;
await page.goto("/settings/access");
const row = page.locator(
`[data-slot="managed-profile-row"][data-managed-profile-id="${profileId}"]`,
);
await expect(row).toBeVisible();
await row
.locator('[data-slot="managed-profile-handover-toggle"]')
.click();
const form = row.locator('[data-slot="managed-profile-handover-form"]');
await expect(form).toBeVisible();
// Born 2012: the minor hint is shown, the choice stays the Guardian's.
await expect(
row.locator('[data-slot="managed-profile-handover-minor"]'),
).toBeVisible();
// The Guardian proposes to keep managing.
await form
.locator('[data-slot="managed-profile-handover-proposal"]')
.first()
.selectOption("manage");
await form
.locator('[data-slot="managed-profile-handover-create"]')
.click();
const linkPanel = row.locator(
'[data-slot="managed-profile-handover-link"]',
);
await expect(linkPanel).toBeVisible();
await expect(
row.locator('[data-slot="managed-profile-handover-qr"]'),
).toBeVisible();
const url = await linkPanel.getAttribute("data-handover-url");
expect(url).toMatch(/\/claim\/hlp_[0-9a-f]{64}$/);
await expectNoAxeViolations(page);
// ── The person, on a browser that never signed in ────────────────
const person = await browser.newContext({ baseURL });
try {
const claimPage = await person.newPage();
const path = new URL(url as string).pathname;
await claimPage.goto(path);
await expect(claimPage).toHaveURL(/\/auth\/claim\?token=hlp_/);
await expect(
claimPage.locator('[data-testid="claim-form-card"]'),
).toBeVisible();
await expect(
claimPage.locator(
'[data-slot="claim-guardian"][data-proposal="manage"]',
),
).toHaveCount(1);
await expectNoAxeViolations(claimPage);
await claimPage.locator("#claim-username").fill(claimant);
await claimPage
.locator("#claim-email")
.fill(`${claimant}@healthlog.test`);
await claimPage.locator("#claim-password").fill(PASSWORD);
await claimPage.locator('[data-slot="claim-submit"]').click();
await expect(claimPage).toHaveURL(/\/onboarding\/handover$/, {
timeout: 30_000,
});
const decision = claimPage.locator(
'[data-slot="handover-decision-guardian"][data-decidable="true"]',
);
await expect(decision).toHaveCount(1);
await expect(
claimPage.locator('[data-slot="handover-notification-hint"]'),
).toBeVisible();
await expectNoAxeViolations(claimPage);
// The person decides: view only, not managing.
await decision
.locator('[data-slot="handover-decision-choice"]')
.selectOption("read");
await claimPage
.locator('[data-slot="handover-decision-submit"]')
.click();
await expect(claimPage).toHaveURL(/\/onboarding(\?|$)/, {
timeout: 30_000,
});
// The used link is dead for anyone after.
const reused = await person.request.post("/api/auth/claim/preview", {
data: { token: (url as string).split("/claim/")[1] },
});
// A signed-in browser is refused before the token is read; a fresh one
// would get the uniform 404. Either way, nothing is handed over twice.
expect([404, 409]).toContain(reused.status());
} finally {
await person.close();
}
// ── What the database says ───────────────────────────────────────
const account = await pool.query(
`SELECT managed_profile_at, username, email, password_hash IS NOT NULL AS has_password
FROM users WHERE id = $1`,
[profileId],
);
expect(account.rows[0]).toMatchObject({
managed_profile_at: null,
username: claimant,
has_password: true,
});
const live = await pool.query(
`SELECT g.access FROM account_grants g JOIN users u ON u.id = g.grantee_id
WHERE g.grantor_id = $1 AND u.username = $2 AND g.revoked_at IS NULL`,
[profileId, guardian],
);
expect(live.rows).toEqual([{ access: "READ" }]);
} finally {
await pool.end();
}
});
});