Files
jobtrackingapp/job-tracker-ui/auth-tests/auth-recovery.spec.ts

141 lines
8.7 KiB
TypeScript

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();
}
});