diff --git a/.gitignore b/.gitignore index 86f5863..9871a89 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,13 @@ frontend/.output/ frontend/dist/ ###< frontend ### +###> playwright ### +frontend/test-results/ +frontend/playwright-report/ +frontend/blob-report/ +frontend/playwright/.cache/ +###< playwright ### + ###> docker ### infra/dev/.env.docker.local ###< docker ### diff --git a/CLAUDE.md b/CLAUDE.md index 623edc2..73400fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,6 +175,7 @@ make migration-migrate # Lancer les migrations make fixtures # Charger les fixtures make db-reset # Reset BDD + migrations + fixtures make test # PHPUnit +make nuxt-test # Vitest (tests unitaires frontend) make php-cs-fixer-allow-risky # Fix code style PHP make logs-dev # Tail logs Symfony ``` @@ -185,6 +186,36 @@ docker exec -t -u root php-coltura-fpm chown -R www-data:www-data /var/www/html/ docker exec -t -u www-data php-coltura-fpm php bin/console cache:clear ``` +### Tests E2E (Playwright) + +La suite E2E vit dans `frontend/tests/e2e/` (personas, Page Objects, specs). Elle tourne sur l'host (Playwright a besoin d'un navigateur reel, pas dans un container PHP). + +**Bootstrap one-time par poste de dev :** +```bash +make install-e2e-deps # Telecharge Chromium + installe les libs systeme (sudo) +``` +A relancer uniquement si `@playwright/test` upgrade de version majeure. + +**Workflow nominal :** +```bash +# Terminal 1 +make start # Containers up +make seed-e2e # Cree les 6 personas e2e.* (idempotent) +make dev-nuxt # Dev server sur :3004 (garder ouvert) + +# Terminal 2 +make test-e2e # Run la suite +# ou +make test-e2e-ui # UI interactive pour debug +``` + +**Regle d'or E2E** (a respecter pour garder la suite maintenable sur la duree) : un nouveau test E2E ne s'ajoute QUE quand un bug critique est passe en prod. Sinon, la bonne place est un test unitaire Vitest (plus rapide, plus stable). Cf. `frontend/tests/e2e/_fixtures/personas.ts` pour etendre la matrice RBAC via un persona existant plutot que d'ajouter un test. + +**Etendre la matrice RBAC** : pour ajouter une permission testable, toucher les 3 endroits (sinon drift garanti) : +1. `config/sidebar.php` — attacher `permission` au bon item +2. `frontend/tests/e2e/_fixtures/personas.ts` — ajuster `permissions` + `expectedAdminLinks` d'un persona existant (ne pas creer de nouveau persona par reflexe) +3. `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` — miroir back du meme persona + ## Conventions ### Commits diff --git a/README.md b/README.md index aba0d1f..495c901 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,35 @@ make dev-nuxt # Port 3003 | `make migration-migrate` | Lancer les migrations | | `make fixtures` | Charger les fixtures | | `make db-reset` | Reset BDD + migrations + fixtures | -| `make test` | PHPUnit | +| `make test` | PHPUnit (tests back) | +| `make nuxt-test` | Vitest (tests unitaires front) | +| `make test-e2e` | Playwright (tests E2E front) | +| `make test-e2e-ui` | Playwright UI interactive (debug) | +| `make seed-e2e` | Seed les 6 personas E2E | +| `make install-e2e-deps` | One-time : Chromium + libs systeme (sudo) | | `make php-cs-fixer-allow-risky` | Fix code style PHP | | `make logs-dev` | Tail logs Symfony | +## Tests + +- **Back** : `make test` (PHPUnit). Fixtures dediees sous `tests/Fixtures/`. +- **Front unitaire** : `make nuxt-test` (Vitest, happy-dom). Composables, utils, stores — rapide, <30s. +- **Front E2E** : `make test-e2e` (Playwright). Couvre login + matrice RBAC sidebar. Suite volontairement minimaliste (11 tests) — voir la regle d'or dans `CLAUDE.md`. + +**Bootstrap E2E (une fois par poste)** : +```bash +make install-e2e-deps # Telecharge Chromium + libs systeme via apt (sudo) +``` + +**Workflow E2E** : +```bash +# Terminal 1 : containers + dev server +make start && make seed-e2e && make dev-nuxt + +# Terminal 2 : tests +make test-e2e +``` + ## Architecture **Modular Monolith DDD** : chaque module est un bounded context autonome, activable/desactivable par tenant. Le backend est la seule source de verite pour l'activation et l'organisation de la sidebar. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6c174d7..f52fd2a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@nuxt/eslint-config": "^1.9.0", + "@playwright/test": "^1.59.1", "@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/parser": "^8.44.1", "@vitejs/plugin-vue": "^6.0.6", @@ -3895,6 +3896,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -12613,6 +12630,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f5f8f94..b5a4369 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,9 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@malio/layer-ui": "^1.4.2", @@ -28,6 +30,7 @@ }, "devDependencies": { "@nuxt/eslint-config": "^1.9.0", + "@playwright/test": "^1.59.1", "@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/parser": "^8.44.1", "@vitejs/plugin-vue": "^6.0.6", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..67d90e7 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Config Playwright pour les tests E2E de Coltura. + * + * Pre-requis avant de lancer : + * 1. Les containers Docker tournent (`make start`) + * 2. Le dev server Nuxt est lance (`make dev-nuxt`) sur le port 3004 + * 3. Les personas E2E sont seedes (`make seed-e2e` — cf. SeedE2ECommand cote back) + * + * La baseURL cible le dev server Nuxt (HMR) en dev local ; surcharger avec + * PLAYWRIGHT_BASE_URL=http://localhost:8083 pour taper sur le build Nginx + * (au plus pres de la prod, utile en CI). + */ +export default defineConfig({ + testDir: './tests/e2e', + + // Interdit `test.only` en CI pour ne pas skipper involontairement la suite. + forbidOnly: !!process.env.CI, + + // Pas de retry en local (bugs a reproduire), 2 retries en CI (flaky mitige). + retries: process.env.CI ? 2 : 0, + + // Parallelisme : 1 worker local pour faciliter le debug, defaut en CI. + workers: process.env.CI ? undefined : 1, + + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3004', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts new file mode 100644 index 0000000..22cd8a4 --- /dev/null +++ b/frontend/tests/e2e/_fixtures/personas.ts @@ -0,0 +1,112 @@ +/** + * Definition des 6 personas utilises dans les tests E2E. + * + * Source de verite unique partagee entre : + * - le seed backend (`bin/console app:seed-e2e`) + * - les tests Playwright (via `loginAs`) + * + * Regle : chaque persona cible une case precise de la matrice RBAC. + * Si tu ajoutes une permission au domaine, tu NE crees pas un nouveau + * persona par reflexe — tu ajustes un persona existant si possible. + * L'objectif est de garder ce set petit et comprehensible a 6 mois. + * + * IMPORTANT : ces personas sont recrees a chaque `app:seed-e2e`. Ne jamais + * reutiliser les users dev (admin/alice/bob) dans les tests : ils evoluent + * au gre des fixtures de demo et casseraient la suite E2E. + */ + +export type PersonaKey = + | 'super-admin' + | 'user-full' + | 'user-readonly' + | 'user-users-only' + | 'user-audit-only' + | 'user-nothing' + +export interface Persona { + key: PersonaKey + username: string + password: string + isAdmin: boolean + // Permissions directes attribuees en dur (on bypasse les roles pour + // garder le seed simple et la correspondance test<->permission directe). + permissions: string[] + // Contenu attendu de la sidebar (admin links). Utilise par le test + // sidebar-visibility pour driver la matrice. Les valeurs correspondent + // aux slugs de route (`/admin/`), volontairement stables quand + // la copie/i18n change. + expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log'> +} + +const SHARED_PASSWORD = 'e2e-secret' + +export const personas: Record = { + 'super-admin': { + key: 'super-admin', + username: 'e2e.super-admin', + password: SHARED_PASSWORD, + isAdmin: true, + permissions: [], + expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'], + }, + 'user-full': { + key: 'user-full', + username: 'e2e.user-full', + password: SHARED_PASSWORD, + isAdmin: false, + permissions: [ + 'core.users.view', + 'core.users.manage', + 'core.roles.view', + 'core.roles.manage', + 'core.audit_log.view', + 'sites.view', + 'sites.manage', + 'sites.bypass_scope', + ], + expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'], + }, + 'user-readonly': { + key: 'user-readonly', + username: 'e2e.user-readonly', + password: SHARED_PASSWORD, + isAdmin: false, + permissions: [ + 'core.users.view', + 'core.roles.view', + 'core.audit_log.view', + 'sites.view', + ], + expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'], + }, + 'user-users-only': { + key: 'user-users-only', + username: 'e2e.user-users-only', + password: SHARED_PASSWORD, + isAdmin: false, + permissions: ['core.users.view', 'core.users.manage'], + expectedAdminLinks: ['users'], + }, + 'user-audit-only': { + key: 'user-audit-only', + username: 'e2e.user-audit-only', + password: SHARED_PASSWORD, + isAdmin: false, + permissions: ['core.audit_log.view'], + expectedAdminLinks: ['audit-log'], + }, + 'user-nothing': { + key: 'user-nothing', + username: 'e2e.user-nothing', + password: SHARED_PASSWORD, + isAdmin: false, + permissions: [], + expectedAdminLinks: [], + }, +} + +export function getPersona(key: PersonaKey): Persona { + return personas[key] +} + +export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'audit-log'] as const diff --git a/frontend/tests/e2e/auth/login.spec.ts b/frontend/tests/e2e/auth/login.spec.ts new file mode 100644 index 0000000..0cfca7f --- /dev/null +++ b/frontend/tests/e2e/auth/login.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from '@playwright/test' +import { LoginPage } from '../helpers/pages/LoginPage' +import { getPersona } from '../_fixtures/personas' + +/** + * Tests du flow login/logout via l'UI. + * + * C'est le SEUL fichier qui traverse le formulaire pour de vrai. Les autres + * specs utilisent `loginAs()` qui pose directement le cookie BEARER via API, + * 10x plus rapide et decouple du form HTML. + */ +test.describe('Login', () => { + test('login valide pose le cookie BEARER et redirige vers /', async ({ page, context }) => { + const superAdmin = getPersona('super-admin') + const loginPage = new LoginPage(page) + + await loginPage.goto() + await loginPage.fillAndSubmit(superAdmin.username, superAdmin.password) + + // La redirection se fait apres un `navigateTo('/')` dans login.vue. + await page.waitForURL('/') + await expect(page).toHaveURL('/') + + // Le cookie BEARER (HTTP-only) doit etre pose par Symfony. + const cookies = await context.cookies() + const bearer = cookies.find(c => c.name === 'BEARER') + expect(bearer, 'Le cookie BEARER doit etre pose apres un login valide').toBeDefined() + expect(bearer?.httpOnly).toBe(true) + }) + + test('login invalide reste sur /login et n\'emet pas de cookie', async ({ page, context }) => { + const loginPage = new LoginPage(page) + + await loginPage.goto() + await loginPage.fillAndSubmit('e2e.super-admin', 'wrong-password') + + // On ne doit PAS etre redirige — le handleSubmit swallow la 401 via toast, + // le user reste sur /login pour corriger. + await page.waitForTimeout(500) + await expect(page).toHaveURL(/\/login$/) + + const cookies = await context.cookies() + const bearer = cookies.find(c => c.name === 'BEARER') + expect(bearer, 'Aucun cookie BEARER ne doit etre pose apres un login invalide').toBeUndefined() + }) + + test('logout efface le cookie et redirige vers /login', async ({ page, context }) => { + const superAdmin = getPersona('super-admin') + const loginPage = new LoginPage(page) + + // 1. Login d'abord + await loginPage.goto() + await loginPage.fillAndSubmit(superAdmin.username, superAdmin.password) + await page.waitForURL('/') + + // 2. Navigation vers /logout (il y a un lien "Deconnexion" dans la sidebar) + await page.goto('/logout') + await page.waitForURL(/\/login$/) + + // 3. Le cookie BEARER doit avoir ete supprime par le firewall de logout + const cookies = await context.cookies() + const bearer = cookies.find(c => c.name === 'BEARER') + expect(bearer, 'Le cookie BEARER doit etre supprime apres logout').toBeUndefined() + }) +}) diff --git a/frontend/tests/e2e/helpers/loginAs.ts b/frontend/tests/e2e/helpers/loginAs.ts new file mode 100644 index 0000000..f068a00 --- /dev/null +++ b/frontend/tests/e2e/helpers/loginAs.ts @@ -0,0 +1,45 @@ +import type { BrowserContext, Page } from '@playwright/test' +import { type PersonaKey, getPersona } from '../_fixtures/personas' + +/** + * Login programmatique : pose le cookie BEARER via l'API sans passer par le + * formulaire de login. + * + * Utilise ce helper dans TOUS les tests qui ne testent pas le flow login + * lui-meme (sidebar visibility, route guards, etc.). Ca evite de payer 2s + * par test sur le form HTML et ca isole les tests : si le form login casse, + * seul `login.spec.ts` est rouge, pas toute la suite. + * + * Impl : on issue une requete POST /api/login_check avec les creds du persona. + * Nginx reecrit vers /login_check, Symfony pose le cookie BEARER sur le + * context du browser. Apres ca, n'importe quelle navigation est authentifiee. + */ +export async function loginAs(context: BrowserContext, persona: PersonaKey, baseURL?: string): Promise { + const { username, password } = getPersona(persona) + const base = baseURL ?? 'http://localhost:3004' + + const response = await context.request.post(`${base}/api/login_check`, { + data: { username, password }, + }) + + if (!response.ok()) { + const body = await response.text() + throw new Error( + `loginAs(${persona}) a echoue : ${response.status()} ${body}. ` + + 'Verifier que le backend tourne et que `make seed-e2e` a ete lance.', + ) + } +} + +/** + * Helper d'appoint quand on veut tester VIA l'UI (login.spec.ts uniquement). + * Passe par le formulaire rendu, clique sur le bouton. A ne PAS utiliser + * dans les autres tests — preferer `loginAs()`. + */ +export async function loginViaForm(page: Page, persona: PersonaKey): Promise { + const { username, password } = getPersona(persona) + await page.goto('/login') + await page.getByLabel("Nom d'utilisateur").fill(username) + await page.getByLabel('Mot de passe').fill(password) + await page.getByRole('button', { name: 'Se connecter' }).click() +} diff --git a/frontend/tests/e2e/helpers/pages/LoginPage.ts b/frontend/tests/e2e/helpers/pages/LoginPage.ts new file mode 100644 index 0000000..819eece --- /dev/null +++ b/frontend/tests/e2e/helpers/pages/LoginPage.ts @@ -0,0 +1,32 @@ +import type { Locator, Page } from '@playwright/test' + +/** + * Page Object du formulaire de login (/login). + * + * Selecteurs : on s'appuie sur les labels/roles accessibles (stable vs les + * changements de CSS/Tailwind). Le jour ou on veut un selecteur plus dur, + * on ajoute des `data-testid` sur login.vue. + */ +export class LoginPage { + readonly page: Page + readonly usernameInput: Locator + readonly passwordInput: Locator + readonly submitButton: Locator + + constructor(page: Page) { + this.page = page + this.usernameInput = page.getByLabel("Nom d'utilisateur") + this.passwordInput = page.getByLabel('Mot de passe') + this.submitButton = page.getByRole('button', { name: 'Se connecter' }) + } + + async goto(): Promise { + await this.page.goto('/login') + } + + async fillAndSubmit(username: string, password: string): Promise { + await this.usernameInput.fill(username) + await this.passwordInput.fill(password) + await this.submitButton.click() + } +} diff --git a/frontend/tests/e2e/helpers/pages/SidebarComponent.ts b/frontend/tests/e2e/helpers/pages/SidebarComponent.ts new file mode 100644 index 0000000..9bdae9e --- /dev/null +++ b/frontend/tests/e2e/helpers/pages/SidebarComponent.ts @@ -0,0 +1,33 @@ +import type { Locator, Page } from '@playwright/test' + +export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'audit-log' + +/** + * Page Object de la sidebar (MalioSidebar), scope sur les items "admin". + * + * Strategie selecteur : `a[href=...]` plutot que le texte i18n. Le slug de + * route ne change pas quand on retraduit ou renomme une entree — c'est le + * selecteur le plus stable pour cette suite. + * + * Si un jour la sidebar change et les slugs bougent, on met a jour CE + * fichier uniquement ; les specs continuent de passer. + */ +export class SidebarComponent { + readonly page: Page + + constructor(page: Page) { + this.page = page + } + + adminLink(slug: AdminLinkSlug): Locator { + return this.page.locator(`a[href="/admin/${slug}"]`) + } + + accountDashboardLink(): Locator { + return this.page.locator('a[href="/"]').first() + } + + logoutLink(): Locator { + return this.page.locator('a[href="/logout"]') + } +} diff --git a/frontend/tests/e2e/permissions/sidebar-visibility.spec.ts b/frontend/tests/e2e/permissions/sidebar-visibility.spec.ts new file mode 100644 index 0000000..e7fc8d7 --- /dev/null +++ b/frontend/tests/e2e/permissions/sidebar-visibility.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from '@playwright/test' +import { loginAs } from '../helpers/loginAs' +import { SidebarComponent } from '../helpers/pages/SidebarComponent' +import { ALL_ADMIN_LINKS, type PersonaKey, getPersona, personas } from '../_fixtures/personas' + +/** + * Test strategique : la matrice persona <-> liens admin visibles. + * + * Valide que `SidebarProvider` (back) + `useSidebar` (front) filtrent bien + * les items admin selon les permissions RBAC de chaque user. + * + * Regle d'evolution : ajouter une permission ou un persona = 1 ligne a + * modifier dans `personas.ts` et cote back (`SeedE2ECommand`) + `sidebar.php`. + * Ce fichier ne bouge pas. + */ +test.describe('Sidebar visibility', () => { + const personaKeys: PersonaKey[] = [ + 'super-admin', + 'user-full', + 'user-readonly', + 'user-users-only', + 'user-audit-only', + 'user-nothing', + ] + + for (const key of personaKeys) { + const persona = getPersona(key) + + test(`${persona.key} ne voit que ses liens admin autorises`, async ({ page, context }) => { + await loginAs(context, persona.key) + await page.goto('/') + + // Attendre que la sidebar soit chargee (le middleware auth fetch /api/sidebar + // apres login). Les liens presents apparaissent alors ; les absents ne + // seront jamais attaches au DOM. + await page.waitForLoadState('networkidle') + + const sidebar = new SidebarComponent(page) + + for (const link of ALL_ADMIN_LINKS) { + const locator = sidebar.adminLink(link) + const shouldBeVisible = persona.expectedAdminLinks.includes(link) + + if (shouldBeVisible) { + await expect( + locator, + `${persona.key} doit voir le lien /admin/${link}`, + ).toBeVisible() + } else { + await expect( + locator, + `${persona.key} ne doit PAS voir le lien /admin/${link}`, + ).toHaveCount(0) + } + } + }) + } + + test('user-nothing voit toujours le dashboard et le logout (section Mon compte sans permission)', async ({ + page, + context, + }) => { + // La section "Mon compte" n'est gardee par aucune permission : tout user + // authentifie voit le dashboard et peut se deconnecter. Ce test protege + // contre une regression qui mettrait un gate RBAC par inadvertance + // dessus — ca bloquerait le logout de users sans permissions. + await loginAs(context, 'user-nothing') + await page.goto('/') + await page.waitForLoadState('networkidle') + + const sidebar = new SidebarComponent(page) + await expect(sidebar.accountDashboardLink()).toBeVisible() + await expect(sidebar.logoutLink()).toBeVisible() + }) + + test('la liste des personas dans personas.ts couvre toutes les combinaisons admin attendues', () => { + // Test meta : si quelqu'un ajoute un persona dans personas.ts sans le + // seeder cote back (SeedE2ECommand), le test sidebar pour ce persona + // echouera (loginAs 401). Ce test rappelle la coherence attendue. + expect(Object.keys(personas)).toEqual(personaKeys) + }) +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 946c045..d150aff 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ test: { environment: 'happy-dom', globals: true, + // Exclure les tests E2E Playwright : meme extension .spec.ts mais + // runtime different (navigateur vrai vs happy-dom). Playwright les + // ramasse via son propre testDir declare dans playwright.config.ts. + exclude: ['**/node_modules/**', '**/dist/**', 'tests/e2e/**'], }, resolve: { alias: { diff --git a/makefile b/makefile index 6e6f01b..60318db 100644 --- a/makefile +++ b/makefile @@ -38,7 +38,7 @@ restart: env-init $(DOCKER_COMPOSE) down CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d -install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate test-db-setup +install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate sync-permissions test-db-setup # Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi) reset: delete_built_dir remove_orphans build-without-cache start wait install @@ -50,6 +50,7 @@ composer-install: $(SYMFONY_CONSOLE) lexik:jwt:generate-keypair --skip-if-exists build-nuxtJS: + $(EXEC_PHP_ROOT) chown -R $(APP_USER):$(APP_USER) /var/www/html/frontend $(EXEC_PHP) sh -lc "cd frontend && npm install && npm run build:dist" dev-nuxt: @@ -65,6 +66,38 @@ nuxt-lint-fix: nuxt-test: $(EXEC_PHP) sh -c "cd frontend && npm run test" +# Seed les 6 personas E2E (idempotent). A relancer des que le catalogue +# permissions bouge (sync-permissions) ou avant chaque run test-e2e. +seed-e2e: + $(SYMFONY_CONSOLE) app:seed-e2e + +# Bootstrap one-time pour les tests E2E sur un nouveau poste : +# 1. Telecharge Chromium dans ~/.cache/ms-playwright +# 2. Installe les deps systeme (libnss3, libasound, libatk, etc.) via +# la liste officielle Playwright — demande sudo. +# +# Le `sudo env "PATH=$$PATH"` est necessaire car avec NVM, `sudo npx` ne +# trouve pas npx (le PATH de sudo est vide par defaut). On preserve +# explicitement le PATH courant pour que npx resolve. +# +# A relancer uniquement si tu upgrade @playwright/test (les deps peuvent +# bouger entre versions majeures). +install-e2e-deps: + cd frontend && npx playwright install chromium + cd frontend && sudo env "PATH=$$PATH" npx playwright install-deps chromium + +# Lance les tests E2E Playwright sur l'host. Pre-requis : +# - `make install-e2e-deps` (une fois par poste) +# - `make start` (containers en vie) +# - `make dev-nuxt` dans un autre terminal (serve frontend sur :3004) +# - `make seed-e2e` (personas crees) +test-e2e: + cd frontend && npm run test:e2e + +# UI interactive Playwright (debug facile) +test-e2e-ui: + cd frontend && npm run test:e2e:ui + delete_built_dir: CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d $(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/ diff --git a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php new file mode 100644 index 0000000..8acd257 --- /dev/null +++ b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php @@ -0,0 +1,205 @@ +roleRepository->findByCode(SystemRoles::USER_CODE); + + if (null === $userRole) { + $io->error(sprintf( + 'Le role systeme "%s" est introuvable. Lance les migrations + fixtures ou `app:sync-permissions` avant ce seed.', + SystemRoles::USER_CODE, + )); + + return Command::FAILURE; + } + + $defaultSite = $this->siteRepository->findByName(self::DEFAULT_SITE_NAME); + + // Pas de fail fatal si le site manque : les tests sidebar/login + // n'en dependent pas. Les tests sites-scope-bypass (a venir) le feront. + if (null === $defaultSite) { + $io->note(sprintf( + 'Site "%s" absent : les personas seront crees sans site. Lance `make fixtures` si tu as besoin des sites.', + self::DEFAULT_SITE_NAME, + )); + } + + $this->wipeExistingE2EUsers($io); + + foreach ($this->personasDefinition() as $persona) { + $user = new User(); + $user->setUsername($persona['username']); + $user->setPassword($this->passwordHasher->hashPassword($user, self::SHARED_PASSWORD)); + $user->setIsAdmin($persona['isAdmin']); + $user->addRbacRole($userRole); + + foreach ($persona['permissions'] as $code) { + $permission = $this->permissionRepository->findByCode($code); + + if (null === $permission) { + throw new RuntimeException(sprintf( + 'Permission "%s" introuvable en base. Lance `app:sync-permissions` avant `app:seed-e2e`.', + $code, + )); + } + + $user->addDirectPermission($permission); + } + + if (null !== $defaultSite && 'e2e.user-nothing' !== $persona['username']) { + // user-nothing reste sans site pour pouvoir tester un flow + // "aucune permission et aucun site". + $user->addSite($defaultSite); + $user->setCurrentSite($defaultSite); + } + + $this->userRepository->save($user); + + $io->text(sprintf( + ' - %s (admin=%s, permissions=%d)', + $persona['username'], + $persona['isAdmin'] ? 'oui' : 'non', + count($persona['permissions']), + )); + } + + $io->success(sprintf('%d personas E2E seedes.', count($this->personasDefinition()))); + + return Command::SUCCESS; + } + + private function wipeExistingE2EUsers(SymfonyStyle $io): void + { + $removed = 0; + + foreach ($this->personasDefinition() as $persona) { + $existing = $this->userRepository->findByUsername($persona['username']); + + if (null === $existing) { + continue; + } + + $this->em->remove($existing); + ++$removed; + } + + if ($removed > 0) { + $this->em->flush(); + $io->text(sprintf('Nettoyage : %d users E2E supprimes.', $removed)); + } + } + + /** + * Liste des personas — source back, miroir de + * `frontend/tests/e2e/_fixtures/personas.ts`. + * + * @return list}> + */ + private function personasDefinition(): array + { + return [ + [ + 'username' => self::E2E_USERNAME_PREFIX.'super-admin', + 'isAdmin' => true, + 'permissions' => [], + ], + [ + 'username' => self::E2E_USERNAME_PREFIX.'user-full', + 'isAdmin' => false, + 'permissions' => [ + 'core.users.view', + 'core.users.manage', + 'core.roles.view', + 'core.roles.manage', + 'core.audit_log.view', + 'sites.view', + 'sites.manage', + 'sites.bypass_scope', + ], + ], + [ + 'username' => self::E2E_USERNAME_PREFIX.'user-readonly', + 'isAdmin' => false, + 'permissions' => [ + 'core.users.view', + 'core.roles.view', + 'core.audit_log.view', + 'sites.view', + ], + ], + [ + 'username' => self::E2E_USERNAME_PREFIX.'user-users-only', + 'isAdmin' => false, + 'permissions' => ['core.users.view', 'core.users.manage'], + ], + [ + 'username' => self::E2E_USERNAME_PREFIX.'user-audit-only', + 'isAdmin' => false, + 'permissions' => ['core.audit_log.view'], + ], + [ + 'username' => self::E2E_USERNAME_PREFIX.'user-nothing', + 'isAdmin' => false, + 'permissions' => [], + ], + ]; + } +}