|
1 | | -import { expect, test } from "./fixtures"; |
| 1 | +import { createHash } from "node:crypto"; |
| 2 | +import type { APIRequestContext, Page } from "@playwright/test"; |
| 3 | +import { createDemoUser, debateControl, deleteDemoUser, expect, test } from "./fixtures"; |
2 | 4 |
|
3 | | -// Feature map: features/auth.md. Demo-code sign-in, reload, rejection, and |
| 5 | +// Feature map: features/auth-demo-login.md. Demo-code sign-in, reload, rejection, and |
4 | 6 | // sign-out are covered by e2e/full-stack/auth.spec.ts, which drive also runs. |
5 | 7 |
|
6 | 8 | const mailer = process.env.WORKSPACE_TEST_MAILER_URL; |
| 9 | +const supabaseUrl = process.env.LOCAL_SUPABASE_URL; |
7 | 10 |
|
8 | | -test("auth-email-otp: an emailed one-time code signs an existing account in", async ({ page, user, request, evidence }) => { |
9 | | - test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive"); |
10 | | - const sentAfter = Date.now(); |
11 | | - await page.goto("/login"); |
12 | | - await page.getByRole("tab", { name: "Email" }).click(); |
13 | | - await page.getByLabel("Email address").fill(user.email); |
14 | | - await page.getByRole("button", { name: "Send code" }).click(); |
15 | | - await expect(page.getByText("Code sent").first()).toBeVisible(); |
16 | | - await evidence.shot("01-code-sent"); |
17 | | - |
18 | | - // Read the real message from the local Mailpit inbox. |
| 11 | +/** Reads the six-digit code sent to `email` after `sentAfter` from the local Mailpit inbox. */ |
| 12 | +async function readOtp(request: APIRequestContext, email: string, sentAfter: number) { |
19 | 13 | let otp = ""; |
20 | 14 | await expect.poll(async () => { |
21 | 15 | const payload = await (await request.get(`${mailer}/api/v1/messages`)).json() as { messages?: Array<Record<string, unknown>> }; |
22 | 16 | for (const message of payload.messages ?? []) { |
23 | | - if (!JSON.stringify(message).includes(user.email)) continue; |
| 17 | + if (!JSON.stringify(message).includes(email)) continue; |
24 | 18 | if (Date.parse(String(message.Created ?? 0)) < sentAfter - 5_000) continue; |
25 | 19 | const detail = await (await request.get(`${mailer}/api/v1/message/${message.ID}`)).text(); |
26 | 20 | otp = detail.match(/\b(\d{6})\b/)?.[1] ?? ""; |
27 | 21 | if (otp) break; |
28 | 22 | } |
29 | 23 | return otp; |
30 | 24 | }, { timeout: 30_000 }).toMatch(/^\d{6}$/); |
| 25 | + return otp; |
| 26 | +} |
| 27 | + |
| 28 | +/** Every path this tab shows, kept across reloads, so a flash of /login is caught. */ |
| 29 | +async function recordPaths(page: Page) { |
| 30 | + await page.addInitScript(() => { |
| 31 | + const record = () => { |
| 32 | + const seen = JSON.parse(sessionStorage.getItem("verify-paths") ?? "[]") as string[]; |
| 33 | + sessionStorage.setItem("verify-paths", JSON.stringify([...seen, location.pathname])); |
| 34 | + }; |
| 35 | + for (const method of ["pushState", "replaceState"] as const) { |
| 36 | + const original = history[method].bind(history); |
| 37 | + history[method] = (...args: Parameters<History["pushState"]>) => { |
| 38 | + original(...args); |
| 39 | + record(); |
| 40 | + }; |
| 41 | + } |
| 42 | + record(); |
| 43 | + }); |
| 44 | + return { |
| 45 | + clear: () => page.evaluate(() => sessionStorage.removeItem("verify-paths")), |
| 46 | + seen: () => page.evaluate(() => JSON.parse(sessionStorage.getItem("verify-paths") ?? "[]") as string[]), |
| 47 | + }; |
| 48 | +} |
| 49 | + |
| 50 | +test("auth-email-otp: an emailed one-time code signs an existing account in", async ({ page, user, request, evidence }) => { |
| 51 | + test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive"); |
| 52 | + const sentAfter = Date.now(); |
| 53 | + await page.goto("/login"); |
| 54 | + await page.getByRole("tab", { name: "Email" }).click(); |
| 55 | + await page.getByLabel("Email address").fill(user.email); |
| 56 | + await page.getByRole("button", { name: "Send code" }).click(); |
| 57 | + await expect(page.getByText("Code sent").first()).toBeVisible(); |
| 58 | + await evidence.shot("01-code-sent"); |
| 59 | + |
| 60 | + const otp = await readOtp(request, user.email, sentAfter); |
31 | 61 | await evidence.step("Read the one-time code from Mailpit"); |
32 | 62 |
|
33 | 63 | await page.getByRole("textbox", { name: "Verification code" }).fill(otp); |
@@ -66,3 +96,106 @@ test("auth-session-expired: a session the server revoked signs out to the login |
66 | 96 | await page.reload(); |
67 | 97 | await expect(page.getByText("Your session expired. Sign in again.")).toHaveCount(0); |
68 | 98 | }); |
| 99 | + |
| 100 | +test("auth-email-link: the emailed sign-in link signs in after a failed resend, and a used link says so", async ({ page, user, request, evidence }) => { |
| 101 | + test.skip(!mailer || !supabaseUrl, "Mailpit or LOCAL_SUPABASE_URL is missing; run through verify.sh drive"); |
| 102 | + const paths = await recordPaths(page); |
| 103 | + const sentAfter = Date.now(); |
| 104 | + await page.goto("/login"); |
| 105 | + await page.getByRole("tab", { name: "Email" }).click(); |
| 106 | + await page.getByLabel("Email address").fill(user.email); |
| 107 | + const otpRequest = page.waitForRequest((request) => request.url().includes("/auth/v1/otp")); |
| 108 | + await page.getByRole("button", { name: "Send code" }).click(); |
| 109 | + // The link returns to the callback route with this sign-in's own flow id. |
| 110 | + const redirectTo = new URL((await otpRequest).url()).searchParams.get("redirect_to") ?? ""; |
| 111 | + expect(redirectTo).toMatch(/^http:\/\/127\.0\.0\.1:18081\/auth\/callback\?sb_flow_id=[0-9a-f]{32}$/); |
| 112 | + await expect(page.getByText("Code sent").first()).toBeVisible(); |
| 113 | + const otp = await readOtp(request, user.email, sentAfter); |
| 114 | + await evidence.step(`Sent a code; the link returns to ${redirectTo.replace(/sb_flow_id=.*/, "sb_flow_id=<flow>")}`); |
| 115 | + |
| 116 | + // AUTH-01: the resend hits Auth's rate limit, and auth-js drops that resend's verifier. |
| 117 | + await page.route("**/auth/v1/otp**", (route) => route.fulfill({ |
| 118 | + status: 429, |
| 119 | + contentType: "application/json", |
| 120 | + body: JSON.stringify({ code: 429, error_code: "over_email_send_rate_limit", msg: "email rate limit exceeded" }), |
| 121 | + })); |
| 122 | + await page.getByRole("button", { name: "Resend code" }).click(); |
| 123 | + await expect(page.getByText("Too many attempts. Try again later.")).toBeVisible(); |
| 124 | + await page.unroute("**/auth/v1/otp**"); |
| 125 | + await evidence.shot("01-resend-rate-limited"); |
| 126 | + |
| 127 | + // The link in prod's email: Auth's /verify with the PKCE token hash of this code. |
| 128 | + const link = `${supabaseUrl}/auth/v1/verify?${new URLSearchParams({ |
| 129 | + token: `pkce_${createHash("sha224").update(user.email + otp).digest("hex")}`, |
| 130 | + type: "magiclink", |
| 131 | + redirect_to: redirectTo, |
| 132 | + })}`; |
| 133 | + await paths.clear(); |
| 134 | + await page.goto(link); |
| 135 | + await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 }); |
| 136 | + await expect(debateControl(page)).toBeVisible(); |
| 137 | + // AUTH-17: the callback waits for the exchange; it never shows /login on the way. |
| 138 | + const seen = await paths.seen(); |
| 139 | + await evidence.json("02-paths-after-link", seen); |
| 140 | + expect(seen).toContain("/auth/callback"); |
| 141 | + expect(seen).not.toContain("/login"); |
| 142 | + await page.reload(); |
| 143 | + await expect(page).toHaveURL(/\/app$/); |
| 144 | + await evidence.shot("02-signed-in-by-link"); |
| 145 | + await evidence.step("Opened the emailed link after the failed resend; signed in on /app without passing /login"); |
| 146 | + |
| 147 | + // AUTH-02: the same link again, in a browser that is no longer signed in. |
| 148 | + await page.evaluate(() => { |
| 149 | + for (const key of Object.keys(localStorage)) if (/^debate-auth-[a-z]+$/.test(key)) localStorage.removeItem(key); |
| 150 | + }); |
| 151 | + await page.goto(link); |
| 152 | + await expect(page).toHaveURL(/\/login$/, { timeout: 20_000 }); |
| 153 | + await expect(page.getByRole("alert")).toHaveText("This sign-in link has expired or was already used. Send a new code."); |
| 154 | + await evidence.shot("03-used-link-message"); |
| 155 | + await evidence.aria("03-used-link-message"); |
| 156 | + await evidence.step("Opened the used link again; /login says it expired or was used"); |
| 157 | +}); |
| 158 | + |
| 159 | +test("auth-switch-account: the next account on this browser starts on /app, gets its own tour, and sign-out stays local", async ({ signedIn: page, evidence }, testInfo) => { |
| 160 | + const second = await createDemoUser(`${testInfo.project.name}-second`); |
| 161 | + try { |
| 162 | + // The first account (tour already seen) leaves off on Settings and signs out. |
| 163 | + await page.goto("/app/settings"); |
| 164 | + const logout = page.waitForRequest((request) => request.url().includes("/auth/v1/logout")); |
| 165 | + await page.getByRole("button", { name: "Sign out" }).click(); |
| 166 | + // AUTH-19: this device only. |
| 167 | + expect(new URL((await logout).url()).searchParams.get("scope")).toBe("local"); |
| 168 | + await expect(page).toHaveURL(/\/login$/); |
| 169 | + await evidence.step("Signed out from /app/settings with scope=local"); |
| 170 | + |
| 171 | + // AUTH-06: a different account signs in on the same /login page. |
| 172 | + await page.getByRole("tab", { name: "Demo code" }).click(); |
| 173 | + await page.getByRole("textbox", { name: "Demo code" }).fill(second.code); |
| 174 | + await page.getByRole("button", { name: "Sign in with demo code" }).click(); |
| 175 | + await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 }); |
| 176 | + await evidence.step("Signed in as a second account; landed on /app, not /app/settings"); |
| 177 | + |
| 178 | + // AUTH-07: the second account gets its own tour. |
| 179 | + const tour = page.getByText("Welcome to DebateLab!"); |
| 180 | + await expect(tour).toBeVisible({ timeout: 10_000 }); |
| 181 | + await evidence.shot("01-second-account-tour"); |
| 182 | + if (await page.getByRole("button", { name: "Open menu" }).isVisible()) { |
| 183 | + // On phones the tour must not block the header's menu: open it and use it. |
| 184 | + await page.getByRole("button", { name: "Open menu" }).click(); |
| 185 | + const menu = page.getByRole("navigation", { name: "Mobile navigation" }); |
| 186 | + await expect(menu).toBeVisible(); |
| 187 | + await evidence.shot("02-menu-open-during-tour"); |
| 188 | + await menu.getByRole("link", { name: /History/ }).click(); |
| 189 | + await expect(page).toHaveURL(/\/app\/history$/); |
| 190 | + await evidence.step("Opened the menu and went to History while the tour showed"); |
| 191 | + // The tour waits on /app and picks up again there. |
| 192 | + await page.goBack(); |
| 193 | + await expect(tour).toBeVisible({ timeout: 10_000 }); |
| 194 | + } |
| 195 | + await page.locator('[data-test-id="button-skip"]').click(); |
| 196 | + await expect(tour).toBeHidden(); |
| 197 | + await evidence.step("Skipped the tour"); |
| 198 | + } finally { |
| 199 | + await deleteDemoUser(second); |
| 200 | + } |
| 201 | +}); |
0 commit comments