141 lines
8.7 KiB
TypeScript
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();
|
|
}
|
|
});
|