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
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 03:22:51 +02:00
1 parent e4870f3b5f
commit e9084d9f17
6 files changed
+389 -2

No files matched your search

+86
View File
@@ -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<void> {
],
);
// 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<void> {
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();
}
+106
View File
@@ -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<void> {
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<void> {
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();
}
}
+2
View File
@@ -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";
+146
View File
@@ -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<void> {
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();
});
});
@@ -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);
});
});
@@ -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.