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
This commit is contained in:
@@ -90,10 +90,12 @@ test.describe('Account Activation Flow', () => {
|
||||
const digitItem = page.locator('.password-requirements li').filter({ hasText: /chiffre/ });
|
||||
await expect(digitItem).not.toHaveClass(/valid/);
|
||||
|
||||
// Valid password should show all checkmarks
|
||||
// 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(3);
|
||||
await expect(validItems).toHaveCount(4);
|
||||
});
|
||||
|
||||
test('requires password confirmation to match', async ({ page }) => {
|
||||
@@ -106,13 +108,13 @@ test.describe('Account Activation Flow', () => {
|
||||
const passwordInput = page.locator('#password');
|
||||
const confirmInput = page.locator('#passwordConfirmation');
|
||||
|
||||
await passwordInput.fill('SecurePass123');
|
||||
await confirmInput.fill('DifferentPass123');
|
||||
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 confirmInput.fill('SecurePass123!');
|
||||
await expect(page.getByText(/mots de passe ne correspondent pas/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
@@ -128,9 +130,9 @@ test.describe('Account Activation Flow', () => {
|
||||
// Initially disabled
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill valid password
|
||||
await page.locator('#password').fill('SecurePass123');
|
||||
await page.locator('#passwordConfirmation').fill('SecurePass123');
|
||||
// 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();
|
||||
@@ -194,9 +196,9 @@ test.describe('Account Activation Flow', () => {
|
||||
// Button should be disabled initially (no password yet)
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill valid password
|
||||
await page.locator('#password').fill('SecurePass123');
|
||||
await page.locator('#passwordConfirmation').fill('SecurePass123');
|
||||
// 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 });
|
||||
|
||||
271
frontend/e2e/password-reset.spec.ts
Normal file
271
frontend/e2e/password-reset.spec.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
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);
|
||||
|
||||
/**
|
||||
* Helper to create a password reset token via CLI command
|
||||
*/
|
||||
function createResetToken(options: { email: string; expired?: boolean }): string | null {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
try {
|
||||
const expiredFlag = options.expired ? ' --expired' : '';
|
||||
// Use APP_ENV=test to ensure Redis cache is used (same as the web server in CI)
|
||||
const result = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T -e APP_ENV=test php php bin/console app:dev:create-test-password-reset-token --email=${options.email}${expiredFlag} 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const tokenMatch = result.match(/Token\s+([a-f0-9-]{36})/i);
|
||||
if (tokenMatch) {
|
||||
return tokenMatch[1];
|
||||
}
|
||||
console.error('Could not extract token from output:', result);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to create reset token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Password Reset Flow', () => {
|
||||
test.describe('Forgot Password Page (/mot-de-passe-oublie)', () => {
|
||||
test('displays the forgot password form', async ({ page }) => {
|
||||
await page.goto('/mot-de-passe-oublie');
|
||||
|
||||
// Page should have the heading and form elements
|
||||
await expect(page.getByRole('heading', { name: /mot de passe oublié/i })).toBeVisible();
|
||||
await expect(page.locator('input#email')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /envoyer le lien de réinitialisation/i })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows success message after submitting email', async ({ page }) => {
|
||||
await page.goto('/mot-de-passe-oublie');
|
||||
|
||||
const emailInput = page.locator('input#email');
|
||||
await emailInput.fill('test-forgot@example.com');
|
||||
|
||||
await page.getByRole('button', { name: /envoyer le lien de réinitialisation/i }).click();
|
||||
|
||||
// Should show success message (always, to prevent enumeration)
|
||||
await expect(page.getByText(/vérifiez votre boîte mail/i)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('has link to login page', async ({ page }) => {
|
||||
await page.goto('/mot-de-passe-oublie');
|
||||
|
||||
const loginLink = page.getByRole('link', { name: /retour à la connexion/i });
|
||||
await expect(loginLink).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Reset Password Page (/reset-password/[token])', () => {
|
||||
test.describe('Password Form Display', () => {
|
||||
test('displays password form', async ({ page }, testInfo) => {
|
||||
// Create a fresh token for this test
|
||||
const email = `e2e-form-display-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email });
|
||||
test.skip(!token, 'Could not create test token');
|
||||
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
|
||||
// Form should be visible
|
||||
await expect(page.locator('form')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('#password')).toBeVisible();
|
||||
await expect(page.locator('#confirmPassword')).toBeVisible();
|
||||
});
|
||||
|
||||
test('validates password requirements in real-time', async ({ page }, testInfo) => {
|
||||
const email = `e2e-validation-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email });
|
||||
test.skip(!token, 'Could not create test token');
|
||||
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const passwordInput = page.locator('#password');
|
||||
|
||||
// Test short password - should not pass length requirement
|
||||
await passwordInput.fill('Abc1!');
|
||||
const minLengthItem = page
|
||||
.locator('.requirements li')
|
||||
.filter({ hasText: /8 caractères/ });
|
||||
await expect(minLengthItem).not.toHaveClass(/valid/);
|
||||
|
||||
// Test missing uppercase
|
||||
await passwordInput.fill('abcdefgh1!');
|
||||
const uppercaseItem = page
|
||||
.locator('.requirements li')
|
||||
.filter({ hasText: /majuscule/ });
|
||||
await expect(uppercaseItem).not.toHaveClass(/valid/);
|
||||
|
||||
// Valid password should show all checkmarks (5 requirements)
|
||||
await passwordInput.fill('Abcdefgh1!');
|
||||
const validItems = page.locator('.requirements li.valid');
|
||||
await expect(validItems).toHaveCount(5);
|
||||
});
|
||||
|
||||
test('validates password confirmation matches', async ({ page }, testInfo) => {
|
||||
const email = `e2e-confirm-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email });
|
||||
test.skip(!token, 'Could not create test token');
|
||||
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const passwordInput = page.locator('#password');
|
||||
const confirmInput = page.locator('#confirmPassword');
|
||||
|
||||
await passwordInput.fill('SecurePass123!');
|
||||
await confirmInput.fill('DifferentPass123!');
|
||||
|
||||
// Should show mismatch error
|
||||
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 }, testInfo) => {
|
||||
const email = `e2e-button-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email });
|
||||
test.skip(!token, 'Could not create test token');
|
||||
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /réinitialiser/i });
|
||||
|
||||
// Initially disabled
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill valid password
|
||||
await page.locator('#password').fill('NewSecurePass123!');
|
||||
await page.locator('#confirmPassword').fill('NewSecurePass123!');
|
||||
|
||||
// Should now be enabled
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Token Validation', () => {
|
||||
test('shows error for invalid token after form submission', async ({ page }) => {
|
||||
// Use an invalid token format
|
||||
await page.goto('/reset-password/00000000-0000-0000-0000-000000000000');
|
||||
|
||||
// Form should still be visible (validation happens on submit)
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Fill valid password and submit
|
||||
await page.locator('#password').fill('ValidPassword123!');
|
||||
await page.locator('#confirmPassword').fill('ValidPassword123!');
|
||||
|
||||
await page.getByRole('button', { name: /réinitialiser/i }).click();
|
||||
|
||||
// Should show error after submission
|
||||
await expect(page.getByRole('heading', { name: 'Lien invalide' })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
});
|
||||
|
||||
test('shows expiration message for expired token after form submission', async ({
|
||||
page
|
||||
}, testInfo) => {
|
||||
const email = `e2e-expired-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email, expired: true });
|
||||
test.skip(!token, 'Could not create expired test token');
|
||||
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Fill valid password and submit
|
||||
await page.locator('#password').fill('ValidPassword123!');
|
||||
await page.locator('#confirmPassword').fill('ValidPassword123!');
|
||||
|
||||
await page.getByRole('button', { name: /réinitialiser/i }).click();
|
||||
|
||||
// Should show expired/used error (shows "Lien invalide" heading with expiry message)
|
||||
await expect(page.getByRole('heading', { name: 'Lien invalide' })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Complete Reset Flow', () => {
|
||||
test('successfully resets password and redirects to login', async ({ page }, testInfo) => {
|
||||
const email = `e2e-success-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email });
|
||||
test.skip(!token, 'Could not create test token');
|
||||
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Fill form
|
||||
await page.locator('#password').fill('NewSecurePass123!');
|
||||
await page.locator('#confirmPassword').fill('NewSecurePass123!');
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /réinitialiser/i });
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
// Submit
|
||||
await submitButton.click();
|
||||
|
||||
// Should show success message (heading "Mot de passe modifié")
|
||||
await expect(page.getByRole('heading', { name: 'Mot de passe modifié' })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
});
|
||||
|
||||
test('token cannot be reused after successful reset', async ({ page }, testInfo) => {
|
||||
const email = `e2e-reuse-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email });
|
||||
test.skip(!token, 'Could not create test token');
|
||||
|
||||
// First reset
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
await expect(page.locator('form')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await page.locator('#password').fill('FirstPassword123!');
|
||||
await page.locator('#confirmPassword').fill('FirstPassword123!');
|
||||
await page.getByRole('button', { name: /réinitialiser/i }).click();
|
||||
|
||||
// Wait for success (heading "Mot de passe modifié")
|
||||
await expect(page.getByRole('heading', { name: 'Mot de passe modifié' })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
// Try to use same token again
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
await expect(page.locator('form')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await page.locator('#password').fill('SecondPassword123!');
|
||||
await page.locator('#confirmPassword').fill('SecondPassword123!');
|
||||
await page.getByRole('button', { name: /réinitialiser/i }).click();
|
||||
|
||||
// Should show error (already used) - page shows "Lien invalide" heading
|
||||
await expect(page.getByRole('heading', { name: 'Lien invalide' })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user