import { test, expect } from '@chromatic-com/playwright'; import type { Page } from '@playwright/test'; import { CONFIG, loginViaAPI, loginViaUI, navigateTo } from './helpers'; // ============================================================================= // AUTH DEEP — Comprehensive behavioral tests for the Veza authentication system // Covers: login validation, register validation, show/hide password, sessions, // forgot password, and security concerns (XSS, SQLi, httpOnly cookies). // // Source code references: // - LoginPage: apps/web/src/features/auth/pages/LoginPage.tsx // - RegisterPage: apps/web/src/features/auth/components/register-page/* // - ForgotPassword: apps/web/src/features/auth/pages/ForgotPasswordPage.tsx // - useRegisterPage: apps/web/src/features/auth/components/register-page/useRegisterPage.ts // - AuthInput: apps/web/src/features/auth/components/AuthInput.tsx // - authStore: apps/web/src/features/auth/store/authStore.ts // // Backend endpoints (from veza-backend-api/internal/core/auth/handler.go): // POST /api/v1/auth/login // POST /api/v1/auth/register // POST /api/v1/auth/logout // POST /api/v1/auth/password/reset-request // POST /api/v1/auth/password/reset // GET /api/v1/auth/verify-email // GET /api/v1/auth/check-username // ============================================================================= const LOGIN_FORM = '[data-testid="login-form"]'; const LOGIN_SUBMIT = '[data-testid="login-submit"]'; const REGISTER_FORM = '[data-testid="register-form"]'; const REGISTER_SUBMIT = '[data-testid="register-submit"]'; const FORGOT_FORM = '[data-testid="forgot-password-form"]'; const FORGOT_SUBMIT = '[data-testid="forgot-password-submit"]'; /** Clear auth state so PublicRoute does not redirect to /dashboard. */ async function clearAuth(page: Page): Promise { await page.context().clearCookies(); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); } /** Navigate to /login with a clean session. */ async function gotoLogin(page: Page): Promise { await page.goto(`${CONFIG.baseURL}/login`, { waitUntil: 'domcontentloaded' }); await clearAuth(page); await page.goto(`${CONFIG.baseURL}/login`, { waitUntil: 'domcontentloaded' }); await page.locator(LOGIN_FORM).waitFor({ state: 'visible', timeout: 15_000 }); } /** Navigate to /register with a clean session. */ async function gotoRegister(page: Page): Promise { await page.goto(`${CONFIG.baseURL}/register`, { waitUntil: 'domcontentloaded' }); await clearAuth(page); await page.goto(`${CONFIG.baseURL}/register`, { waitUntil: 'domcontentloaded' }); await page.locator(REGISTER_FORM).waitFor({ state: 'visible', timeout: 15_000 }); } /** Navigate to /forgot-password with a clean session. */ async function gotoForgotPassword(page: Page): Promise { await page.goto(`${CONFIG.baseURL}/forgot-password`, { waitUntil: 'domcontentloaded' }); await clearAuth(page); await page.goto(`${CONFIG.baseURL}/forgot-password`, { waitUntil: 'domcontentloaded' }); await page.locator(FORGOT_FORM).waitFor({ state: 'visible', timeout: 15_000 }); } /** Generate a unique email/username for each test run. */ function uniqueAccount(label = 'deep'): { email: string; username: string; password: string } { const ts = Date.now(); const suffix = Math.random().toString(36).slice(2, 6); return { email: `${label}-${ts}-${suffix}@test.veza`, username: `${label}${ts}${suffix}`, password: 'SecurePass123!@#', }; } // ============================================================================= // 1. LOGIN FORM VALIDATION (8 tests) // ============================================================================= test.describe('AUTH-DEEP — Login form validation', () => { test('01. Empty email shows required error', async ({ page }) => { await gotoLogin(page); const emailInput = page.locator('input[type="email"]'); await emailInput.click(); await emailInput.blur(); const alert = page.getByRole('alert').first(); await expect(alert).toBeVisible({ timeout: 5_000 }); const alertText = ((await alert.textContent()) || '').toLowerCase(); // i18n: "Email is required" or "Email requis" expect(alertText).toMatch(/email.*(required|requis|obligatoire)/i); }); test('02. Invalid email format shows validation error', async ({ page }) => { await gotoLogin(page); const emailInput = page.locator('input[type="email"]'); await emailInput.fill('not-an-email-at-all'); await emailInput.blur(); const alert = page.getByRole('alert').first(); await expect(alert).toBeVisible({ timeout: 5_000 }); const alertText = ((await alert.textContent()) || '').toLowerCase(); expect(alertText).toMatch(/invalid|invalide|format/i); // And it must NOT mention "required" since the field is not empty expect(alertText).not.toMatch(/required|requis/i); }); test('03. Empty password shows required error', async ({ page }) => { await gotoLogin(page); // Fill a valid email, leave password blank await page.locator('input[type="email"]').fill('test@example.com'); const passwordInput = page.locator('input[type="password"]').first(); await passwordInput.click(); await passwordInput.blur(); // Submit to force validation await page.getByTestId('login-submit').click(); const passwordError = page.getByRole('alert').filter({ hasText: /password.*(required|requis|obligatoire)|mot de passe.*(required|requis|obligatoire)/i, }); await expect(passwordError.first()).toBeVisible({ timeout: 5_000 }); }); test('04. Wrong credentials show generic error (no field leakage)', async ({ page }) => { await gotoLogin(page); await page.locator('input[type="email"]').fill(CONFIG.users.listener.email); await page.locator('input[type="password"]').first().fill('TotallyWrong123!'); await page.getByTestId('login-submit').click(); const alert = page.getByRole('alert').first(); await expect(alert).toBeVisible({ timeout: 15_000 }); const alertText = ((await alert.textContent()) || '').toLowerCase(); // Must NOT leak WHICH field is wrong. // Forbidden: "email does not exist", "user not found", "password incorrect for this user" expect(alertText).not.toMatch(/email.*(not found|does not exist|doesn't exist|introuvable)/i); expect(alertText).not.toMatch(/user.*(not found|does not exist|inexistant)/i); expect(alertText).not.toMatch(/no such (user|account|email)/i); // Must show a generic credentials error expect(alertText).toMatch(/incorrect|invalid|invalide|identifiants|credentials/i); // Must stay on /login await expect(page).toHaveURL(/\/login/); }); test('05. Account lockout after N failed attempts', async ({ page }) => { test.setTimeout(90_000); await gotoLogin(page); // Use a unique email that does not exist to avoid locking a seed account const victim = `lockout-probe-${Date.now()}@test.veza`; // Fire several failed attempts directly via the API (faster + deterministic) const attempts = 8; const statuses: number[] = []; for (let i = 0; i < attempts; i++) { const res = await page.request.post(`${CONFIG.apiURL}/api/v1/auth/login`, { data: { email: victim, password: 'WrongPassword000!', remember_me: false }, failOnStatusCode: false, }); statuses.push(res.status()); } // All responses must be error statuses (not 200). Typically 401 / 423 / 429. expect(statuses.every((s) => s >= 400)).toBeTruthy(); // The response should be consistent: never return 200 for a non-existent account const successCount = statuses.filter((s) => s === 200).length; expect(successCount).toBe(0); // Last attempts should indicate either locked (423), too many requests (429), // or at least not a generic 500 server crash. const lastStatus = statuses[statuses.length - 1]; expect(lastStatus).not.toBe(500); }); test('06. Successful login redirects away from /login', async ({ page }) => { test.setTimeout(45_000); await gotoLogin(page); await page.locator('input[type="email"]').fill(CONFIG.users.listener.email); await page.locator('input[type="password"]').first().fill(CONFIG.users.listener.password); await page.getByTestId('login-submit').click(); // STRICT: must redirect somewhere that's not /login await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 20_000 }); // The authenticated shell must be rendered await expect(page.locator('main, [role="main"]').first()).toBeVisible({ timeout: 10_000 }); // Must land on /dashboard (per LoginPage.tsx navigate('/dashboard')) expect(page.url()).toContain('/dashboard'); }); test('07. Remember me persists the email across reload', async ({ page }) => { test.setTimeout(45_000); await gotoLogin(page); const email = CONFIG.users.listener.email; await page.locator('input[type="email"]').fill(email); await page.locator('input[type="password"]').first().fill(CONFIG.users.listener.password); // Tick remember-me const rememberMe = page.locator('#remember_me'); await expect(rememberMe).toBeVisible({ timeout: 5_000 }); await rememberMe.check(); await page.getByTestId('login-submit').click(); await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 20_000 }); // The LoginPage stores the email in localStorage under 'rememberedEmail' const stored = await page.evaluate(() => localStorage.getItem('rememberedEmail')); expect(stored).toBe(email); // Logout (clear session) and return to /login — email should be prefilled await page.context().clearCookies(); await page.evaluate(() => { // Keep rememberedEmail, clear auth const remembered = localStorage.getItem('rememberedEmail'); localStorage.clear(); if (remembered) localStorage.setItem('rememberedEmail', remembered); }); await page.goto(`${CONFIG.baseURL}/login`, { waitUntil: 'domcontentloaded' }); await page.locator(LOGIN_FORM).waitFor({ state: 'visible', timeout: 10_000 }); const emailValue = await page.locator('input[type="email"]').inputValue(); expect(emailValue).toBe(email); }); test('08. Login request fires a single POST and uses cookies', async ({ page }) => { await gotoLogin(page); const loginRequests: { url: string; method: string; withCredentials: boolean }[] = []; page.on('request', (req) => { if (req.url().includes('/auth/login') && req.method() === 'POST') { loginRequests.push({ url: req.url(), method: req.method(), withCredentials: true, }); } }); await page.locator('input[type="email"]').fill(CONFIG.users.listener.email); await page.locator('input[type="password"]').first().fill(CONFIG.users.listener.password); await page.getByTestId('login-submit').click(); await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 20_000 }); // Exactly one POST /auth/login (no duplicate double-submit) const loginPosts = loginRequests.filter((r) => /\/auth\/login(\?|$)/.test(r.url)); expect(loginPosts.length).toBe(1); // After successful login a session cookie MUST be present const cookies = await page.context().cookies(); const sessionCookie = cookies.find( (c) => /session|token|auth|jwt|veza/i.test(c.name) && (c.domain.includes('127.0.0.1') || c.domain.includes('localhost')), ); expect(sessionCookie, `No auth cookie found. Cookies: ${cookies.map((c) => c.name).join(', ')}`).toBeTruthy(); }); }); // ============================================================================= // 2. REGISTER FORM VALIDATION (10 tests) // ============================================================================= test.describe('AUTH-DEEP — Register form validation', () => { test('09. Email is required and its format is validated', async ({ page }) => { await gotoRegister(page); // Empty email const emailInput = page.locator('#register-email'); await emailInput.click(); await emailInput.blur(); await expect(page.locator('#register-email-error').first()).toBeVisible({ timeout: 5_000 }); const requiredText = (await page.locator('#register-email-error').first().textContent()) || ''; expect(requiredText.toLowerCase()).toMatch(/required|requis|obligatoire/i); // Invalid format await emailInput.fill('not-an-email'); await emailInput.blur(); await expect(page.locator('#register-email-error').first()).toBeVisible({ timeout: 5_000 }); const invalidText = (await page.locator('#register-email-error').first().textContent()) || ''; expect(invalidText.toLowerCase()).toMatch(/invalid|invalide|format/i); }); test('10. Password < 12 chars rejected client-side (no API call)', async ({ page }) => { await gotoRegister(page); const registerCalls: string[] = []; page.on('request', (req) => { if (req.url().includes('/auth/register') && req.method() === 'POST') { registerCalls.push(req.url()); } }); const acct = uniqueAccount('short'); await page.locator('#register-username').fill(acct.username); await page.locator('#register-email').fill(acct.email); await page.locator('#register-password').fill('Short1!'); await page.locator('#register-password_confirm').fill('Short1!'); // Accept terms await page.locator('#register-terms').click({ force: true }); await page.waitForTimeout(300); await page.locator(REGISTER_SUBMIT).click(); await page.waitForTimeout(1_000); // Client-side validation should catch this before any API call const errorAlert = page.getByRole('alert'); await expect(errorAlert.first()).toBeVisible({ timeout: 5_000 }); const errorText = ((await errorAlert.first().textContent()) || '').toLowerCase(); expect(errorText).toMatch(/12|trop court|too short|minimum|au moins|at least/i); // No API call should have been made expect(registerCalls.length).toBe(0); }); test('11. Weak password (no uppercase/digit/special) rejected', async ({ page }) => { await gotoRegister(page); const registerCalls: string[] = []; page.on('request', (req) => { if (req.url().includes('/auth/register') && req.method() === 'POST') { registerCalls.push(req.url()); } }); const acct = uniqueAccount('weak'); await page.locator('#register-username').fill(acct.username); await page.locator('#register-email').fill(acct.email); // 13 lowercase chars: length OK but no uppercase/digit/special await page.locator('#register-password').fill('aaaaaaaaaaaaa'); await page.locator('#register-password_confirm').fill('aaaaaaaaaaaaa'); await page.locator('#register-terms').click({ force: true }); await page.waitForTimeout(300); await page.locator(REGISTER_SUBMIT).click(); await page.waitForTimeout(1_000); const errorAlert = page.getByRole('alert').first(); await expect(errorAlert).toBeVisible({ timeout: 5_000 }); const errorText = ((await errorAlert.textContent()) || '').toLowerCase(); // The message should mention weakness/complexity expect(errorText).toMatch(/weak|faible|complex|majuscule|uppercase|chiffre|digit|special|spécial/i); // And no API call fired expect(registerCalls.length).toBe(0); }); test('12. Password confirmation mismatch shows explicit error', async ({ page }) => { await gotoRegister(page); await page.locator('#register-password').fill('SecurePass123!@#'); await page.locator('#register-password_confirm').fill('DifferentPass456!@#'); await page.locator('#register-password_confirm').blur(); const mismatchError = page.locator('#register-password_confirm-error').first(); await expect(mismatchError).toBeVisible({ timeout: 5_000 }); const errorText = ((await mismatchError.textContent()) || '').toLowerCase(); expect(errorText).toMatch(/match|correspondent|pas identique|do not match|different/i); }); test('13. Username is required, min 3 chars', async ({ page }) => { await gotoRegister(page); const usernameInput = page.locator('#register-username'); // Empty await usernameInput.click(); await usernameInput.blur(); await expect(page.locator('#register-username-error').first()).toBeVisible({ timeout: 5_000 }); let text = ((await page.locator('#register-username-error').first().textContent()) || '').toLowerCase(); expect(text).toMatch(/required|requis|obligatoire/i); // Too short await usernameInput.fill('ab'); await usernameInput.blur(); await expect(page.locator('#register-username-error').first()).toBeVisible({ timeout: 5_000 }); text = ((await page.locator('#register-username-error').first().textContent()) || '').toLowerCase(); expect(text).toMatch(/3|short|court|minimum|au moins|at least/i); }); test('14. Username already taken shows "unavailable" status', async ({ page }) => { test.setTimeout(45_000); await gotoRegister(page); // Seed user: 'music_fan' exists await page.locator('#register-username').fill(CONFIG.users.listener.username); // Wait for the debounced availability check (~500ms debounce + API call) await page.waitForTimeout(2_000); // There should be either: // - a status indicating "unavailable" (p with role=alert, see RegisterPageForm.tsx) // - a username field error const unavailableMsg = page .locator('[role="alert"]') .filter({ hasText: /unavailable|indisponible|taken|déjà pris|already/i }) .first(); const isUnavailable = await unavailableMsg.isVisible({ timeout: 5_000 }).catch(() => false); if (!isUnavailable) { // Fallback: the availability API may not be implemented yet // At minimum, the endpoint must not return "available: true" for a seeded username const response = await page.request.get( `${CONFIG.apiURL}/api/v1/auth/check-username?username=${CONFIG.users.listener.username}`, { failOnStatusCode: false }, ); if (response.ok()) { const json = await response.json(); expect( json.available, `Seeded username '${CONFIG.users.listener.username}' should not report as available`, ).toBeFalsy(); } else { test.skip(true, 'check-username endpoint not available — cannot assert taken status'); } } else { expect(isUnavailable).toBeTruthy(); } }); test('15. Email already taken shows server error', async ({ page }) => { test.setTimeout(45_000); await gotoRegister(page); const acct = uniqueAccount('dup-email'); await page.locator('#register-username').fill(acct.username); // Use the seeded listener email await page.locator('#register-email').fill(CONFIG.users.listener.email); await page.locator('#register-password').fill(acct.password); await page.locator('#register-password_confirm').fill(acct.password); await page.locator('#register-terms').click({ force: true }); await page.waitForTimeout(300); await page.locator(REGISTER_SUBMIT).click(); const errorIndicator = page .getByRole('alert') .filter({ hasText: /exist|déjà|taken|already|utilisé|in use|utilisé/i }); await expect(errorIndicator.first()).toBeVisible({ timeout: 15_000 }); }); test('16. Terms checkbox is required to submit', async ({ page }) => { await gotoRegister(page); const registerCalls: string[] = []; page.on('request', (req) => { if (req.url().includes('/auth/register') && req.method() === 'POST') { registerCalls.push(req.url()); } }); const acct = uniqueAccount('no-terms'); await page.locator('#register-username').fill(acct.username); await page.locator('#register-email').fill(acct.email); await page.locator('#register-password').fill(acct.password); await page.locator('#register-password_confirm').fill(acct.password); // Deliberately DO NOT tick the terms checkbox await page.locator(REGISTER_SUBMIT).click(); await page.waitForTimeout(1_000); // Should see a terms-related error const termsError = page .locator('#terms-error') .or(page.getByRole('alert').filter({ hasText: /terms|conditions|cgu|accept/i })); await expect(termsError.first()).toBeVisible({ timeout: 5_000 }); // No API call expect(registerCalls.length).toBe(0); }); test('17. Valid submission triggers register and shows verification notice', async ({ page }) => { test.setTimeout(45_000); await gotoRegister(page); const acct = uniqueAccount('success'); await page.locator('#register-username').fill(acct.username); await page.locator('#register-email').fill(acct.email); await page.locator('#register-password').fill(acct.password); await page.locator('#register-password_confirm').fill(acct.password); await page.locator('#register-terms').click({ force: true }); await page.waitForTimeout(300); await page.locator(REGISTER_SUBMIT).click(); // Either: // a) Verification notice rendered (RegisterPageVerificationNotice) // b) Direct redirect to /dashboard // c) Error if username already happens to exist (very unlikely with random suffix) const verificationNotice = page .getByText(/verification|vérification|check your email|vérifiez.*email|email envoyé/i) .first(); const dashboardLoaded = page.locator('[data-testid="app-sidebar"]').first(); await Promise.race([ verificationNotice.waitFor({ state: 'visible', timeout: 20_000 }), dashboardLoaded.waitFor({ state: 'visible', timeout: 20_000 }), ]); // One of the two success states must be visible const hasNotice = await verificationNotice.isVisible().catch(() => false); const hasDashboard = await dashboardLoaded.isVisible().catch(() => false); expect(hasNotice || hasDashboard).toBeTruthy(); }); test('18. Submit button disables during request (no double-submit)', async ({ page }) => { test.setTimeout(45_000); await gotoRegister(page); const registerCalls: string[] = []; page.on('request', (req) => { if (req.url().includes('/auth/register') && req.method() === 'POST') { registerCalls.push(req.url()); } }); const acct = uniqueAccount('nodouble'); await page.locator('#register-username').fill(acct.username); await page.locator('#register-email').fill(acct.email); await page.locator('#register-password').fill(acct.password); await page.locator('#register-password_confirm').fill(acct.password); await page.locator('#register-terms').click({ force: true }); await page.waitForTimeout(300); const submitBtn = page.locator(REGISTER_SUBMIT); // First click await submitBtn.click(); // Immediately attempt a second click before the request resolves await submitBtn.click({ force: true }).catch(() => { // Button may be disabled — click fails, which is the desired behaviour }); // Wait for navigation or response to settle await page.waitForTimeout(4_000); // Must have at most ONE register POST expect(registerCalls.length).toBeLessThanOrEqual(1); }); }); // ============================================================================= // 3. PASSWORD SHOW/HIDE (3 tests) // ============================================================================= test.describe('AUTH-DEEP — Password show/hide toggle', () => { test('19. Toggle reveals password on login page', async ({ page }) => { await gotoLogin(page); const passwordInput = page.locator('input[type="password"]').first(); await passwordInput.fill('MySecret123!'); expect(await passwordInput.getAttribute('type')).toBe('password'); // Toggle button lives right after the password input, with aria-label "Show password"/"Afficher" const toggleBtn = page .locator('button[aria-label*="password" i], button[aria-label*="mot de passe" i]') .first(); await expect(toggleBtn).toBeVisible({ timeout: 5_000 }); await toggleBtn.click(); // After click, the input type should become "text" const textInput = page.locator('input[value="MySecret123!"]').first(); await expect(textInput).toHaveAttribute('type', 'text', { timeout: 3_000 }); }); test('20. Toggle hides password again on second click', async ({ page }) => { await gotoLogin(page); const passwordInput = page.locator('input[type="password"]').first(); await passwordInput.fill('HiddenAgain456!'); const toggleBtn = page .locator('button[aria-label*="password" i], button[aria-label*="mot de passe" i]') .first(); // Reveal await toggleBtn.click(); await expect(page.locator('input[value="HiddenAgain456!"]').first()).toHaveAttribute('type', 'text', { timeout: 3_000, }); // Hide again await toggleBtn.click(); // After hiding, there should again be an input[type="password"] with this value // (Playwright cannot directly query password fields by value, so check by name/id) const hidden = await page.evaluate(() => { const inputs = Array.from(document.querySelectorAll('input')); const match = inputs.find((i) => i.value === 'HiddenAgain456!'); return match ? match.type : null; }); expect(hidden).toBe('password'); }); test('21. Toggle works independently on both password fields in register form', async ({ page }) => { await gotoRegister(page); const password = page.locator('#register-password'); const confirm = page.locator('#register-password_confirm'); await password.fill('PwdOne111!@#'); await confirm.fill('PwdTwo222!@#'); // Both initially hidden expect(await password.getAttribute('type')).toBe('password'); expect(await confirm.getAttribute('type')).toBe('password'); // Find the two toggle buttons (one per password field, scoped to each input's parent) const passwordToggle = page .locator('#register-password') .locator('..') .locator('button[aria-label*="password" i], button[aria-label*="mot de passe" i]') .first(); const confirmToggle = page .locator('#register-password_confirm') .locator('..') .locator('button[aria-label*="password" i], button[aria-label*="mot de passe" i]') .first(); await expect(passwordToggle).toBeVisible({ timeout: 5_000 }); await expect(confirmToggle).toBeVisible({ timeout: 5_000 }); // Reveal only the first await passwordToggle.click(); await expect(page.locator('#register-password')).toHaveAttribute('type', 'text', { timeout: 3_000 }); // The confirm field must REMAIN hidden await expect(page.locator('#register-password_confirm')).toHaveAttribute('type', 'password'); // Reveal the second — both should now be visible await confirmToggle.click(); await expect(page.locator('#register-password_confirm')).toHaveAttribute('type', 'text', { timeout: 3_000 }); await expect(page.locator('#register-password')).toHaveAttribute('type', 'text'); }); }); // ============================================================================= // 4. SESSION MANAGEMENT (6 tests) // ============================================================================= test.describe('AUTH-DEEP — Session management', () => { test('22. Session persists across page reload', async ({ page }) => { test.setTimeout(60_000); await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); // Reload await page.reload({ waitUntil: 'domcontentloaded' }); await page.locator('main, [role="main"]').first().waitFor({ state: 'visible', timeout: 15_000 }); // Must NOT be redirected to /login expect(page.url()).not.toContain('/login'); // auth-storage should still mark the user as authenticated const isAuthed = await page.evaluate(() => { const raw = localStorage.getItem('auth-storage'); if (!raw) return false; try { return JSON.parse(raw)?.state?.isAuthenticated === true; } catch { return false; } }); expect(isAuthed).toBeTruthy(); }); test('23. Session expires when tokens are invalidated (401 from API)', async ({ page }) => { test.setTimeout(60_000); await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); // Make every protected API call return 401 to simulate expired tokens await page.route('**/api/v1/**', async (route) => { const url = route.request().url(); if (url.includes('/auth/login') || url.includes('/auth/register')) { await route.continue(); return; } await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'TOKEN_EXPIRED', message: 'Token expired' }, }), }); }); await page.goto(`${CONFIG.baseURL}/dashboard`, { waitUntil: 'domcontentloaded' }); // Either: client redirects to /login, OR still on /dashboard but in an unauth state. // We accept either — but it MUST NOT crash and MUST NOT silently keep "authenticated" claims. await page.waitForTimeout(5_000); const isAuthed = await page.evaluate(() => { const raw = localStorage.getItem('auth-storage'); if (!raw) return false; try { return JSON.parse(raw)?.state?.isAuthenticated === true; } catch { return false; } }); const onLogin = page.url().includes('/login'); // At least one side effect of expiry must have occurred expect(onLogin || !isAuthed).toBeTruthy(); }); test('24. Logout clears session and redirects to /login', async ({ page }) => { test.setTimeout(60_000); await loginViaUI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); // Trigger logout via user menu const userMenu = page.getByTestId('user-menu'); await expect(userMenu).toBeVisible({ timeout: 5_000 }); await userMenu.click(); await page.waitForTimeout(600); const signOutBtn = page .locator('button.text-destructive') .first() .or(page.locator('button').filter({ hasText: /sign out|déconnexion|logout|log out/i }).first()); await expect(signOutBtn).toBeVisible({ timeout: 5_000 }); await signOutBtn.click(); // Must redirect to /login await expect(page).toHaveURL(/\/login/, { timeout: 20_000 }); // Auth state must be cleared const isAuthed = await page.evaluate(() => { const raw = localStorage.getItem('auth-storage'); if (!raw) return false; try { return JSON.parse(raw)?.state?.isAuthenticated === true; } catch { return false; } }); expect(isAuthed).toBeFalsy(); }); test('25. Logout invalidates the auth cookie server-side', async ({ page }) => { test.setTimeout(60_000); await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); // Call logout via API directly const logoutResponse = await page.request.post(`${CONFIG.apiURL}/api/v1/auth/logout`, { failOnStatusCode: false, }); // Logout endpoint should not error (200 or 204 expected) expect(logoutResponse.status()).toBeLessThan(400); // After logout, /users/me should now fail (401) const meResponse = await page.request.get(`${CONFIG.apiURL}/api/v1/users/me`, { failOnStatusCode: false, }); expect(meResponse.status(), 'Protected endpoint should return 401 after logout').toBeGreaterThanOrEqual(401); expect(meResponse.status()).toBeLessThan(500); }); test('26. Concurrent tabs share authentication via broadcast sync', async ({ browser }) => { test.setTimeout(60_000); const context = await browser.newContext(); const tab1 = await context.newPage(); const tab2 = await context.newPage(); // Login in tab1 await loginViaAPI(tab1, CONFIG.users.listener.email, CONFIG.users.listener.password); // Tab2 navigates to a protected route — it should inherit the auth via shared cookies await tab2.goto(`${CONFIG.baseURL}/dashboard`, { waitUntil: 'domcontentloaded' }); // Give the app time to hydrate and resolve auth state await tab2.waitForTimeout(3_000); // Tab2 must NOT be redirected to /login (cookies are shared across same-context tabs) expect(tab2.url()).not.toContain('/login'); await context.close(); }); test('27. Unauthenticated access to protected route redirects to /login', async ({ page }) => { test.setTimeout(45_000); // Warm up the app so navigateTo can find the
element quickly on first hop await navigateTo(page, '/login'); await clearAuth(page); const protectedPaths = ['/dashboard', '/library', '/settings/sessions']; for (const path of protectedPaths) { await page.goto(`${CONFIG.baseURL}${path}`, { waitUntil: 'domcontentloaded' }); await page.waitForURL(/\/login/, { timeout: 15_000 }); expect(page.url(), `Accessing ${path} must redirect to /login when unauthenticated`).toMatch(/\/login/); // Clear again between iterations, just in case await clearAuth(page); } }); }); // ============================================================================= // 5. FORGOT PASSWORD FLOW (4 tests) // ============================================================================= test.describe('AUTH-DEEP — Forgot password flow', () => { test('28. Forgot password page is accessible from /login', async ({ page }) => { await gotoLogin(page); const forgotLink = page .getByRole('link', { name: /forgot password|mot de passe oublié|forgot|oublié/i }) .or(page.locator('a[href="/forgot-password"]')); await expect(forgotLink.first()).toBeVisible({ timeout: 5_000 }); await forgotLink.first().click(); await expect(page).toHaveURL(/\/forgot-password/, { timeout: 10_000 }); await expect(page.locator(FORGOT_FORM)).toBeVisible({ timeout: 10_000 }); }); test('29. Submitting a valid email shows confirmation', async ({ page }) => { test.setTimeout(45_000); await gotoForgotPassword(page); // Use a random email — backend should respond the same way regardless (anti-enumeration) const email = `reset-${Date.now()}@test.veza`; await page.locator('input[type="email"]').fill(email); await page.locator(FORGOT_SUBMIT).click(); // Success panel renders: look for the success status role OR confirmation text const successPanel = page .locator('[role="status"]') .or(page.getByText(/check your email|vérifiez votre|email envoyé|email sent|if an account/i)) .first(); await expect(successPanel).toBeVisible({ timeout: 15_000 }); }); test('30. Invalid email format is rejected client-side', async ({ page }) => { await gotoForgotPassword(page); const requests: string[] = []; page.on('request', (req) => { if (req.url().includes('/auth/password/reset-request') && req.method() === 'POST') { requests.push(req.url()); } }); await page.locator('input[type="email"]').fill('not-an-email'); await page.locator('input[type="email"]').blur(); await page.locator(FORGOT_SUBMIT).click(); await page.waitForTimeout(1_000); // Error must be shown const errorMsg = page .getByRole('alert') .or(page.locator('[id$="-error"]')) .filter({ hasText: /invalid|invalide|format/i }); await expect(errorMsg.first()).toBeVisible({ timeout: 5_000 }); // No API request should have been sent expect(requests.length).toBe(0); }); test('31. Rate limiting protects the password reset endpoint', async ({ page }) => { test.setTimeout(60_000); await gotoForgotPassword(page); const email = `ratelimit-${Date.now()}@test.veza`; const statuses: number[] = []; // Fire many requests rapidly directly via the API for (let i = 0; i < 12; i++) { const res = await page.request.post(`${CONFIG.apiURL}/api/v1/auth/password/reset-request`, { data: { email }, failOnStatusCode: false, }); statuses.push(res.status()); } // Every response must be graceful (no 5xx crashes) expect(statuses.every((s) => s < 500)).toBeTruthy(); // We expect either: // - all 200/204 (endpoint accepts anti-enumeration but should still cap somewhere) // - some 429 responses once the rate limit kicks in // We assert that the server doesn't crash under load, which is the critical invariant. const has429 = statuses.includes(429); const allSuccess = statuses.every((s) => s === 200 || s === 202 || s === 204); // At least one of these MUST be true — otherwise we have an unexpected status mix expect(has429 || allSuccess).toBeTruthy(); }); }); // ============================================================================= // 6. SECURITY (6 tests) // ============================================================================= test.describe('AUTH-DEEP — Security', () => { test('32. Login page has correct security posture (form method, no credentials in URL)', async ({ page }) => { await gotoLogin(page); // The login form must use POST (or ajax submit), never GET const form = page.locator(LOGIN_FORM); const method = (await form.getAttribute('method')) || ''; // React apps typically submit via JS (method empty), which prevents plaintext credentials in URL expect(method.toLowerCase() === '' || method.toLowerCase() === 'post').toBeTruthy(); // Password input must be type="password" (not text) const pwd = page.locator('input[type="password"]').first(); await expect(pwd).toBeVisible({ timeout: 5_000 }); }); test('33. Credentials are never visible in URL after login', async ({ page }) => { test.setTimeout(45_000); await gotoLogin(page); const urlLog: string[] = []; page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) { urlLog.push(frame.url()); } }); const email = CONFIG.users.listener.email; const password = CONFIG.users.listener.password; await page.locator('input[type="email"]').fill(email); await page.locator('input[type="password"]').first().fill(password); await page.getByTestId('login-submit').click(); await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 20_000 }); // None of the visited URLs should contain the password for (const url of urlLog) { expect(url, `URL ${url} must not contain password`).not.toContain(password); expect(url, `URL ${url} must not contain encoded password`).not.toContain(encodeURIComponent(password)); } // Nor the current URL expect(page.url()).not.toContain(password); expect(page.url()).not.toContain(encodeURIComponent(password)); }); test('34. XSS payload in email field does not execute', async ({ page }) => { await gotoLogin(page); const dialogCalls: string[] = []; page.on('dialog', (dialog) => { dialogCalls.push(dialog.message()); dialog.dismiss().catch(() => {}); }); const xssPayload = ''; await page.locator('input[type="email"]').fill(xssPayload); await page.locator('input[type="password"]').first().fill('AnyPassword123!'); await page.getByTestId('login-submit').click(); await page.waitForTimeout(3_000); // No dialog should have been triggered expect(dialogCalls.length).toBe(0); // The payload must NOT have been rendered as HTML anywhere on the page const imageInDOM = await page.locator('img[src="x"]').count(); expect(imageInDOM).toBe(0); // The error/form should treat the input as plain text const bodyText = (await page.textContent('body')) || ''; // The literal " { await gotoLogin(page); // Issue a login request from an unrelated origin (simulated by missing Origin/Referer) // via direct request with mismatched origin headers. const response = await page.request.post(`${CONFIG.apiURL}/api/v1/auth/login`, { data: { email: 'probe@test.veza', password: 'Probe123!@#', remember_me: false }, headers: { Origin: 'https://evil.example.com', Referer: 'https://evil.example.com/attack', }, failOnStatusCode: false, }); const status = response.status(); // The endpoint must either: // - Reject the cross-origin request (403 CSRF, 400 bad origin) // - Validate credentials normally (401 if creds bad, 200 if somehow valid) // What it MUST NOT do: return 500 (crash) or unconditionally succeed (200) with bogus creds. expect(status).not.toBe(500); // With non-existent credentials, the response MUST NOT be 200 expect(status).not.toBe(200); // Must be a 4xx expect(status).toBeGreaterThanOrEqual(400); expect(status).toBeLessThan(500); }); test('37. Session/auth cookies are httpOnly (not accessible from JS)', async ({ page }) => { test.setTimeout(45_000); await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); // Retrieve all cookies from the browser context const cookies = await page.context().cookies(); // Find auth-related cookies (names may vary: session, token, jwt, veza_auth, access_token...) const authCookies = cookies.filter((c) => /session|token|auth|jwt|refresh|access/i.test(c.name), ); expect(authCookies.length, `Expected auth cookies but found: ${cookies.map((c) => c.name).join(', ')}`).toBeGreaterThan(0); // Every auth cookie MUST be httpOnly for (const cookie of authCookies) { expect( cookie.httpOnly, `Cookie "${cookie.name}" must be httpOnly to prevent XSS theft`, ).toBeTruthy(); } // Verify via JS that the cookie values are NOT accessible through document.cookie const jsVisibleCookies = await page.evaluate(() => document.cookie); for (const cookie of authCookies) { expect( jsVisibleCookies.includes(cookie.name + '='), `Cookie "${cookie.name}" leaked to document.cookie — must be httpOnly`, ).toBeFalsy(); } }); });