Files
Classeo/frontend/e2e/login.spec.ts
Mathias STRASSER b45ef735db feat: Dashboard placeholder avec preview Score Sérénité
Permet aux parents de visualiser une démo du Score Sérénité dès leur
première connexion, avant même que les données réelles soient disponibles.
Les autres rôles (enseignant, élève, admin) ont également leur dashboard
adapté avec des sections placeholder.

La landing page redirige automatiquement vers /dashboard si l'utilisateur
est déjà authentifié, offrant un accès direct au tableau de bord.
2026-02-04 18:34:08 +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: 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 });
});
});
});