diff --git a/e2e/invite-registration.spec.ts b/e2e/invite-registration.spec.ts new file mode 100644 index 000000000..b37b19dea --- /dev/null +++ b/e2e/invite-registration.spec.ts @@ -0,0 +1,118 @@ +/** + * The journey that would have caught the invite defect. + * + * Every layer of the invite chain had unit coverage and the chain was broken + * anyway: the proxy test proved the edge admits `/invite/`, the landing + * page test proved it redirects onto `/auth/register?invite=…`, and neither + * could see that in the real runtime the redirect arrives in an RSC flight + * payload AFTER the shell has flushed — so the shell mounted on `/invite/…`, + * read it as a protected route and raced its own `router.replace( + * "/auth/login")` against it. The operator scanned his own QR in a private + * window and landed on the sign-in page holding a one-time invitation the + * sign-in page cannot use. + * + * So this spec is deliberately end to end and deliberately about the SEAM: + * mint a real invitation in the admin UI, open the real URL in a genuinely + * cookie-less context, and assert both where the visitor ends up and where + * they never went. + * + * The second case is the other half of the same defect: the same link opened + * in the admin's own window used to render the bare form, and submitting it + * replaced his session cookie with the new account's — signing him out of his + * admin account and into a stranger's onboarding. + * + * Neither case completes a signup: that would add a user to the shared e2e + * database and spend the 5-per-15-minutes registration bucket every other + * spec shares. What the signup does with the token is pinned in + * `src/app/api/auth/register/__tests__/route.test.ts`. + */ +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; + +/** Mint an invitation in the admin UI and return its `/invite/` path. */ +async function mintInvite(page: import("@playwright/test").Page) { + await page.goto("/admin/invites"); + await page.getByTestId("admin-invites-open-create").click(); + await page.getByTestId("admin-invites-submit-create").click(); + const minted = page.getByTestId("admin-invites-minted"); + await expect(minted).toBeVisible(); + const url = await page.getByTestId("admin-invites-minted-url").textContent(); + expect(url).toMatch(/\/invite\/hlv_[0-9a-f]{64}$/); + // The minted URL carries whatever origin the instance is configured with + // (`APP_URL` / `NEXT_PUBLIC_APP_URL`), which is not necessarily the one + // Playwright is driving. The path is the part under test. + return new URL(url!.trim()).pathname; +} + +test.describe("invitation links", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("opens registration in a cookie-less window and never passes login", async ({ + page, + browser, + }) => { + const invitePath = await mintInvite(page); + + // The operator's private window: a context with no cookies at all, not a + // page that merely has not logged in. + const fresh = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + const guest = await fresh.newPage(); + const visited: string[] = []; + guest.on("framenavigated", (frame) => { + if (frame === guest.mainFrame()) visited.push(frame.url()); + }); + + try { + await guest.goto(invitePath); + await expect(guest).toHaveURL( + /\/auth\/register\?invite=hlv_[0-9a-f]{64}/, + ); + await expect(guest.getByTestId("register-invite-banner")).toBeVisible(); + // The form is reachable, not just the URL. + await expect(guest.locator("#username")).toBeVisible(); + // The defect, named: the visitor must never have been bounced through + // the sign-in page on the way. + expect(visited.filter((u) => u.includes("/auth/login"))).toEqual([]); + } finally { + // Contexts a spec creates itself are invisible to the route-cleanup + // fixture — see the header of `e2e/setup/test.ts`. + await fresh.close(); + } + }); + + test("shows the signed-in panel in the admin's own window and keeps the session", async ({ + page, + }) => { + const invitePath = await mintInvite(page); + + await page.goto(invitePath); + await expect(page).toHaveURL(/\/auth\/register\?invite=hlv_[0-9a-f]{64}/); + await expect(page.getByTestId("register-already-signed-in")).toBeVisible(); + // No form to submit, so no way to register over the live session. + await expect(page.locator("#username")).toHaveCount(0); + await expect(page.getByTestId("register-sign-out")).toBeVisible(); + + // The admin is still the admin. + const me = await page.request.get("/api/auth/me"); + expect(me.status()).toBe(200); + }); + + test("refuses a registration POST carried on a live session", async ({ + page, + }) => { + // The server half of the same rule, asserted through the wire rather than + // through the UI: the panel above is an explanation, not the enforcement. + const res = await page.request.post("/api/auth/register", { + data: { + email: "invite-over-session@healthlog.test", + username: "invite-over-session", + password: "ZJ4hN8x!Pq3vMr2C", + }, + }); + expect(res.status()).toBe(409); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("already_authenticated"); + }); +}); diff --git a/src/components/admin/invite-tokens-section.tsx b/src/components/admin/invite-tokens-section.tsx index 0e8a741ed..9047a519b 100644 --- a/src/components/admin/invite-tokens-section.tsx +++ b/src/components/admin/invite-tokens-section.tsx @@ -619,7 +619,10 @@ export function InviteTokensSection() { height={176} className="rounded-lg bg-white p-2" /> -

+

{minted.url}