Files
Classeo/frontend/e2e/activation.spec.ts
Mathias STRASSER affad287f9 feat: Réinitialisation de mot de passe avec tokens sécurisés
Implémentation complète du flux de réinitialisation de mot de passe (Story 1.5):

Backend:
- Aggregate PasswordResetToken avec TTL 1h, UUID v7, usage unique
- Endpoint POST /api/password/forgot avec rate limiting (3/h par email, 10/h par IP)
- Endpoint POST /api/password/reset avec validation token
- Templates email (demande + confirmation)
- Repository Redis avec TTL 2h pour distinguer expiré/invalide

Frontend:
- Page /mot-de-passe-oublie avec message générique (anti-énumération)
- Page /reset-password/[token] avec validation temps réel des critères
- Gestion erreurs: token invalide, expiré, déjà utilisé

Tests:
- 14 tests unitaires PasswordResetToken
- 7 tests unitaires RequestPasswordResetHandler
- 7 tests unitaires ResetPasswordHandler
- Tests E2E Playwright pour le flux complet
2026-02-02 09:45:15 +01:00

248 lines
8.8 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);
// Each browser project gets its own token to avoid conflicts
let testToken: string | null = null;
// eslint-disable-next-line no-empty-pattern
test.beforeAll(async ({ }, testInfo) => {
const browserName = testInfo.project.name;
// Create a unique token for this browser project
try {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
const email = `e2e-${browserName}@example.com`;
const result = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-activation-token --email=${email} 2>&1`,
{ encoding: 'utf-8' }
);
const tokenMatch = result.match(/Token\s+([a-f0-9-]{36})/i);
if (tokenMatch) {
testToken = tokenMatch[1];
console.warn(`[${browserName}] Test token created: ${testToken}`);
} else {
console.error(`[${browserName}] Could not extract token from output:`, result);
}
} catch (error) {
console.error(`[${browserName}] Failed to create test token:`, error);
}
});
function getToken(): string {
if (!testToken) {
throw new Error('No test token available. Make sure Docker is running.');
}
return testToken;
}
test.describe('Account Activation Flow', () => {
test.describe('Token Validation', () => {
test('displays error for invalid token', async ({ page }) => {
await page.goto('/activate/invalid-token-uuid-format');
// Wait for the error state
await expect(page.getByRole('heading', { name: /lien invalide/i })).toBeVisible();
await expect(page.getByText(/contacter votre établissement/i)).toBeVisible();
});
test('displays error for non-existent token', async ({ page }) => {
// Use a valid UUID format but non-existent token
await page.goto('/activate/00000000-0000-0000-0000-000000000000');
// Shows error because token doesn't exist
const heading = page.getByRole('heading', { name: /lien invalide/i });
await expect(heading).toBeVisible();
});
});
test.describe('Password Form', () => {
test('validates password requirements in real-time', async ({ page }) => {
const token = getToken();
await page.goto(`/activate/${token}`);
// Wait for form to be visible (token must be valid)
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const passwordInput = page.locator('#password');
// Test minimum length requirement - should NOT be valid yet
await passwordInput.fill('Abc1');
const minLengthItem = page.locator('.password-requirements li').filter({ hasText: /8 caractères/ });
await expect(minLengthItem).not.toHaveClass(/valid/);
// Test uppercase requirement - missing
await passwordInput.fill('abcd1234');
const uppercaseItem = page.locator('.password-requirements li').filter({ hasText: /majuscule/ });
await expect(uppercaseItem).not.toHaveClass(/valid/);
// Test digit requirement - missing
await passwordInput.fill('Abcdefgh');
const digitItem = page.locator('.password-requirements li').filter({ hasText: /chiffre/ });
await expect(digitItem).not.toHaveClass(/valid/);
// Valid password (without special char) should show 4/5 checkmarks
// Requirements: minLength, uppercase, lowercase, digit, specialChar
// "Abcdefgh1" satisfies: minLength(9>=8), uppercase(A), lowercase(bcdefgh), digit(1)
await passwordInput.fill('Abcdefgh1');
const validItems = page.locator('.password-requirements li.valid');
await expect(validItems).toHaveCount(4);
});
test('requires password confirmation to match', async ({ page }) => {
const token = getToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const passwordInput = page.locator('#password');
const confirmInput = page.locator('#passwordConfirmation');
await passwordInput.fill('SecurePass123!');
await confirmInput.fill('DifferentPass123!');
await expect(page.getByText(/mots de passe ne correspondent pas/i)).toBeVisible();
// Fix confirmation
await confirmInput.fill('SecurePass123!');
await expect(page.getByText(/mots de passe ne correspondent pas/i)).not.toBeVisible();
});
test('submit button is disabled until form is valid', async ({ page }) => {
const token = getToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const submitButton = page.getByRole('button', { name: /activer mon compte/i });
// Initially disabled
await expect(submitButton).toBeDisabled();
// Fill valid password (must include special char)
await page.locator('#password').fill('SecurePass123!');
await page.locator('#passwordConfirmation').fill('SecurePass123!');
// Should now be enabled
await expect(submitButton).toBeEnabled();
});
});
test.describe('Establishment Info Display', () => {
test('shows establishment name and role when token is valid', async ({ page }) => {
const token = getToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
// School info should be visible
await expect(page.locator('.school-info')).toBeVisible();
await expect(page.locator('.school-name')).toBeVisible();
await expect(page.locator('.account-type')).toBeVisible();
});
});
test.describe('Password Visibility Toggle', () => {
test('toggles password visibility', async ({ page }) => {
const token = getToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const passwordInput = page.locator('#password');
const toggleButton = page.locator('.toggle-password');
// Initially password type
await expect(passwordInput).toHaveAttribute('type', 'password');
// Click toggle
await toggleButton.click();
await expect(passwordInput).toHaveAttribute('type', 'text');
// Click again to hide
await toggleButton.click();
await expect(passwordInput).toHaveAttribute('type', 'password');
});
});
test.describe('Full Activation Flow', () => {
// TODO: Investigate CI timeout issue - activation works locally but times out in CI
// The token is created successfully but the redirect to /login?activated=true doesn't happen
// This might be a race condition or timing issue specific to the CI environment
test.skip(!!process.env.CI, 'Activation flow times out in CI - needs investigation');
test('activates account and redirects to login', async ({ page }) => {
const token = getToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const submitButton = page.getByRole('button', { name: /activer mon compte/i });
// Button should be disabled initially (no password yet)
await expect(submitButton).toBeDisabled();
// Fill valid password (must include special char)
await page.locator('#password').fill('SecurePass123!');
await page.locator('#passwordConfirmation').fill('SecurePass123!');
// Wait for validation to complete - button should now be enabled
await expect(submitButton).toBeEnabled({ timeout: 2000 });
// Submit and wait for navigation
await Promise.all([
page.waitForURL(/\/login\?activated=true/, { timeout: 10000 }),
submitButton.click()
]);
// Verify success message
await expect(page.getByText(/compte a été activé avec succès/i)).toBeVisible();
});
});
});
test.describe('Login Page 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('Parental Consent Flow (Minor User)', () => {
// These tests would require seeded data for a minor user
test.skip('shows consent required message for minor without consent', async () => {
// Would navigate to activation page for a minor user token
// and verify the consent required message is displayed
});
test.skip('allows activation after parental consent is given', async () => {
// Would verify the full flow:
// 1. Minor receives activation link
// 2. Parent gives consent
// 3. Minor can then activate their account
});
});