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

View File

@@ -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",

View File

@@ -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",

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: {
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: {