veza/tests/e2e/44-auth-deep.spec.ts
senke 320e526428 feat(e2e): add 303 deep behavioral tests + fix WebSocket + lint-staged
9 deep E2E test files (303 tests total):
41-chat(33) 42-player(31) 43-upload(28) 44-auth(37) 45-playlists(35)
46-search(32) 47-social(30) 48-marketplace(30) 49-settings(37)

Fix WebSocket origin bug (Chat never worked):
GetAllowedWebSocketOrigins() excluded localhost/127.0.0.1 in dev.

Fix lint-staged gofmt: pass files as args not stdin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:35:26 +02:00

1083 lines
43 KiB
TypeScript

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<void> {
await page.context().clearCookies();
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
}
/** Navigate to /login with a clean session. */
async function gotoLogin(page: Page): Promise<void> {
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<void> {
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<void> {
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<HTMLInputElement>('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 <main> 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 = '<img src=x onerror="alert(\'XSS\')">';
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 "<img" should not break out of the form — it should either be
// escaped or rejected as invalid email
expect(bodyText).not.toContain('onerror="alert');
});
test('35. SQL injection payload is handled safely', async ({ page }) => {
test.setTimeout(45_000);
await gotoLogin(page);
const sqlPayloads = [
"' OR '1'='1",
"admin'--",
"'; DROP TABLE users;--",
"' UNION SELECT NULL--",
];
for (const payload of sqlPayloads) {
// Clear fields between attempts
const emailInput = page.locator('input[type="email"]');
const passwordInput = page.locator('input[type="password"]').first();
await emailInput.clear();
await passwordInput.clear();
await emailInput.fill(`${payload}@test.veza`);
await passwordInput.fill(payload);
// Submit directly via API to see the raw response
const response = await page.request.post(`${CONFIG.apiURL}/api/v1/auth/login`, {
data: {
email: `${payload}@test.veza`,
password: payload,
remember_me: false,
},
failOnStatusCode: false,
});
const status = response.status();
// Must NEVER return 200 (would mean injection succeeded)
expect(status, `SQL injection with payload "${payload}" must not succeed`).not.toBe(200);
// Must NEVER return 500 (would mean DB crash — information leak)
expect(status, `SQL injection with payload "${payload}" crashed the server`).not.toBe(500);
// Should return 400 or 401
expect(status).toBeGreaterThanOrEqual(400);
expect(status).toBeLessThan(500);
}
});
test('36. CSRF protection is active on login endpoint', async ({ page }) => {
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();
}
});
});