Files
Classeo/frontend/e2e/login.spec.ts
Mathias STRASSER 76e16db0d8 feat: Pagination et recherche des sections admin
Les listes admin (utilisateurs, classes, matières, affectations) chargeaient
toutes les données d'un coup, ce qui dégradait l'expérience avec un volume
croissant. La pagination côté serveur existait dans la config API Platform
mais aucun Provider ne l'exploitait.

Cette implémentation ajoute la pagination serveur (30 items/page, max 100)
avec recherche textuelle sur toutes les sections, des composants frontend
réutilisables (Pagination + SearchInput avec debounce), et la synchronisation
URL pour le partage de liens filtrés.

Les Query valident leurs paramètres (clamp page/limit, trim search) pour
éviter les abus. Les affectations utilisent des lookup maps pour résoudre
les noms sans N+1 queries. Les pages admin gèrent les race conditions
via AbortController.
2026-02-15 13:54:51 +01:00

390 lines
14 KiB
TypeScript

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: 30000 }),
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 (4173 matches playwright.config.ts)
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
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: 30000 });
});
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: 30000 });
});
});
});