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 }); }); }); }); });