import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; import { randomUUID } from "node:crypto"; import { translations } from "../src/i18n/translations"; const apiUrl = "http://localhost:5302/api"; const mailUrl = `http://127.0.0.1:${process.env.AUTH_RECOVERY_MAIL_PORT ?? "5303"}`; const t = translations.en; async function actualMailLink(request: APIRequestContext, recipient: string, pathname: string) { let messages: Array<{ sequence: number; body: string }> = []; await expect.poll(async () => { const response = await request.get(`${mailUrl}/messages`, { params: { recipient } }); expect(response.status()).toBe(200); messages = await response.json(); return messages.some((message) => message.body.includes(`http://localhost:3300${pathname}?`)); }, { message: "The configured local SMTP sender delivers the actual recovery email." }).toBe(true); const body = messages.findLast((message) => message.body.includes(`http://localhost:3300${pathname}?`))!.body; const value = body.match(/http:\/\/localhost:3300\/[^\s]+/)?.[0]; if (!value) throw new Error("Local recovery email has no application link."); const link = new URL(value); expect(link.origin).toBe("http://localhost:3300"); expect(link.pathname).toBe(pathname); expect(link.searchParams.has("token")).toBe(true); return link; } async function login(page: Page, email: string, password: string, expectedStatus: number) { await page.getByLabel(t.usernameOrEmail, { exact: true }).fill(email); await page.getByLabel(t.profileCurrentPassword, { exact: true }).fill(password); const response = page.waitForResponse((item) => item.url() === `${apiUrl}/auth/login` && item.request().method() === "POST"); await page.getByRole("button", { name: t.signInTitle, exact: true }).click(); expect((await response).status()).toBe(expectedStatus); } test.beforeEach(async ({ page }) => { await page.addInitScript(() => localStorage.setItem("uiLanguage", "en")); }); test("[PRES-E02] incomplete recovery links explain the problem without submitting tokens", async ({ page }) => { const submissions: string[] = []; page.on("request", (request) => { if (request.method() === "POST" && /\/auth\/(reset-password|verify-email)$/.test(request.url())) submissions.push(request.url()); }); await page.goto("/reset-password?email=incomplete%40example.test"); await expect(page.getByRole("main").getByRole("alert")).toContainText(t.missingResetLinkInfo); await expect(page.getByRole("button", { name: t.updatePassword, exact: true })).toBeDisabled(); await page.goto("/verify-email"); await expect(page.getByRole("main").getByRole("alert")).toContainText(t.missingVerifyLinkInfo); await expect(page.getByRole("button", { name: t.backToLogin, exact: true })).toBeVisible(); expect(submissions).toEqual([]); }); test("[PRES-E02 PRES-E05 PRES-E09] real registration, SMTP verification and password recovery preserve protected return and revoke the old session", async ({ page, browser, request }, testInfo) => { const email = `recovery-${randomUUID()}@example.test`; const initialPassword = "RecoveryBefore123!"; const newPassword = "RecoveryAfter456!"; const replayPassword = "RecoveryReplay789!"; const config = await request.get(`${apiUrl}/auth/config`); expect(config.status()).toBe(200); expect(await config.json()).toMatchObject({ requireAuth: true, allowRegistration: true, requireEmailVerification: true, googleEnabled: false, microsoftEnabled: false, turnstileEnabled: false }); await page.goto("/register"); await page.getByLabel(t.profileEmail, { exact: true }).fill(email); await page.getByLabel(t.profileNewPassword, { exact: true }).fill(initialPassword); await page.getByLabel(t.confirmPassword, { exact: true }).fill(initialPassword); const registered = page.waitForResponse((item) => item.url() === `${apiUrl}/auth/register` && item.request().method() === "POST"); await page.getByRole("button", { name: t.createAccount, exact: true }).click(); const registration = await registered; expect(registration.status()).toBe(202); expect(await registration.json()).toMatchObject({ verificationRequired: true }); await expect(page.getByRole("main").getByRole("alert").filter({ hasText: t.emailNotVerified })).toBeVisible(); const verification = await actualMailLink(request, email, "/verify-email"); // The product's fixed auth-email policy remains five requests/IP per 15min. // These are the five submissions in this isolated run: invalid verification, // valid verification, request reset, valid reset, replay reset. const invalidVerification = new URL(verification); invalidVerification.searchParams.set("token", "not-a-valid-identity-token"); const invalid = page.waitForResponse((item) => item.url() === `${apiUrl}/auth/verify-email` && item.request().method() === "POST"); await page.goto(invalidVerification.toString()); expect((await invalid).status()).toBe(400); await expect(page.getByRole("main").getByRole("alert")).toContainText("Invalid or expired link."); const verified = page.waitForResponse((item) => item.url() === `${apiUrl}/auth/verify-email` && item.request().method() === "POST"); await page.goto(verification.toString()); expect((await verified).status()).toBe(204); await expect(page.getByRole("status").filter({ hasText: t.verifyEmailSuccess })).toBeVisible(); const protectedPath = "/jobs?search=recovery-return"; await page.goto(protectedPath); await expect(page).toHaveURL("http://localhost:3300/"); await page.getByRole("button", { name: t.signInTitle, exact: true }).first().click(); await expect(page).toHaveURL(/\/login$/); await login(page, email, initialPassword, 200); await expect(page).toHaveURL(new RegExp("/jobs\\?search=recovery-return$")); expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(200); const recoveryContext = await browser.newContext(); try { await recoveryContext.addInitScript(() => localStorage.setItem("uiLanguage", "en")); const recovery = await recoveryContext.newPage(); await recovery.goto(`http://localhost:3300/forgot-password?email=${encodeURIComponent(email)}`); await expect(recovery.getByLabel(t.profileEmail, { exact: true })).toHaveValue(email); const requested = recovery.waitForResponse((item) => item.url() === `${apiUrl}/auth/request-password-reset` && item.request().method() === "POST"); await recovery.getByRole("button", { name: t.forgotPasswordSubmit, exact: true }).click(); expect((await requested).status()).toBe(204); await expect(recovery.getByRole("status").filter({ hasText: t.passwordResetRequestSent })).toBeVisible(); const resetLink = await actualMailLink(request, email, "/reset-password"); expect(resetLink.searchParams.get("email")).toBe(email); await recovery.goto(resetLink.toString()); await expect(recovery.getByLabel(t.profileEmail, { exact: true })).toHaveValue(email); await expect(recovery.getByLabel(t.profileEmail, { exact: true })).toBeDisabled(); await recovery.getByLabel(t.profileNewPassword, { exact: true }).fill(newPassword); await recovery.getByLabel(t.confirmPassword, { exact: true }).fill(newPassword); const reset = recovery.waitForResponse((item) => item.url() === `${apiUrl}/auth/reset-password` && item.request().method() === "POST"); await recovery.getByRole("button", { name: t.updatePassword, exact: true }).click(); expect((await reset).status()).toBe(204); await expect(recovery).toHaveURL(/\/login$/); expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(401); await login(recovery, email, initialPassword, 401); await expect(recovery).toHaveURL(/\/login$/); await expect(recovery.getByRole("main").getByRole("alert")).toBeVisible(); await recovery.goto(resetLink.toString()); await recovery.getByLabel(t.profileNewPassword, { exact: true }).fill(replayPassword); await recovery.getByLabel(t.confirmPassword, { exact: true }).fill(replayPassword); const replay = recovery.waitForResponse((item) => item.url() === `${apiUrl}/auth/reset-password` && item.request().method() === "POST"); await recovery.getByRole("button", { name: t.updatePassword, exact: true }).click(); expect((await replay).status()).toBe(400); await expect(recovery.getByRole("main").getByRole("alert")).toBeVisible(); await recovery.getByRole("button", { name: t.backToLogin, exact: true }).click(); await login(recovery, email, replayPassword, 401); await login(recovery, email, newPassword, 200); await expect(recovery).toHaveURL(/\/dashboard$/); expect((await recovery.request.get(`${apiUrl}/auth/me`)).status()).toBe(200); await testInfo.attach("verified-recovery-complete", { body: await recovery.screenshot(), contentType: "image/png" }); } finally { await recoveryContext.close(); } });