Files
HealthLog/e2e/invite-registration.spec.ts
Marc-André Bombeck d2f47e3bb5 fix(auth): the refusal code joins the published catalogue
Two structural guards caught the new gate. The error-code catalogue is derived
from the route tree on every run and fails in both directions, so a code the
server emits and does not publish is a client that cannot branch on it; the
session-surface guard freezes the short list of files under `src/app` that
resolve a caller without `requireAuth`, and the register route now belongs on
it — `requireAuth` is the wrong gate there twice over, since it would throw at
the anonymous caller the route exists for and this route wants the ABSENCE of a
session.

The code is `auth.already_authenticated` rather than the bare
`already_authenticated`: the catalogue's own header rules that new codes are
dot.case, and it sits beside `auth.mfa.code_invalid` in the same family. The
OpenAPI document renders the catalogue into the ErrorEnvelope description, so
it is regenerated.
2026-09-11 09:51:32 +02:00

119 lines
5.1 KiB
TypeScript

/**
* 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/<token>`, 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/<token>` 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("auth.already_authenticated");
});
});