mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
test(e2e): an invite link registers from a cookie-less window
Every layer of the invite chain had unit coverage and the chain was broken anyway: the proxy test proved the edge admits the landing, the page test proved it redirects onto the registration form, and neither could see that the redirect arrives in an RSC flight payload after the shell has already flushed. The operator's path — mint an invitation, open the URL in a private window, reach a usable form — was exercised by no spec at any level. The new spec mints a real invitation in the admin UI and opens it twice: once in a genuinely cookie-less context, asserting both where the visitor lands and that they never passed through the sign-in page, and once in the admin's own window, asserting the explaining panel and that the admin's session survives. A third case pins the server half through the wire. Against `origin/main` all three fail — the anonymous visitor lands on `/auth/login`, the panel does not exist, and the registration POST answers 201 over a live session. The minted URL gains a test id so the spec reads it from its own element rather than from the first paragraph in the ticket.
This commit is contained in:
1 parent
be7fbe37d5
commit
0290554d36
2 files changed
+122
-1
No files matched your search
@@ -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/<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("already_authenticated");
|
||||
});
|
||||
});
|
||||
@@ -619,7 +619,10 @@ export function InviteTokensSection() {
|
||||
height={176}
|
||||
className="rounded-lg bg-white p-2"
|
||||
/>
|
||||
<p className="bg-muted w-full overflow-x-auto rounded-md p-2 text-center font-mono text-xs break-all select-all">
|
||||
<p
|
||||
className="bg-muted w-full overflow-x-auto rounded-md p-2 text-center font-mono text-xs break-all select-all"
|
||||
data-testid="admin-invites-minted-url"
|
||||
>
|
||||
{minted.url}
|
||||
</p>
|
||||
<Button
|
||||
|
||||
Reference in new issue
Block a user