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
248 lines
8.8 KiB
TypeScript
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
|
|
});
|
|
});
|