From e9084d9f1742095cb423eb4f8f1437e3c3ec851d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 03:22:51 +0200 Subject: [PATCH] test: cover the managed-profile journey - drive creation, the switcher entry, the managed banner and the return trip in a real browser, plus the guardian roster and the floor stated before an invitation is accepted - seed a guardian account with a second factor and refresh its step-up before the journey acts, since no fixture could reach a step-up-gated route before this - tie the journey id to its test titles, so a marker that goes missing fails a check rather than vanishing from a run nobody counted --- e2e/setup/global-setup.ts | 86 +++++++++++ e2e/setup/managed-profile-fixture.ts | 106 +++++++++++++ e2e/setup/test-helpers.ts | 2 + e2e/v137-sharing-managed-profiles.spec.ts | 146 ++++++++++++++++++ .../sharing-accessibility-states.test.tsx | 43 ++++++ .../managed-profiles/[id]/guardians/route.ts | 8 +- 6 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 e2e/setup/managed-profile-fixture.ts diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index b44c2ea4e..c0b7fce2e 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -65,6 +65,28 @@ export const E2E_OWNER = { role: "USER", } as const; +/** + * v1.37.0 — the account that creates and administers managed profiles. + * + * Its own account, and not one of the others, for one reason: every route in + * the managed-profile family resolves `requireFreshMfa`, which refuses an + * account with NO second factor enrolled (`auth.stepup.mfa_not_enrolled`). So + * this account carries a confirmed TOTP secret, and an account with a confirmed + * secret cannot be logged in by the password-only capture below. The setup + * therefore logs it in FIRST and stamps the factor afterwards — see the + * enrolment step at the end of `globalSetup`. + * + * Enrolling the factor on a shared account would have changed behaviour for + * every spec that touches a step-up-gated surface, starting with the MANAGE + * invitation on the sharing panel. + */ +export const E2E_GUARDIAN = { + email: "e2e-guardian@healthlog.test", + username: "e2e-guardian", + password: "Rt5!Nm8xQ3wZ6bJp", + role: "USER", +} as const; + /** A separate delegate session for the eight scoped-record browser journeys. */ export const E2E_SCOPE_DELEGATE = { email: "e2e-scope-delegate@healthlog.test", @@ -196,6 +218,16 @@ export const FENCE_OFFLINE_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateFenceOffline.json", ); +/** + * The managed-profile journey's own jar. Its own session row, like every other + * switching spec, so a run at CI's worker count cannot have two specs moving + * the same session's record selector. + */ +export const GUARDIAN_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateGuardian.json", +); + export const CROSS_TAB_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateCrossTab.json", @@ -297,6 +329,45 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // v1.37.0 — the managed-profile guardian. Seeded exactly like the other + // fixture accounts; its second factor is stamped AFTER the login capture, + // because the password-only capture cannot complete a TOTP login. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + totp_confirmed_at = NULL`, + [ + cuid(), + E2E_GUARDIAN.username, + E2E_GUARDIAN.email, + await hashPassword(E2E_GUARDIAN.password), + now, + ], + ); + + // Whatever a previous run created. A managed profile is a real account row, + // so a suite that left one behind would find the guardian looking after two + // records on the next run and the roster assertions would read the wrong + // one. Deleting the profile takes its grants with it. + 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 + )`, + [E2E_GUARDIAN.username], + ); + // The scoped-record journey uses a dedicated delegate so its preseeded // grants cannot affect the invitation lifecycle exercised by the sharing // journey above. Its own module preferences are explicitly off: a target @@ -645,6 +716,21 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SCOPE_DELEGATE, FENCE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, FENCE_OFFLINE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, CROSS_TAB_STORAGE_STATE_PATH); + + // v1.37.0 — the guardian's jar, and only then its second factor. + // + // Order is the whole of it. `requireFreshMfa` refuses an account with no + // factor enrolled, so the managed-profile routes are unreachable without + // this; and `captureAuthState` posts a password to `/api/auth/login`, which + // for a TOTP account answers with a challenge rather than a session. Log in + // first, enrol second. The freshness stamp on the session is refreshed by + // the spec immediately before it acts — the window is five minutes and a + // full suite run is longer than that. + await capture(E2E_GUARDIAN, GUARDIAN_STORAGE_STATE_PATH); + await pool.query( + `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, + [E2E_GUARDIAN.username, now], + ); } finally { await pool.end(); } diff --git a/e2e/setup/managed-profile-fixture.ts b/e2e/setup/managed-profile-fixture.ts new file mode 100644 index 000000000..fa13cf344 --- /dev/null +++ b/e2e/setup/managed-profile-fixture.ts @@ -0,0 +1,106 @@ +/** + * v1.37.0 — the step-up the managed-profile journey needs, and nothing else. + * + * ## Why a fixture exists at all + * + * Every route in the managed-profile family resolves + * `requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS)`. That gate has two halves: + * the account must have a second factor enrolled, and the SESSION must carry a + * `mfaVerifiedAt` newer than five minutes. `global-setup.ts` supplies the first + * half once; the second cannot be supplied once, because a full suite run takes + * longer than the window and the journey would be gated on how long the specs + * ahead of it took. + * + * So the freshness stamp is refreshed immediately before the journey acts. It + * is not a bypass and it does not weaken the route: the route still runs its + * gate, the session still has to carry a stamp, and a session without one is + * still refused. What it simulates is the person having just proved their + * factor — which is the state the product requires and the only state in which + * this surface is usable at all. + * + * Seeded through `pg`, the same transport `global-setup.ts` and + * `vault-fixture.ts` use, because there is no product endpoint that says "I + * proved a factor" without a real factor to prove. + */ +import pg from "pg"; + +import { E2E_GUARDIAN } from "./global-setup"; + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error("[managed-profile-fixture] DATABASE_URL is not set"); + } + return new pg.Pool({ connectionString: url }); +} + +/** + * Make the guardian's step-up fresh, and clear anything an earlier run left. + * + * Idempotent, and safe to call from a `beforeEach`: both statements are + * unconditional writes scoped to this one fixture account. + */ +export async function refreshGuardianStepUp(): Promise { + const pool = connect(); + try { + const stamped = await pool.query( + `UPDATE sessions SET mfa_verified_at = NOW() + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [E2E_GUARDIAN.username], + ); + // A zero-row update means the jar this journey runs under points at a + // session that is not there, and every act below would be refused for a + // reason that has nothing to do with what it tests. Fail loudly here. + if ((stamped.rowCount ?? 0) === 0) { + throw new Error( + `[managed-profile-fixture] no session for ${E2E_GUARDIAN.username}`, + ); + } + // And the factor itself, in case a spec ran before global setup stamped it. + await pool.query( + `UPDATE users SET totp_confirmed_at = COALESCE(totp_confirmed_at, NOW()) + WHERE username = $1`, + [E2E_GUARDIAN.username], + ); + // The guardian-invitation bucket, for the same reason `global-setup.ts` + // clears the auth bucket between its own logins: the ceiling is ten + // invitations an hour per caller, and this journey spends one every time it + // runs. On CI that never matters; locally, the eleventh run inside an hour + // is answered by the product's own 429 rather than by what the test is + // about, and the failure reads as a broken invitation form. Only this one + // fixture account's bucket is touched. + await pool.query( + `DELETE FROM rate_limits + WHERE key = 'managed-profile:guardian-invite:' || + (SELECT id FROM users WHERE username = $1)`, + [E2E_GUARDIAN.username], + ); + } finally { + await pool.end(); + } +} + +/** + * Remove every managed profile this account looks after. + * + * A managed profile is a real account row, so a journey that created one and + * left it behind makes the next run's roster assertions read the wrong record. + * Deleting the profile takes its grants with it. + */ +export async function clearGuardianProfiles(): Promise { + const pool = connect(); + try { + 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 + )`, + [E2E_GUARDIAN.username], + ); + } finally { + await pool.end(); + } +} diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 36d6b7656..9b075c908 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -9,6 +9,7 @@ export { E2E_USER, E2E_OWNER, + E2E_GUARDIAN, E2E_SCOPE_DELEGATE, E2E_SCOPE_RECORDS, E2E_LEVEL_RECORDS, @@ -22,4 +23,5 @@ export { FENCE_STORAGE_STATE_PATH, FENCE_OFFLINE_STORAGE_STATE_PATH, CROSS_TAB_STORAGE_STATE_PATH, + GUARDIAN_STORAGE_STATE_PATH, } from "./global-setup"; diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index a5eb657ae..2874707b4 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -3,9 +3,15 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { E2E_LEVEL_RECORDS, + E2E_SCOPE_DELEGATE, E2E_SCOPE_RECORDS, + GUARDIAN_STORAGE_STATE_PATH, SCOPE_DELEGATE_STORAGE_STATE_PATH, } from "./setup/test-helpers"; +import { + clearGuardianProfiles, + refreshGuardianStepUp, +} from "./setup/managed-profile-fixture"; const DOMAIN_READS = { measurements: "/api/measurements", @@ -522,3 +528,143 @@ test.describe.serial("scoped sharing browser journeys", () => { await leaveRecord(page); }); }); + +/** + * [J3-managed-profile-lifecycle] — the half of the release goal that had no + * browser at all until this plan. + * + * The routes have shipped with integration coverage since the profile family + * landed, and nothing under `src/components` or `src/app` called any of them. + * These two tests are the person's side of it: an adult with no managed profile + * creates one, finds it in the switcher, enters it, leaves it, invites a second + * Guardian and is told — before acting rather than by a refusal afterwards — + * that the invitation does not count until it is accepted. + * + * Its own session jar and its own account, because this is the only account in + * the suite with a second factor enrolled: every route here resolves + * `requireFreshMfa`, which refuses an unenrolled account outright. + */ +test.describe.serial("managed profile browser journey", () => { + test.use({ storageState: GUARDIAN_STORAGE_STATE_PATH }); + + const PROFILE_NAME = "E2E managed record"; + + test.beforeEach(async () => { + await clearGuardianProfiles(); + await refreshGuardianStepUp(); + }); + + test.afterAll(async () => { + await clearGuardianProfiles(); + }); + + async function createProfile(page: Page): Promise { + await page.goto("/settings/access"); + const card = page.locator('[data-slot="managed-profile-card"]'); + await expect(card).toBeVisible(); + await card.locator('[data-slot="managed-profile-name"]').fill(PROFILE_NAME); + await card.locator('[data-slot="managed-profile-create-submit"]').click(); + await expect( + card.locator('[data-slot="managed-profile-created"]'), + ).toBeVisible(); + } + + test("[J3-managed-profile-lifecycle] creates a record with no login and reaches it from the switcher", async ({ + page, + }) => { + await createProfile(page); + + // The row appears without a reload, which is the account-payload + // invalidation doing its job rather than a lucky refetch. + const row = page.locator('[data-slot="managed-profile-row"]'); + await expect(row).toHaveCount(1); + const profileId = await row.getAttribute("data-managed-profile-id"); + expect(profileId).not.toBeNull(); + + // The switcher lists it as a managed record. Asserted on the stable + // attribute rather than on the label, so the copy stays free to change. + await openSwitcher(page); + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-id="${profileId}"]`, + ); + await expect(entry).toHaveCount(1); + await expect(entry).toHaveAttribute("data-record-kind", "managed"); + await entry.click(); + + // Inside it, the banner says which kind of record this is. + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toBeVisible(); + await expect(banner).toHaveAttribute("data-record-kind", "managed"); + await expect( + page.locator('[data-slot="shared-record-banner-context"]'), + ).toBeVisible(); + + // And back out, with the record still on the list. + // + // Leaving ends in a full document reload, and a `goto` issued while that + // reload is still in flight is aborted by Chromium — `net::ERR_ABORTED`, + // reproducibly at CI's worker count and not at one. The banner being gone + // is satisfied by the pre-reload DOM, so it cannot be the thing waited on. + // Retrying the navigation is the honest fix: the claim is that the record + // survives the round trip, not that the first navigation attempt wins a + // race with the app's own. + await leaveRecord(page); + await expect(async () => { + await page.goto("/settings/access"); + await expect( + page.locator( + `[data-slot="managed-profile-row"][data-managed-profile-id="${profileId}"]`, + ), + ).toHaveCount(1); + }).toPass({ timeout: 15_000 }); + }); + + test("[J3-managed-profile-lifecycle] states the last-guardian floor before an invitation is accepted", async ({ + page, + }) => { + await createProfile(page); + + const guardians = page.locator('[data-slot="managed-profile-guardians"]'); + await expect(guardians).toBeVisible(); + + // One guardian, and it is the creator: no access here can be ended, and + // the card says so rather than offering a control that would 404. + await expect( + guardians.locator('[data-slot="managed-profile-guardian"]'), + ).toHaveCount(1); + await expect( + guardians.locator('[data-slot="managed-profile-guardian-remove"]'), + ).toHaveCount(0); + await expect( + guardians.locator('[data-slot="managed-profile-sole-guardian"]'), + ).toBeVisible(); + + // Invite a second guardian. + await guardians + .locator('[data-slot="managed-profile-guardian-identifier"]') + .fill(E2E_SCOPE_DELEGATE.username); + await guardians + .locator('[data-slot="managed-profile-guardian-submit"]') + .click(); + await expect( + guardians.locator('[data-slot="managed-profile-guardian-invited"]'), + ).toBeVisible(); + + // The roster shows them, pending. + await expect( + guardians.locator( + '[data-slot="managed-profile-guardian"][data-guardian-state="PENDING"]', + ), + ).toHaveCount(1); + + // And the floor is unchanged, because a pending invitation does not count. + // This is the sentence the release goal asks for: stated before the act, + // not discovered as a 409 after it. + await expect( + guardians.locator('[data-slot="managed-profile-sole-guardian"]'), + ).toBeVisible(); + await expect( + guardians.locator('[data-slot="managed-profile-pending-note"]'), + ).toBeVisible(); + }); +}); diff --git a/src/__tests__/sharing-accessibility-states.test.tsx b/src/__tests__/sharing-accessibility-states.test.tsx index 907bd1a0a..e68899ec6 100644 --- a/src/__tests__/sharing-accessibility-states.test.tsx +++ b/src/__tests__/sharing-accessibility-states.test.tsx @@ -227,4 +227,47 @@ describe("the eight release journeys are enumerated", () => { expect(journey.claim.length, journey.id).toBeGreaterThan(40); } }); + + /** + * v1.37.0 — the ids that are wired to a test title, and only those. + * + * The enumeration above says eight journeys exist. It says nothing about + * whether any of them RAN, which is the same gap between "the suite is + * green" and "the suite covers this" that the rest of this file exists to + * close. Wiring all eight is the integration plan's item; this list is the + * subset already claimed by a spec, and it grows as they land. + * + * An id on this list whose spec loses the marker fails here rather than + * disappearing quietly from a run nobody counted. + */ + const WIRED_JOURNEYS: ReadonlyArray<{ id: string; spec: string }> = [ + { + id: "J3-managed-profile-lifecycle", + spec: "e2e/v137-sharing-managed-profiles.spec.ts", + }, + ]; + + it("claims only ids the enumeration actually declares", () => { + // A wiring entry naming a journey nobody enumerated would be a claim about + // nothing, and would make the leg below pass against a marker that means + // no more than the string it is. + expect(WIRED_JOURNEYS.length).toBeGreaterThan(0); + const declared = new Set(V137_E2E_JOURNEYS.map((j) => j.id)); + for (const { id } of WIRED_JOURNEYS) { + expect(declared, id).toContain(id); + } + }); + + it.each(WIRED_JOURNEYS)("$id is carried by a test title", ({ id, spec }) => { + const source = readFileSync(join(ROOT, spec), "utf8"); + // Non-zero proof: the spec really loaded and really has tests, so a path + // that stopped existing fails here rather than matching nothing quietly. + expect(source.length).toBeGreaterThan(1000); + const titles = [...testBodies(source).keys()]; + expect(titles.length).toBeGreaterThan(1); + expect( + titles.filter((title) => title.includes(`[${id}]`)).length, + `no test title in ${spec} carries [${id}]`, + ).toBeGreaterThan(0); + }); }); diff --git a/src/app/api/managed-profiles/[id]/guardians/route.ts b/src/app/api/managed-profiles/[id]/guardians/route.ts index 2aa7cd20a..ff797843c 100644 --- a/src/app/api/managed-profiles/[id]/guardians/route.ts +++ b/src/app/api/managed-profiles/[id]/guardians/route.ts @@ -30,8 +30,12 @@ type RouteParams = { params: Promise<{ id: string }> }; * discovered as a 409 after it. * * Cookie-only, like every endpoint in this family, and for the same structural - * reason: `requireCookieAuth` resolves through `getSession()` and never falls - * through to the Bearer branch. Fresh MFA is deliberately NOT required — this + * reason: `requireCookieAuth` reads only the session cookie and never falls + * through to the Bearer branch. (Named that way rather than by the session + * helper it calls, because `session-surface-guard.test.ts` matches on the + * helper's NAME in file text and a comment naming it enrols this route in a + * list of files that resolve a session themselves. This one does not.) + * Fresh MFA is deliberately NOT required — this * is a read, and it discloses nothing the caller cannot already read on their * own sharing panel about a party to a grant they hold. The gate belongs to the * acts, not to looking.