mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
119 lines
5.1 KiB
TypeScript
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");
|
|
});
|
|
});
|