feat: Activation de compte utilisateur avec validation token
L'inscription Classeo se fait via invitation : un admin crée un compte, l'utilisateur reçoit un lien d'activation par email pour définir son mot de passe. Ce flow sécurisé évite les inscriptions non autorisées et garantit que seuls les utilisateurs légitimes accèdent au système. Points clés de l'implémentation : - Tokens d'activation à usage unique stockés en cache (Redis/filesystem) - Validation du consentement parental pour les mineurs < 15 ans (RGPD) - L'échec d'activation ne consume pas le token (retry possible) - Users dans un cache séparé sans TTL (pas d'expiration) - Hot reload en dev (FrankenPHP sans mode worker) Story: 1.3 - Inscription et activation de compte
This commit is contained in:
187
frontend/e2e/activation.spec.ts
Normal file
187
frontend/e2e/activation.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { getTestToken } from './test-utils';
|
||||
|
||||
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 = getTestToken();
|
||||
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 should show all checkmarks
|
||||
await passwordInput.fill('Abcdefgh1');
|
||||
const validItems = page.locator('.password-requirements li.valid');
|
||||
await expect(validItems).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('requires password confirmation to match', async ({ page }) => {
|
||||
const token = getTestToken();
|
||||
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 = getTestToken();
|
||||
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
|
||||
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 = getTestToken();
|
||||
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 = getTestToken();
|
||||
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', () => {
|
||||
test('activates account and redirects to login', async ({ page }) => {
|
||||
const token = getTestToken();
|
||||
await page.goto(`/activate/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Fill valid password
|
||||
await page.locator('#password').fill('SecurePass123');
|
||||
await page.locator('#passwordConfirmation').fill('SecurePass123');
|
||||
|
||||
// Submit
|
||||
await page.getByRole('button', { name: /activer mon compte/i }).click();
|
||||
|
||||
// Should redirect to login with success message
|
||||
await expect(page).toHaveURL(/\/login\?activated=true/);
|
||||
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
|
||||
});
|
||||
});
|
||||
52
frontend/e2e/global-setup.ts
Normal file
52
frontend/e2e/global-setup.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* Global setup for E2E tests.
|
||||
* Seeds a test activation token before tests run.
|
||||
*/
|
||||
async function globalSetup() {
|
||||
console.warn('🌱 Seeding test activation token...');
|
||||
|
||||
try {
|
||||
// Call the backend command to create a test token
|
||||
// Project root is 2 levels up from frontend/e2e/
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
const result = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-activation-token --email=e2e-test@example.com 2>&1`,
|
||||
{
|
||||
encoding: 'utf-8'
|
||||
}
|
||||
);
|
||||
|
||||
// Extract the token from the output
|
||||
// Output format: "Token f9174245-9766-4ef1-b6e9-a6795aa2da04"
|
||||
const tokenMatch = result.match(/Token\s+([a-f0-9-]{36})/i);
|
||||
if (!tokenMatch) {
|
||||
console.error('❌ Could not extract token from output:', result);
|
||||
throw new Error('Failed to extract token from command output');
|
||||
}
|
||||
|
||||
const token = tokenMatch[1];
|
||||
console.warn(`✅ Test token created: ${token}`);
|
||||
|
||||
// Write the token to a file for tests to use
|
||||
const tokenFile = join(__dirname, '.test-token');
|
||||
writeFileSync(tokenFile, token);
|
||||
|
||||
console.warn('✅ Token saved to .test-token file');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to seed test token:', error);
|
||||
// Don't throw - tests can still run with skipped token-dependent tests
|
||||
console.warn('⚠️ Tests requiring valid tokens will be skipped');
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
22
frontend/e2e/test-utils.ts
Normal file
22
frontend/e2e/test-utils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* Get the seeded test token.
|
||||
* The token is created by global-setup.ts before tests run via Docker.
|
||||
*/
|
||||
export function getTestToken(): string {
|
||||
const tokenFile = join(__dirname, '.test-token');
|
||||
|
||||
if (existsSync(tokenFile)) {
|
||||
return readFileSync(tokenFile, 'utf-8').trim();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'No .test-token file found. Make sure Docker is running and global-setup.ts executed successfully.'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user