import { test, expect } from '@playwright/test'; import { execSync } from 'child_process'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Test credentials - must match what's created by the command const _TEST_EMAIL = 'e2e-login@example.com'; // Base email pattern used by getTestEmail() const TEST_PASSWORD = 'TestPassword123'; const WRONG_PASSWORD = 'WrongPassword123'; // eslint-disable-next-line no-empty-pattern test.beforeAll(async ({ }, testInfo) => { const browserName = testInfo.project.name; // Create a test user for login tests try { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); // Create a unique email for this browser project to avoid conflicts const email = `e2e-login-${browserName}@example.com`; const result = execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --email=${email} --password=${TEST_PASSWORD} 2>&1`, { encoding: 'utf-8' } ); console.warn(`[${browserName}] Test user created or exists:`, result.includes('already exists') ? 'exists' : 'created'); } catch (error) { console.error(`[${browserName}] Failed to create test user:`, error); } }); function getTestEmail(browserName: string): string { return `e2e-login-${browserName}@example.com`; } test.describe('Login Flow', () => { test.describe('Successful Login', () => { test('logs in successfully and redirects to dashboard', async ({ page }, testInfo) => { const email = getTestEmail(testInfo.project.name); await page.goto('/login'); // Verify we're on the login page await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible(); // Fill in credentials await page.locator('#email').fill(email); await page.locator('#password').fill(TEST_PASSWORD); // Submit button should be enabled const submitButton = page.getByRole('button', { name: /se connecter/i }); await expect(submitButton).toBeEnabled(); // Submit and wait for navigation to dashboard await Promise.all([ page.waitForURL('/dashboard', { timeout: 10000 }), submitButton.click() ]); // We should be on the dashboard await expect(page).toHaveURL('/dashboard'); }); }); test.describe('Failed Login', () => { test('shows error message for invalid credentials', async ({ page }, testInfo) => { const email = getTestEmail(testInfo.project.name); await page.goto('/login'); // Fill in wrong credentials await page.locator('#email').fill(email); await page.locator('#password').fill(WRONG_PASSWORD); // Submit const submitButton = page.getByRole('button', { name: /se connecter/i }); await submitButton.click(); // Wait for error message const errorBanner = page.locator('.error-banner'); await expect(errorBanner).toBeVisible({ timeout: 5000 }); // Error should be generic (not reveal if email exists) await expect(errorBanner).toContainText(/email ou .* mot de passe .* incorrect/i); }); test('shows error for non-existent user', async ({ page }) => { await page.goto('/login'); // Use a random email that doesn't exist await page.locator('#email').fill(`nonexistent-${Date.now()}@example.com`); await page.locator('#password').fill('SomePassword123'); // Submit const submitButton = page.getByRole('button', { name: /se connecter/i }); await submitButton.click(); // Wait for error message const errorBanner = page.locator('.error-banner'); await expect(errorBanner).toBeVisible({ timeout: 5000 }); // Error should be the same generic message (security: don't reveal if email exists) await expect(errorBanner).toContainText(/email ou .* mot de passe .* incorrect/i); }); }); test.describe('Form Validation', () => { test('submit button is disabled until both fields are filled', async ({ page }) => { await page.goto('/login'); const submitButton = page.getByRole('button', { name: /se connecter/i }); // Initially disabled (no fields filled) await expect(submitButton).toBeDisabled(); // Fill only email await page.locator('#email').fill('test@example.com'); await expect(submitButton).toBeDisabled(); // Clear email, fill only password await page.locator('#email').fill(''); await page.locator('#password').fill('password123'); await expect(submitButton).toBeDisabled(); // Fill both await page.locator('#email').fill('test@example.com'); await expect(submitButton).toBeEnabled(); }); }); test.describe('Rate Limiting - Fibonacci Delay', () => { // Skip rate limiting tests in CI - they require the real rate limiter which is // replaced by NullLoginRateLimiter in test environment to avoid IP blocking test.skip(!!process.env.CI, 'Rate limiting tests require real rate limiter (skipped in CI)'); // Configure to run serially to avoid race conditions with rate limiter test.describe.configure({ mode: 'serial' }); // Only run on chromium - rate limiter is shared across browsers running in parallel test.skip(({ browserName }) => browserName !== 'chromium', 'Rate limiting tests only run on chromium'); test('shows progressive delay after failed attempts', async ({ page }, testInfo) => { const browserName = testInfo.project.name; // Use a unique email to avoid affecting other tests const rateLimitEmail = `rate-limit-${browserName}-${Date.now()}@example.com`; await page.goto('/login'); // First attempt - no delay expected await page.locator('#email').fill(rateLimitEmail); await page.locator('#password').fill('WrongPassword'); await page.getByRole('button', { name: /se connecter/i }).click(); // Wait for error await expect(page.locator('.error-banner')).toBeVisible({ timeout: 5000 }); // Wait for form fields to be re-enabled (delay countdown may disable them) await expect(page.locator('#password')).toBeEnabled({ timeout: 10000 }); // Second attempt - should have 1 second delay await page.locator('#password').fill('WrongPassword2'); await page.getByRole('button', { name: /se connecter/i }).click(); // After second failed attempt, button should show delay countdown const _submitButton = page.getByRole('button', { name: /patientez|se connecter/i }); // Wait for response - the button should briefly show "Patientez Xs..." await page.waitForTimeout(500); // Check that error message is displayed await expect(page.locator('.error-banner')).toBeVisible(); }); test('delays increase with Fibonacci sequence', async ({ page }, testInfo) => { const browserName = testInfo.project.name; const rateLimitEmail = `fibo-${browserName}-${Date.now()}@example.com`; await page.goto('/login'); // Make 4 failed attempts to see increasing delays // Fibonacci: attempt 2 = 1s, attempt 3 = 1s, attempt 4 = 2s, attempt 5 = 3s for (let i = 0; i < 4; i++) { // Wait for form fields to be enabled before filling await expect(page.locator('#email')).toBeEnabled({ timeout: 15000 }); await page.locator('#email').fill(rateLimitEmail); await page.locator('#password').fill(`WrongPassword${i}`); const submitButton = page.getByRole('button', { name: /se connecter|patientez/i }); // Wait for button to be enabled if there's a delay await expect(submitButton).toBeEnabled({ timeout: 10000 }); await submitButton.click(); // Wait for response await page.waitForTimeout(300); } // After 4 attempts, should see error await expect(page.locator('.error-banner')).toBeVisible(); }); }); test.describe('CAPTCHA after failed attempts', () => { // Skip CAPTCHA tests in CI - they require the real rate limiter which is // replaced by NullLoginRateLimiter in test environment to avoid IP blocking test.skip(!!process.env.CI, 'CAPTCHA tests require real rate limiter (skipped in CI)'); // Configure to run serially to avoid race conditions with rate limiter test.describe.configure({ mode: 'serial' }); // Only run on chromium - rate limiter is shared across browsers running in parallel test.skip(({ browserName }) => browserName !== 'chromium', 'CAPTCHA tests only run on chromium'); test('shows CAPTCHA after 5 failed login attempts', async ({ page }, testInfo) => { const browserName = testInfo.project.name; const captchaEmail = `captcha-${browserName}-${Date.now()}@example.com`; await page.goto('/login'); // Make 5 failed attempts to trigger CAPTCHA requirement for (let i = 0; i < 5; i++) { // Wait for form fields to be enabled before filling await expect(page.locator('#email')).toBeEnabled({ timeout: 15000 }); await page.locator('#email').fill(captchaEmail); await page.locator('#password').fill(`WrongPassword${i}`); const submitButton = page.getByRole('button', { name: /se connecter|patientez/i }); // Wait for button to be enabled await expect(submitButton).toBeEnabled({ timeout: 15000 }); await submitButton.click(); // Wait for response await page.waitForTimeout(500); } // After 5 failed attempts, CAPTCHA should appear const captchaSection = page.locator('.captcha-section'); await expect(captchaSection).toBeVisible({ timeout: 10000 }); // Should see the security verification label await expect(page.getByText(/vérification de sécurité/i)).toBeVisible(); // Turnstile container should be present await expect(page.locator('.turnstile-container')).toBeVisible(); }); // TODO: Revisit this test - the button may intentionally stay enabled // with server-side CAPTCHA validation instead of client-side disabling test.skip('submit button disabled when CAPTCHA required but not completed', async ({ page }, testInfo) => { const browserName = testInfo.project.name; const captchaEmail = `captcha-btn-${browserName}-${Date.now()}@example.com`; await page.goto('/login'); // Make 5 failed attempts for (let i = 0; i < 5; i++) { // Wait for form fields to be enabled before filling await expect(page.locator('#email')).toBeEnabled({ timeout: 15000 }); await page.locator('#email').fill(captchaEmail); await page.locator('#password').fill(`WrongPassword${i}`); const submitButton = page.getByRole('button', { name: /se connecter|patientez/i }); await expect(submitButton).toBeEnabled({ timeout: 15000 }); await submitButton.click(); await page.waitForTimeout(500); } // Wait for CAPTCHA to appear await expect(page.locator('.captcha-section')).toBeVisible({ timeout: 10000 }); // Wait for any delay to expire await expect(page.getByRole('button', { name: /se connecter/i })).toBeVisible({ timeout: 15000 }); // Submit button should be disabled because CAPTCHA is not completed const submitButton = page.getByRole('button', { name: /se connecter/i }); await expect(submitButton).toBeDisabled(); }); }); test.describe('Success Message After Activation', () => { test('shows success message when redirected after activation', async ({ page }) => { await page.goto('/login?activated=true'); await expect(page.getByText(/compte a été activé avec succès/i)).toBeVisible(); await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible(); }); test('does not show success message without query param', async ({ page }) => { await page.goto('/login'); await expect(page.getByText(/compte a été activé avec succès/i)).not.toBeVisible(); await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible(); }); }); test.describe('Tenant Isolation', () => { // Extract port from PLAYWRIGHT_BASE_URL or use default const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; const urlMatch = baseUrl.match(/:(\d+)$/); const PORT = urlMatch ? urlMatch[1] : '5174'; const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; const BETA_URL = `http://ecole-beta.classeo.local:${PORT}`; const ALPHA_EMAIL = 'tenant-test-alpha@example.com'; const BETA_EMAIL = 'tenant-test-beta@example.com'; const PASSWORD = 'TenantTest123'; // Create test users on different tenants before running these tests test.beforeAll(async () => { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); try { // Create user on ecole-alpha execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ALPHA_EMAIL} --password=${PASSWORD} 2>&1`, { encoding: 'utf-8' } ); // Create user on ecole-beta execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-beta --email=${BETA_EMAIL} --password=${PASSWORD} 2>&1`, { encoding: 'utf-8' } ); // eslint-disable-next-line no-console console.log('Tenant isolation test users created'); } catch (error) { console.error('Failed to create tenant test users:', error); } }); test('user can login on their own tenant', async ({ page }) => { // Alpha user on Alpha tenant - should succeed await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ALPHA_EMAIL); await page.locator('#password').fill(PASSWORD); const submitButton = page.getByRole('button', { name: /se connecter/i }); await submitButton.click(); // Should redirect to dashboard (successful login) await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); }); test('user cannot login on different tenant', async ({ page }) => { // Alpha user on Beta tenant - should fail await page.goto(`${BETA_URL}/login`); await page.locator('#email').fill(ALPHA_EMAIL); await page.locator('#password').fill(PASSWORD); const submitButton = page.getByRole('button', { name: /se connecter/i }); await submitButton.click(); // Should show error (user doesn't exist in this tenant) const errorBanner = page.locator('.error-banner'); await expect(errorBanner).toBeVisible({ timeout: 5000 }); await expect(errorBanner).toContainText(/email ou .* mot de passe .* incorrect/i); // Should still be on login page await expect(page).toHaveURL(/\/login/); }); test('each tenant has isolated users', async ({ page }) => { // Beta user on Beta tenant - should succeed await page.goto(`${BETA_URL}/login`); await page.locator('#email').fill(BETA_EMAIL); await page.locator('#password').fill(PASSWORD); const submitButton = page.getByRole('button', { name: /se connecter/i }); await submitButton.click(); // Should redirect to dashboard (successful login) await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); }); }); });