/** * AI Software Factory — E2E: Authentication (Phase 1) * * Covers the full auth flow: registration, login, logout, session handling, * wrong-password errors, password reset, and rate limiting. These are the * canonical auth E2E tests from AGENTS.md (Phase 1 exit criteria). * * Selector convention (documented in tests/README.md): * data-testid="auth-email" login/register email input * data-testid="auth-password" login/register password input * data-testid="auth-submit" login/register submit button * data-testid="auth-error" error message under the form * data-testid="user-menu" top-right user menu * data-testid="user-menu-logout" logout item in the user menu * data-testid="sidebar" workspace sidebar (visible when logged in) */ import { test, expect, DEMO_EMAIL, DEMO_PASSWORD } from "../fixtures"; import { register, login, me } from "../utils/api"; const uniqueEmail = () => `e2e-auth-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`; test.describe("Authentication — Phase 1", () => { test("register → verify email → login lands in personal workspace", async ({ browser, }) => { // NOTE: email verification itself is SKIPPED in this flow — dev has no // mail server. The register response in dev mode auto-verifies (or the // seeded user is already verified); we assert the downstream behavior // (login works, personal workspace exists). See "verify email" test below. const email = uniqueEmail(); const password = "correct-horse-battery-9"; const name = "Auth E2E User"; // Register + login through the API (the API is the contract). const plain = await browser.newContext(); const plainApi = plain.request; await register(plainApi, email, password, name); await login(plainApi, email, password); // Now drive the UI with the new user's session: the cookies from the // login jar (the httpOnly `factory_refresh` cookie is the source of // truth). The access token is NOT mirrored into localStorage — the web // client keeps it in JS memory and re-issues it from the cookie on load // (silent refresh; security-architecture.md — S10 dropped the stale // Phase-1 localStorage mirror). const context = await browser.newContext(); await context.addCookies( (await plain.cookies()).map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path, expires: c.expires, httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite, })), ); const page = await context.newPage(); await page.goto("/"); // After login the app lands in the user's (auto-created) personal // workspace: the sidebar must be visible. await expect(page.getByTestId("sidebar")).toBeVisible({ timeout: 20_000 }); await context.close(); await plain.close(); }); test("login → redirect to personal workspace", async ({ browser }) => { // Fresh context: no auth state at all. const context = await browser.newContext(); const page = await context.newPage(); await page.goto("/login"); await page.getByTestId("auth-email").fill(DEMO_EMAIL); await page.getByTestId("auth-password").fill(DEMO_PASSWORD); await page.getByTestId("auth-submit").click(); // Login succeeds → redirected away from /login into the workspace shell. await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 20_000, }); await expect(page.getByTestId("sidebar")).toBeVisible(); await context.close(); }); test("logout → redirect to login page", async ({ authPage }) => { await authPage.goto("/"); await expect(authPage.getByTestId("sidebar")).toBeVisible(); await authPage.getByTestId("user-menu").click(); await authPage.getByTestId("user-menu-logout").click(); await authPage.waitForURL((url) => url.pathname.startsWith("/login"), { timeout: 15_000, }); // The sidebar must be gone after logout. await expect(authPage.getByTestId("sidebar")).toHaveCount(0); }); test("wrong password shows error and does not log in", async ({ browser }) => { const context = await browser.newContext(); const page = await context.newPage(); await page.goto("/login"); await page.getByTestId("auth-email").fill(DEMO_EMAIL); await page.getByTestId("auth-password").fill("definitely-wrong-password-123"); await page.getByTestId("auth-submit").click(); // Error message visible, still on the login page. const error = page.getByTestId("auth-error"); await expect(error).toBeVisible({ timeout: 10_000 }); await expect(error).toContainText(/invalid|incorrect|wrong/i); await expect(page).toHaveURL(/\/login/); await expect(page.getByTestId("sidebar")).toHaveCount(0); await context.close(); }); test("registering an existing email is rejected", async ({ api }) => { // DEMO_EMAIL already exists (seeded). const res = await api.post("/api/v1/auth/register", { data: { email: DEMO_EMAIL, password: "another-password-123", name: "Dupe" }, }); expect([400, 409]).toContain(res.status()); }); test("verify email (skip: no mail server in dev)", async () => { // Email verification (Phase 1) sends a magic link. Local dev has no SMTP // sink, so this test is intentionally skipped until a mail-capture // service (e.g. Mailpit) is added to docker-compose. test.skip(true, "lands with a mail sink in dev (Mailpit in docker-compose) — Phase 1"); // When unskipped: register, read the verification link from the captured // mail, open it, and assert GET /api/v1/auth/me reports verified=true. }); test("password reset flow (skip: token not exposed to the client in dev)", async () => { // The reset token is sent by email; without a mail sink the E2E cannot // complete the loop. The integration suite covers the full contract // (request → token → single-use → expiry) via the DB. test.skip(true, "covered by tests/integration (auth.integration.test.ts) — Phase 1"); // When unskipped: request reset → grab token (mail sink or admin API) → // POST /api/v1/auth/password-reset/confirm → old password invalid, // new password valid, token single-use (second confirm fails). }); test("rate limit kicks in after 10+ failed logins", async ({ browser }) => { // The API rate-limits /api/v1/auth/login (auth edge zone: 10 req/min per // IP at nginx; the API applies its own limit). After 10+ rapid failures // from one IP the endpoint must return 429. const context = await browser.newContext(); const api = context.request; const email = uniqueEmail(); // Register the user first so failures are "wrong password", not // "unknown user" (both are rate-limited, but this is the documented case). const regApi = await browser.newContext(); await register(regApi.request, email, "pw-12345678", "Rate Limit User"); await regApi.close(); let got429 = false; let lastStatus = 0; for (let i = 0; i < 12; i++) { const res = await api.post("/api/v1/auth/login", { data: { email, password: "wrong-password-" + i }, }); lastStatus = res.status(); if (res.status() === 429) { got429 = true; break; } } expect( got429, `expected a 429 within 12 failed logins (last status: ${lastStatus}) — ` + "check RATE_LIMIT_* env / auth rate limiter config", ).toBe(true); // A correct password is ALSO rejected while limited (the limiter does // not inspect the body). const resAfter = await api.post("/api/v1/auth/login", { data: { email, password: "pw-12345678" }, }); expect(resAfter.status()).toBe(429); await context.close(); }); test("refresh token rotation: reuse of a rotated token is rejected", async ({ api, }) => { // API-level proof of rotation (the UI inherits it). Login → rotate → // reusing the FIRST refresh token must be rejected (reuse detection). const context = api; // already the demo user's context const email = uniqueEmail(); // Use a fresh user so rotation state is clean. const reg = await register(context, email, "rotate-pw-12345678", "Rotation User"); void reg; const l1 = await login(context, email, "rotate-pw-12345678"); const first = l1.refreshToken; expect(first, "login response must include a refresh token").toBeTruthy(); // Rotate once (the /refresh endpoint returns a NEW token and invalidates // the old one). const r1 = await context.post("/api/v1/auth/refresh", { data: { refreshToken: first }, }); expect(r1.status()).toBe(200); const r1body = await r1.json(); const second: string | undefined = r1body?.refreshToken ?? r1body?.refresh_token; expect(second).toBeTruthy(); expect(second).not.toBe(first); // Reusing the FIRST (already rotated) token must fail — reuse detection. const reuse = await context.post("/api/v1/auth/refresh", { data: { refreshToken: first }, }); expect([400, 401, 403]).toContain(reuse.status()); }); test("me endpoint reflects the authenticated user", async ({ api, user }) => { const who = await me(api); expect(who.email).toBe(user.email); }); });