Permet aux utilisateurs de visualiser et gérer leurs sessions actives sur différents appareils, avec la possibilité de révoquer des sessions à distance en cas de suspicion d'activité non autorisée. Fonctionnalités : - Liste des sessions actives avec métadonnées (appareil, navigateur, localisation) - Identification de la session courante - Révocation individuelle d'une session - Révocation de toutes les autres sessions - Déconnexion avec nettoyage des cookies sur les deux chemins (legacy et actuel) Sécurité : - Cache frontend scopé par utilisateur pour éviter les fuites entre comptes - Validation que le refresh token appartient à l'utilisateur JWT authentifié - TTL des sessions Redis aligné sur l'expiration du refresh token - Événements d'audit pour traçabilité (SessionInvalidee, ToutesSessionsInvalidees) @see Story 1.6 - Gestion des sessions
272 lines
10 KiB
TypeScript
272 lines
10 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);
|
|
|
|
/**
|
|
* 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 dev environment to match the running web server
|
|
const result = execSync(
|
|
`docker compose -f "${composeFile}" exec -T 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
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|