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.
390 lines
14 KiB
TypeScript
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 });
|
|
});
|
|
});
|
|
});
|