mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
A guardian with a fresh second factor mints a one-time handover link (hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per profile, withdrawable) with a proposed access level per guardian: end, view only or manage, view only by default. The link is shown once with a copy button and a QR code; nothing is mailed. The panel sits at the end of each managed-profile row and hints when the person is under 16. The person opens /claim/<token>, sees a minimal preview (name, expiry, who keeps which access, no health data) and chooses a username, a required email and a password under the registration policy. The claim is one transaction under the managed-profile lock: the token is consumed, the credentials are set, the marker is cleared, every pending invitation the record offered is withdrawn, each guardian's access is set to its proposal, the disclaimer, onboarding and AI consent are reset, and every step is audited on the record and on each guardian's own account. A failure anywhere leaves the profile managed and the link unused. Each guardian is notified on their own channels. The new owner then sees each guardian's access on the first screen after signing in and decides finally; until then the proposal holds, and the decision waits at the top of Shared access. A guardian whose access moved since the claim is left alone. The same screen points the owner at their own notification channels, since reminders no longer reach the guardians. Both anonymous routes refuse a single-sign-on-only instance, refuse a live session before spending the per-address rate limit, check the link before probing names, and answer one 404 for every unusable token. The token travels in request bodies only; hlp_ joins the redaction and secret-shape rules and /claim/ the path-secret registry. Usernames starting with managed- are reserved. The session-surface and acting- account guards list the new surfaces with their reasons.
259 lines
9.4 KiB
TypeScript
259 lines
9.4 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. */
|
|
async function expectNoAxeViolations(page: Page): Promise<void> {
|
|
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();
|
|
}
|
|
});
|
|
});
|