test(e2e) : initialise la suite Playwright (login + sidebar RBAC)

- 11 tests couvrant le login (3) et la visibilite sidebar par RBAC (8)
- 6 personas seedes via la commande app:seed-e2e, miroir cote front
  dans frontend/tests/e2e/_fixtures/personas.ts
- Page Objects (LoginPage, SidebarComponent) avec selecteurs stables
  par href + loginAs programmatique via cookie BEARER
- Targets Makefile : seed-e2e, test-e2e, test-e2e-ui, install-e2e-deps
- CLAUDE.md + README.md : workflow E2E + regle d'or "un E2E par bug
  prod uniquement" pour garder la suite maintenable dans la duree

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 17:27:05 +02:00
parent 99c77eb7b6
commit 4603ab2832
15 changed files with 786 additions and 3 deletions

7
.gitignore vendored
View File

@@ -29,6 +29,13 @@ frontend/.output/
frontend/dist/ frontend/dist/
###< frontend ### ###< frontend ###
###> playwright ###
frontend/test-results/
frontend/playwright-report/
frontend/blob-report/
frontend/playwright/.cache/
###< playwright ###
###> docker ### ###> docker ###
infra/dev/.env.docker.local infra/dev/.env.docker.local
###< docker ### ###< docker ###

View File

@@ -175,6 +175,7 @@ make migration-migrate # Lancer les migrations
make fixtures # Charger les fixtures make fixtures # Charger les fixtures
make db-reset # Reset BDD + migrations + fixtures make db-reset # Reset BDD + migrations + fixtures
make test # PHPUnit make test # PHPUnit
make nuxt-test # Vitest (tests unitaires frontend)
make php-cs-fixer-allow-risky # Fix code style PHP make php-cs-fixer-allow-risky # Fix code style PHP
make logs-dev # Tail logs Symfony 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 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 ## Conventions
### Commits ### Commits

View File

@@ -46,10 +46,35 @@ make dev-nuxt # Port 3003
| `make migration-migrate` | Lancer les migrations | | `make migration-migrate` | Lancer les migrations |
| `make fixtures` | Charger les fixtures | | `make fixtures` | Charger les fixtures |
| `make db-reset` | Reset BDD + migrations + 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 php-cs-fixer-allow-risky` | Fix code style PHP |
| `make logs-dev` | Tail logs Symfony | | `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 ## 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. **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.

View File

@@ -20,6 +20,7 @@
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint-config": "^1.9.0", "@nuxt/eslint-config": "^1.9.0",
"@playwright/test": "^1.59.1",
"@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1", "@typescript-eslint/parser": "^8.44.1",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
@@ -3895,6 +3896,22 @@
"node": ">=14" "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": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -12613,6 +12630,53 @@
"pathe": "^2.0.3" "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": { "node_modules/pluralize": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",

View File

@@ -12,7 +12,9 @@
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.4.2", "@malio/layer-ui": "^1.4.2",
@@ -28,6 +30,7 @@
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint-config": "^1.9.0", "@nuxt/eslint-config": "^1.9.0",
"@playwright/test": "^1.59.1",
"@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1", "@typescript-eslint/parser": "^8.44.1",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",

View File

@@ -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'] },
},
],
})

View File

@@ -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/<slug>`), volontairement stables quand
// la copie/i18n change.
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log'>
}
const SHARED_PASSWORD = 'e2e-secret'
export const personas: Record<PersonaKey, Persona> = {
'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

View File

@@ -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()
})
})

View File

@@ -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<void> {
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<void> {
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()
}

View File

@@ -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<void> {
await this.page.goto('/login')
}
async fillAndSubmit(username: string, password: string): Promise<void> {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}

View File

@@ -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"]')
}
}

View File

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

View File

@@ -7,6 +7,10 @@ export default defineConfig({
test: { test: {
environment: 'happy-dom', environment: 'happy-dom',
globals: true, 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: { resolve: {
alias: { alias: {

View File

@@ -38,7 +38,7 @@ restart: env-init
$(DOCKER_COMPOSE) down $(DOCKER_COMPOSE) down
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d 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) # Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
reset: delete_built_dir remove_orphans build-without-cache start wait install 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 $(SYMFONY_CONSOLE) lexik:jwt:generate-keypair --skip-if-exists
build-nuxtJS: 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" $(EXEC_PHP) sh -lc "cd frontend && npm install && npm run build:dist"
dev-nuxt: dev-nuxt:
@@ -65,6 +66,38 @@ nuxt-lint-fix:
nuxt-test: nuxt-test:
$(EXEC_PHP) sh -c "cd frontend && npm run 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: delete_built_dir:
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/ $(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Seed dedie aux tests E2E Playwright (frontend/tests/e2e).
*
* Cree 6 personas (e2e.*) qui couvrent les cases nominales de la matrice
* RBAC : super-admin, user-full, user-readonly, user-users-only,
* user-audit-only, user-nothing. Cette liste est la replique back du
* fichier `frontend/tests/e2e/_fixtures/personas.ts` — si tu modifies
* l'une des deux, met a jour l'autre.
*
* Idempotent : supprime les users prefixes `e2e.` avant de les recreer.
* Ne touche PAS aux fixtures dev (admin/alice/bob) ni aux sites.
*
* Pre-requis : `bin/console app:sync-permissions` doit avoir tourne pour
* que les permissions soient en base. La commande echoue en erreur explicite
* si une permission attendue est absente du catalogue.
*/
#[AsCommand(
name: 'app:seed-e2e',
description: 'Seed les 6 personas utilises par les tests E2E Playwright.',
)]
final class SeedE2ECommand extends Command
{
private const string SHARED_PASSWORD = 'e2e-secret';
private const string E2E_USERNAME_PREFIX = 'e2e.';
private const string DEFAULT_SITE_NAME = 'Chatellerault';
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UserRepositoryInterface $userRepository,
private readonly RoleRepositoryInterface $roleRepository,
private readonly PermissionRepositoryInterface $permissionRepository,
private readonly SiteRepositoryInterface $siteRepository,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$userRole = $this->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<array{username: string, isAdmin: bool, permissions: list<string>}>
*/
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' => [],
],
];
}
}