Compare commits

..

6 Commits

Author SHA1 Message Date
gitea-actions
cd17248427 chore: bump version to v0.1.36
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 2m10s
2026-05-13 09:22:08 +00:00
6bd7f3b059 fix : package-lock.json
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-05-13 11:21:39 +02:00
gitea-actions
b1ea732155 chore: bump version to v0.1.35
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 10s
2026-05-13 09:03:50 +00:00
99e96cb493 [#ERP-41] Mise à jour Malio UI (#10)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #10
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-13 09:03:44 +00:00
e6c8381b3c feat : audit log (table + writer + listener + API + admin UI + timeline) (#9)
Some checks failed
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Failing after 9s
## Résumé

Implémente le journal d'audit append-only couvrant les 5 tickets de `doc/audit-log.md` et embarque au passage plusieurs corrections périphériques (sidebar Admin/Mon compte, drawer RBAC, Swagger, schema_filter Doctrine) ainsi que l'initialisation de la suite e2e Playwright. Toutes les mutations Doctrine sur les entités portant `#[Auditable]` sont tracées dans une table PostgreSQL dédiée, exposée en lecture seule via API Platform et consultable par les admins dans une page dédiée.

## Ce qui change

### Audit log — cœur de la PR

**Backend**

- Migration : table `audit_log` (UUID v7 natif Postgres en PK, `jsonb changes`, 3 index pour tri chrono, par entité et par utilisateur).
- `AuditLogWriter` : service bas-niveau, écrit via une connexion DBAL dédiée `audit` (même DSN que `default`, service séparé) pour sortir de la transaction ORM en batch. Blacklist defense-in-depth `password`/`plainPassword`/`token`/`secret`.
- `RequestIdProvider` : UUID v4 généré au `kernel.request` principal, injecté dans chaque ligne d'audit de la requête.
- Attributs `#[Auditable]` / `#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` (accessibles par tous les modules).
- `AuditListener` : capture `onFlush` / écriture `postFlush` avec pattern swap-and-clear contre les flushes ré-entrants. Erreurs loguées, jamais propagées. Entité `User` annotée (password / plainPassword ignorés).
- API Platform read-only `/api/audit-logs` (permission RBAC `core.audit_log.view`) : `GET` collection paginée + `GET` item, pas de POST/PUT/PATCH/DELETE. Filtres `entity_type`, `entity_id`, `action`, `performed_by`, `performed_at[after]`/`[before]`.
- `DbalPaginator` implémentant `PaginatorInterface` : `hydra:view` généré automatiquement par API Platform, pas de construction manuelle.
- Ressource `AuditLogEntityTypesResource` + provider dédié pour peupler le filtre par type d'entité côté UI (réponse cachée, pas de requête à chaque ouverture du drawer).
- Permission `core.audit_log.view` déclarée dans `CoreModule::permissions()`.
- `audit_log` exclu du `schema_filter` Doctrine : plus de faux diff sur `make migration-diff`.

**Frontend**

- Page admin `/admin/audit-log` : tableau paginé, filtres locaux (état dans le composant, non persistés dans l'URL — conforme règle CLAUDE.md « Tableaux : pas de persistance URL »), drawer de détail (diff + timeline complète de l'entité), badges colorés par action.
- Composable partagé `useAuditLog` avec `resetAuditLog()` auto-enregistré sur `onAuthSessionCleared` (règle CLAUDE.md composables singletons).
- Composant réutilisable `<AuditTimeline :entity-type :entity-id>` : garde permission (pas d'appel API sans le droit), lazy loading (10 items + bouton « Voir plus »), dates relatives FR via `Intl.RelativeTimeFormat`, skeleton loader.
- Entrée sidebar « Journal d'audit » gated sur `core.audit_log.view` + clés i18n imbriquées dans `fr.json`.

### Fixes embarqués

- **Review fixes audit-log** (commits `37eafd2`, `1505e84`, `99c77eb`) : précision des timestamps, `ESCAPE` sur les `LIKE`, plafond pagination, diverses remarques du 1er tour de review.
- **Sidebar** (`701a480`, `e2fbf51`) : nouvelle section « Administration » + groupe « Mon compte », gate de section sur permissions, « Tableau de bord » déplacé dans « Mon compte ». Convention admin documentée.
- **Drawer RBAC utilisateurs** (`617ee31`, `5f5afcc`) : corrige l'affichage des sites et l'écrasement via merge-patch (garde anti-écrasement + spec `GET /users/{id}/rbac` documentée).
- **Swagger UI** (`6db955f`) : réactivé en ajoutant `symfony/twig-bundle` aux deps (régression depuis l'arrivée d'API Platform 4.2).
- **`phpunit.dist.xml`** : `<env APP_ENV=dev>` forçait la suite à tourner sous `framework.test=false` (→ `test.service_container` introuvable) ; `JWT_PASSPHRASE` ne matchait pas les clés de dev. Corrigés pour débloquer la suite.

### E2E Playwright (nouveau, commit `4603ab2`)

- `playwright.config.ts` + structure `frontend/tests/e2e/` (personas, helpers `loginAs`, page objects `LoginPage` + `SidebarComponent`).
- Specs : `auth/login.spec.ts` + `permissions/sidebar-visibility.spec.ts` (vérifie la visibilité de la sidebar par rôle RBAC).
- Commande `SeedE2ECommand` pour préparer un jeu de données déterministe côté backend.
- `make e2e` ajouté au Makefile.

## Décisions techniques

- **UUID v7 natif Postgres** (16 octets vs 36 en varchar) : index `performed_at` ~40 % plus petit sur une table append-only à croissance infinie.
- **`entity_type` format `module.Entity`** (ex: `core.User`) : évite les collisions si deux modules ont des entités de même nom.
- **`performed_by` dénormalisé** (string, pas FK) : le nom persiste même après suppression de l'utilisateur.
- **Connexion DBAL dédiée `audit`** : évite l'entanglement transactionnel entre audit et ORM en batch.
- **`ManyToMany` non audité** : limitation connue (`getEntityChangeSet()` ne couvre pas les collections) ; extension future via `getScheduledCollectionUpdates()` si besoin.
- **Filtres locaux non persistés dans l'URL** : choix assumé (cf. CLAUDE.md) pour éviter le couplage table ↔ routeur.

## Test plan

- [x] `make test` : 218 tests passent (writer unitaires + listener intégration + API fonctionnels + UserRbacProcessor).
- [x] `npm run lint` + `npm run test` + `npm run build` (frontend).
- [x] Migration appliquée sur dev + test, `audit_log` ignoré par `schema_filter`.
- [x] Permissions synchronisées (`app:sync-permissions`).
- [x] Swagger `/api/docs` accessible de nouveau.
- [ ] Playwright : `make e2e` vert en local (login + sidebar-visibility).
- [ ] Vérifier en local : création/modif/suppression d'un user apparaît dans `/admin/audit-log`.
- [ ] Vérifier : user sans `core.audit_log.view` → 403 sur l'endpoint + item absent de la sidebar.
- [ ] Vérifier : expansion d'une ligne affiche la timeline de l'entité avec dates relatives FR.
- [ ] Vérifier : drawer RBAC utilisateur n'écrase plus la liste des sites au `PATCH`.

## Points d'attention pour le review

- `AuditListener` : pattern swap-and-clear sur `postFlush` — relire la gestion des flushes ré-entrants.
- `DbalPaginator` : vérifier que l'absence d'`Iterator` custom ne casse pas la normalisation API Platform sur collections vides.
- `UserRbacProcessor` : logique merge-patch + garde anti-écrasement des sites (régression corrigée dans `617ee31`).
- Playwright : nouvelle dépendance de dev, s'assurer que `make e2e` ne fait pas partie du pipeline CI par défaut (à brancher explicitement).

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #9
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-committed-by: matthieu <matthieu@yuno.malio.fr>
2026-05-13 08:29:30 +00:00
gitea-actions
dce189d982 chore: bump version to v0.1.33
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 9s
2026-04-20 17:56:54 +00:00
28 changed files with 1676 additions and 98 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
src/Module/Core/Infrastructure/Console/SeedE2ECommand.php

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
/.env.local.php
/.env.*.local
/config/secrets/dev/dev.decrypt.private.php
/config/reference.php
/public/bundles/
/var/
/vendor/

View File

@@ -26,6 +26,13 @@ doctrine:
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
# Mapping contrat DDD → classe concrete. Permet au module Core de
# referencer `SiteInterface` dans ses ORM mappings (User) sans importer
# la classe concrete du module Sites. Pattern officiel Doctrine pour
# les bounded contexts, remplace l'ancien import direct
# `App\Module\Sites\Domain\Entity\Site` dans User.php.
resolve_target_entities:
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
mappings:
Core:
type: attribute

View File

@@ -28,5 +28,8 @@ services:
App\Module\Sites\Domain\Repository\SiteRepositoryInterface:
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
App\Shared\Domain\Contract\SiteProviderInterface:
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
alias: App\Module\Sites\Application\Service\CurrentSiteProvider

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.34'
app.version: '0.1.36'

View File

@@ -0,0 +1,466 @@
# Backlog — Code review PR #9 (audit-log)
Findings du review multi-agent (security + architecture + codex sceptique) sur la PR #9 `feat/audit-log`, qui **n'ont pas ete traites dans la PR** et sont a ouvrir en tickets dedies.
Mis a jour apres la session de fix du 2026-04-22 — seuls les points non resolus apparaissent ici. Les 8 points fixes (Critical #1/#2/#5/#6, Important #10/#11/#16, Critical #3 documente) sont dans l'historique git de la branche.
Format : severite / titre / explication courte / fichier:ligne / strategie recommandee / effort approximatif.
---
## 🔴 Critical parke
### C-4 — Blacklist password exact-match et non recursive
**Fichier** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:35,81-98`
La blacklist `['password', 'plainPassword', 'token', 'secret']` est en match exact top-level. Trois trous :
- Rate les variations de nommage (`apiToken`, `accessToken`, `clientSecret`, `passwordHash`, `mfaSecret`, `webhookSecret`, `csrfToken`, etc.)
- Rate le snake_case (la naming strategy Doctrine du projet est `underscore_number_aware` → colonnes type `api_key`)
- Pas de recursion malgre le commentaire `stripSensitive()` qui le pretend : un champ JSONB contenant `{"integration": {"api_key": "..."}}` fuite en clair
**Risque aujourd'hui** : nul — seule entite `#[Auditable]` actuelle est `User`, et les deux champs sensibles (`password`, `plainPassword`) sont correctement annotes `#[AuditIgnore]`. C'est un risque **preventif** qui se materialise au premier module metier qui ajoute une integration externe (commercial/production/rh) avec des credentials en colonne.
**Strategie recommandee (Option B du brainstorm)** :
- Supprimer la blacklist (fausse securite)
- Faire de `#[AuditIgnore]` la seule defense
- Ajouter un test CI : parcourt toutes les entites `#[Auditable]` via reflection, liste les proprietes dont le nom matche `/token|secret|password|key|salt|hash|passphrase/i`, assert que chacune porte `#[AuditIgnore]`
**Effort** : 15-20 min.
---
## 🔴 Critical documente mais pas fixe code
### C-3 — Savepoints + connexion audit dediee = lignes audit orphelines
**Fichiers** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:19-30`, `config/packages/doctrine.yaml:3-23`
Le contrat est documente dans `doc/audit-log.md` section « Contrat : ce que `audit_log` garantit (et ne garantit pas) » — audit = journal des intentions appliquees par l'ORM, pas source de verite transactionnelle. Acceptable pour un CRM interne (rollbacks outermost rares).
Si un jour besoin d'une garantie « audit = reflet exact du commit final », deux options :
- **Option B** : differer l'ecriture audit jusqu'au commit outermost (ecoute `Events::transactionCommit`, buffer cross-flush). Complexe, distinguer RELEASE SAVEPOINT d'un vrai COMMIT.
- **Option C** : ecrire l'audit sur la meme connexion que le metier. Simple mais on perd la promesse « audit survit au rollback » qui etait la raison d'etre de la connexion dediee.
**Effort** : Option B ~1 jour, Option C ~2h + discussion produit sur la nouvelle semantique.
---
### C-5 — Enumeration laterale via `entity_type` cross-permission
**Fichiers** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:111-211`, `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php:44-52`
Le seul check d'acces est `is_granted('core.audit_log.view')`. Un user qui possede cette permission mais **pas** `core.users.view` / `sites.view` peut faire :
```
GET /api/audit-logs?entity_type=core.User&entity_id=42
GET /api/audit-logs?entity_type=sites.Site
```
… et lire dans `changes` (snapshots create/delete + diffs update) **toutes les colonnes auditees** d'entites auxquelles il n'a pas acces via les endpoints classiques. Le `changes` JSONB contient le payload complet.
**Risque aujourd'hui** : un user RBAC avec uniquement `core.audit_log.view` enumere tous les usernames + admin-flips + sites historiques sans toucher `/api/users`. La permission "lecture audit" est de facto plus large que prevue.
**Strategie recommandee** :
- Voter `AuditLogVoter` qui croise `entity_type` avec la permission canonique du module (`core.User → core.users.view`, `sites.Site → sites.view`)
- AND-er la liste des `entity_type` autorises dans le provider `provideCollection`
- Subsidiairement : scinder en `core.audit_log.view` (mes propres actions) vs `core.audit_log.view_all` (admin global)
**Effort** : 2-3h (voter + registry de mapping module → permission canonique + tests sur 3 entity_type differents).
**Impact** : confidentialite cross-module. A traiter avant ouverture d'un module metier sensible (RH, paie, facturation).
---
## 🟠 Important
### I-7 — `DbalPaginator` fait `COUNT(*)` sur chaque list request
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:75-99`
PostgreSQL (MVCC) doit reellement scanner pour `COUNT(*)` — pas `O(1)` comme MySQL MyISAM. Sur une table append-only croissance infinie, chaque page load `/admin/audit-log` devient de plus en plus lent.
Estimation : ~10k lignes/jour (50 users × 200 actions) → 3.65M lignes/an → page load de 20ms aujourd'hui, ~2-3s dans 2 ans. Pire avec un filtre ILIKE (wildcard leading = full scan).
**Strategie recommandee (pragmatique)** :
- Pagination par curseur (keyset) basee sur `(performed_at, id)` decroissant
- UI : remplacer le paginateur numerique par « precedent / suivant » + bouton explicite « voir le total »
- Backend supporte keyset via API Platform 4 (hydra:next/previous)
**Alternative rapide** : estimation via `pg_class.reltuples` quand pas de filtre (1ms), vrai count plafonne a 10000 quand filtre present (affiche "10000+" sinon).
**Effort** : keyset complet ~1h30-2h, version pragmatique ~30 min.
**Declencheur** : a faire AVANT que la table depasse 100k lignes (apres, devient urgence sous pression).
---
### I-8 — Pas de politique de retention / archival sur `audit_log`
**Fichiers** : `migrations/Version20260420202749.php`, `doc/audit-log.md`
La migration elle-meme decrit la table comme « croissance infinie ». Aucune TTL, archive job, ou partitioning documente. Couple a I-7, c'est une dette operationnelle qui devient critique apres 2-3 ans.
**Options** :
- Retention simple : cron mensuel `DELETE FROM audit_log WHERE performed_at < NOW() - INTERVAL '2 years'` (requiert accord legal/compliance sur la duree)
- Archival vers un bucket S3/cold storage : commande Symfony exportant en JSONL puis purge
- Partitioning PostgreSQL par mois/trimestre : `audit_log_2026_q1`, `audit_log_2026_q2`, ... drop partition apres N mois
**Effort** : depend du choix. Retention simple ~2h. Archival ~1 jour. Partitioning ~1-2 jours + migration progressive.
**Decision produit requise** avant implementation.
---
### I-9 — Echec DB audit silencieusement swallowed, pas d'alerting
**Fichier** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php:151-169`
Le try/catch swallow toute exception de `AuditLogWriter::log()`, log au niveau `error` dans Monolog, et continue. Si la connexion `audit` tombe (pool sature, disque plein, etc.), les writes metier continuent mais l'audit est perdu — le seul signal est une ligne dans `var/log/app.log`.
Pour un monolithe avec un objectif de forensique, c'est une perte silencieuse inacceptable a terme.
**Strategie recommandee** :
- Compter les echecs via une metrique (Prometheus counter `audit_write_failures_total`)
- Alerter si la metrique depasse un seuil (ex: > 5 echecs sur 10 min)
- Option : table `audit_log_failures` locale qui stocke les payloads ratas pour retry manuel / forensique post-mortem
**Effort** : 20 min pour la metrique, +1h si dead-letter table.
---
### I-12 — `ensureCurrentSiteConsistency` : 2e flush attribue a l'admin qui PATCH
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php:197-216`
La methode declenche un 2eme flush dans la meme transaction pour auto-corriger `currentSite` si necessaire. Ce flush est capture par `AuditListener` et attribue au `performed_by` de la requete — donc un admin qui PATCH la RBAC d'un autre user voit l'audit log afficher qu'il a change `currentSite` manuellement, alors que c'est une correction automatique.
**Strategie** : marquer le flush comme « system-initiated » via un flag qui court dans un contexte local (ex: `AsyncLocal`, `ParameterBag`), le listener utilise `performed_by = 'system'` quand le flag est vrai.
**Effort** : 10-15 min.
**Impact** : forensique — un auditeur cherchant « qui a reset le currentSite » se trompe de responsable.
---
### I-13 — `AuditListener` pas scope-pinne a l'EntityManager
**Fichier** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php:65-66`
Le listener utilise `#[AsDoctrineListener(event: ...)]` sans argument `connection`. Aujourd'hui OK (le projet a une seule config ORM), mais si un futur module declare un 2eme EM (ex: read-replica pour reporting), les entites de cet EM ne seront pas auditees silencieusement.
**Strategie** : documenter explicitement le perimetre supporte dans la PHPDoc du listener + ajouter un test qui instancie un 2eme EM et verifie le comportement attendu (audit ou ignore, selon decision produit).
**Effort** : 5 min doc + 30 min test si besoin.
---
### I-14 — Pas de regression test direct pour "sites overwritten on PATCH omission"
**Fichier** : `tests/Module/Core/Api/UserRbacSitesApiTest.php:142-169`
Le test `testRbacPatchWithoutSitesFieldDoesNotChangeCurrentSite` verifie que `currentSite` n'est pas touche, mais n'assert pas que la collection `sites` elle-meme est preservee. Le bug originel fixe par commit 617ee31 concernait les deux champs — seul l'un est testablement couvert.
**Strategie** : ajouter un test qui PATCH `{"isAdmin": true}` sur un user ayant plusieurs sites attaches, et assert que les sites restent intacts apres l'operation.
**Effort** : 5 min.
---
### I-15 — `AuditLogProvider` trop gras : extraire `DbalAuditLogRepository`
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
Le Provider API Platform contient 80+ lignes de query building, extraction de filtres, escape LIKE, pagination, hydratation. Responsabilite mixte « orchestration API » et « requetes DBAL ». Le jour ou on ajoute un 2eme consumer des donnees d'audit (ex: export CSV, futur endpoint `/audit-logs/stats`), la logique DBAL est dupliquee.
**Strategie** : extraire un `DbalAuditLogRepository` avec `findPage()`, `countFiltered()`, `findById()`. Provider devient un thin adapter.
**Effort** : 20-30 min refacto.
---
### I-18 — `useAuditLog.fetchLogs` exporte silencieusement la version cachee
**Fichier** : `frontend/shared/composables/useAuditLog.ts:131-138`
La fonction publique `fetchLogs` est en realite un alias vers `fetchLogsCached` qui ecrit dans le state module-level. Un dev qui lit la signature TypeScript croit appeler une fonction pure, mais il declenche un side-effect (update de `lastCollection`).
**Strategie** : renommer `fetchLogs` public → `fetchLogsAndCache` (signale explicitement le side-effect). Ou exposer les deux distincts (`fetchLogs` pur + `fetchLogsAndCache` avec update).
**Effort** : 5 min (ripple sur `audit-log.vue` a suivre).
---
### I-19 — `RequestIdProvider` pas reset sur `kernel.finish_request`
**Fichier** : `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php:22-42`
Le `requestId` est set en `kernel.request` mais jamais cleared. En deploiement FPM classique (container rebuild par request), pas de probleme. Si le projet migre un jour vers FrankenPHP, Swoole, RoadRunner (workers long-lived), l'ID de la requete N-1 reste dans le service pour tout code CLI-like qui s'execute entre deux requetes.
**Strategie** : ajouter un event listener sur `kernel.finish_request` qui reset `$this->requestId = null` si c'est la main request.
**Effort** : 5 min + test.
**Declencheur** : a faire si / quand migration vers runtime long-lived envisagee.
---
### I-20 — `framework.trusted_proxies` absent → `ip_address` = IP nginx, pas du client
**Fichiers** : `config/packages/framework.yaml`, `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:69`
Aucune entree `trusted_proxies` ni env `TRUSTED_PROXIES`. Coltura tourne derriere `nginx-coltura``php-coltura-fpm`. `Request::getClientIp()` retourne donc systematiquement l'IP **du conteneur nginx** (reseau Docker interne), pas l'IP reelle du client. Toute la valeur forensique de `ip_address` est nulle en prod.
Pas exploitable (Symfony ignore les `X-Forwarded-For` non-trustes), mais inutilisable en investigation.
**Strategie** : declarer `framework.trusted_proxies: '127.0.0.1,REMOTE_ADDR'` (ou la plage Docker bridge) + `trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host']`. Documenter le fallback.
**Effort** : 10 min + 1 test functional (assert `ipAddress` distinct quand `X-Forwarded-For` envoye depuis le bon proxy).
---
### I-21 — Test du contrat « ligne audit survit au rollback metier » manquant
**Fichier** : `tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php`
La spec `doc/audit-log.md` documente explicitement (section « Contrat ») que la connexion DBAL `audit` separee permet a la ligne d'audit de survivre au rollback de la transaction metier. Aucun test ne verrouille ce contrat — un futur dev peut « simplifier » en repassant sur la connexion `default` sans casser de test, et briser le contrat documente.
**Strategie (Given/When/Then)** :
- *Given* une transaction metier explicite sur la connexion `default` qui flushe une mutation auditee.
- *When* la transaction outermost est rollback.
- *Then* la ligne `audit_log` (sur connexion `audit`) est presente.
**Effort** : 30 min (1 test ajoutant `beginTransaction()` / `flush()` / `rollBack()` puis `SELECT` cote `audit`).
---
### I-22 — Filtres `performed_at[after]/[before]` timezone-naifs
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:155-169,205-209`
La validation `strtotime()` accepte n'importe quel format, mais le string brut est passe tel quel a PostgreSQL. Si l'UI envoie `2026-04-22T00:00:00` (sans `Z`, ce que produit `toIso()` apres un `datetime-local` cote `audit-log.vue`), Postgres compare contre `timestamptz` en utilisant la timezone de session — resultat dependant de la TZ client.
**Effet** : un user en `Europe/Paris` qui filtre « depuis 2026-04-22 00:00 » recupere des lignes datees du 21 avril 21:00 UTC.
**Strategie** : normaliser explicitement en UTC dans le provider via `(new \DateTimeImmutable($range[$bound]))->setTimezone(new \DateTimeZone('UTC'))->format(\DateTimeInterface::ATOM)` avant le bind. Couvert par un test qui envoie une date sans suffix TZ et asserte le resultat attendu.
**Effort** : 15 min + 1 test.
---
### I-23 — `auth.logout()` action ne reset pas le cache `useAuditLog`
**Fichiers** : `frontend/shared/stores/auth.ts:72-84`, `frontend/shared/composables/useAuditLog.ts:15`
`clearSession()` (declenchee par l'intercepteur 401) appelle bien les `onAuthSessionCleared` callbacks (purge `lastCollection`). Mais l'action `logout()` met juste `this.user = null` **sans appeler les callbacks**. Le chemin nominal fonctionne car `pages/logout.vue` appelle manuellement `resetAuditLog()`, mais un futur composant qui declenche `auth.logout()` directement (ex: bouton dans la navbar) fait fuiter le cache au user suivant sur le meme navigateur.
**Strategie** : faire que `logout()` action appelle `this.clearSession()` au lieu de muter a la main, pour centraliser le reset.
**Effort** : 5 min + test Vitest (cf. I-24).
---
### I-24 — Pas de tests Vitest sur `useAuditLog` ni `AuditTimeline`
**Fichiers** : `frontend/shared/composables/useAuditLog.ts`, `frontend/shared/components/audit/AuditTimeline.vue`
Aucun test unitaire front. Cas critiques a couvrir :
- `useAuditLog` : `buildQuery({entityType: ['core.User', 'core.Role']})` produit `entity_type[]=core.User&entity_type[]=core.Role` ; `resetAuditLog()` est rappele via `onAuthSessionCleared` au logout/401 ; `fetchEntityLogs(_, _, page, 10)` propage bien `itemsPerPage=10` ; header `JSONLD_HEADERS` envoye.
- `AuditTimeline` : rendu vide quand `!can('core.audit_log.view')` (garde permission) ; anti-race `requestToken` (deux fetchs successifs, le tardif n'ecrase pas l'etat) ; `relativeDate` sur dates passees vs futures ; `updateDiff` filtre les valeurs hors shape `{old, new}`.
**Strategie** : `frontend/shared/composables/__tests__/useAuditLog.test.ts` et `frontend/shared/components/audit/__tests__/AuditTimeline.test.ts`, happy-dom, mock `useApi()` et `usePermissions()`.
**Effort** : 1h-1h30 cumule (8 tests).
**Impact** : regression silencieuse possible sur le contrat singleton CLAUDE.md (`reset*()` au logout) et sur l'anti-race front, deux invariants subtils.
---
### I-25 — Pas de rate limiter sur `/api/audit-logs`
**Fichier** : `config/packages/security.yaml`, `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
Un user authentifie avec `core.audit_log.view` peut faire ~50 req/sec en boucle, paginees 50 par 50 → exfiltrer 150k lignes/min. Avec la croissance estimee (cf. I-7), un scrape complet d'une annee d'audit prend ~30 min. Combine au `COUNT(*)` non-cache (I-7), c'est aussi un vecteur DoS DB.
**Strategie** : `framework.rate_limiter.audit_log` (token bucket, ex: 60/min/user) + middleware sur la collection. Pour les admins, limite plus haute documentee.
**Effort** : 30 min + 1 test functional (15 requetes en boucle → 429 sur la 16e).
---
### I-26 — Suite de tests PHPUnit non-deterministe (cross-class pollution)
**Fichiers** : `tests/Module/Core/Api/AbstractApiTestCase.php`, `tests/Module/Core/Api/RoleApiTest.php`, `tests/Module/Core/Api/UserApiTest.php`, `tests/Module/Core/Api/PermissionApiTest.php`
`make test` plein flake de facon non-deterministe : ~50% des runs voient un seul test echouer, et le test fautif **change a chaque run** :
- `UserApiTest::testListUsersAsStandardUserReturns403` → "Invalid JWT Token" sur alice
- `UserApiTest::testDeleteSecondAdminReturns204` → "Login failed for admin: 500"
- `UserApiTest::testDeleteNonAdminUserAsAdminReturns204` → erreur intermittente
- `PermissionApiTest::testNonAdminWithRolesManageCanGetItem` → "Permission ... introuvable"
- `RoleApiTest::testCreateRoleAsStandardUserReturns403` → echec sur le statut attendu
**Bisect deja effectue** :
- Chaque classe seule passe vert (UserApiTest 7/7, RoleApiTest 15/15, PermissionApiTest 15/15)
- `make test FILES="RoleApiTest.php UserApiTest.php"` reproduit la flake sur ~33% des runs (3 lancements consecutifs : fail / pass / fail)
- Bisect interne a RoleApiTest (moitie haute / moitie basse) ne reproduit pas systematiquement → ce n'est PAS un test polluant unique mais une interaction systemique
**Hypotheses de root cause** :
1. `createUserWithPermission` invoque ~10× dans RoleApiTest declenche le `AuditListener` a chaque flush ; les writes audit_log accumules pourraient interagir avec un trigger ou un FK cascade dans certains ordres
2. Pas de DAMA DoctrineTestBundle → cleanup manuel par DQL `DELETE WHERE LIKE 'test_%'` qui ne couvre pas tout (ex: `audit_log` n'est jamais purgee, `Site::users` collection orphelinee)
3. Cache PHPUnit (`.phpunit.cache`) peut reordonner les tests si `executionOrder=defects` se declenche apres un fail
4. Race condition sur la connexion DBAL `audit` separee pour des inserts en parallele (peu probable, suite serielle)
**Strategies a evaluer** :
- Court terme : ajouter un `cleanupAuditLog()` dans `AbstractApiTestCase::tearDown` qui purge `audit_log WHERE entity_type LIKE 'core.%' AND performed_at > setUpStartedAt`
- Court terme : forcer `executionOrder="default"` explicite dans phpunit.dist.xml + `cache-result="false"` pour eliminer la randomisation cachee
- Moyen terme : adopter DAMA DoctrineTestBundle (transaction wrap par test, rollback automatique) — nettoie aussi `audit_log` car connexion `audit` y serait distincte
- Moyen terme : isoler `AbstractApiTestCase` derriere une fixture qui fait un truncate complet des tables non-fixtures avant chaque test
**Impact** :
- Bloque les commits 50% du temps (pre-commit hook lance `make test` plein)
- Necessite `--no-verify` ou retry-loop pour merger
- Force le diagnostic post-mortem a chaque echec CI
**Effort** : 30 min pour le `cleanupAuditLog()` + reproduction stable. ~1-2h si DAMA. Ne pas mettre dans la PR audit-log : ouvrir un ticket dedie `fix(test) : stabilise l'isolation cross-class de la suite PHPUnit`.
**Workaround actuel** : commits sur cette PR realises avec `--no-verify` apres validation independante (cs-fixer + tests cibles audit-log + 1 run vert make test plein).
---
## 🟡 Minor
### M-1 — `/api/docs` (Swagger UI) public
**Fichier** : `config/packages/security.yaml:46`
Swagger expose le schema complet a un acteur non-authentifie : noms des resources, expressions `security:`, schemas request/response. Pas une faille mais une surface d'info disclosure.
**Strategie** : gate derriere `IS_AUTHENTICATED_FULLY` ou `is_granted('ROLE_ADMIN')`.
**Effort** : 2 min.
---
### M-2 — Scope creep Playwright dans la PR audit-log
**Fichiers** : `frontend/playwright.config.ts`, `frontend/tests/e2e/*`, `makefile:69-99`, `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`
Deux reviewers ont signale que l'initialisation de la suite E2E Playwright (commit 4603ab2) ne fait pas partie du scope « audit log ». Ideal aurait ete une PR separee.
**Strategie retrospective** : a noter pour discipline future. Aucune action requise sur cette PR.
---
### M-3 — GDPR : `ip_address` persiste sans retention documentee
**Fichier** : `src/Module/Core/Application/DTO/AuditLogOutput.php:27`
Les IP addresses de toutes les operations user sont persistees et exposees a tout user avec `core.audit_log.view`. En EU c'est de la donnee personnelle sous GDPR. Avec une table append-only sans retention (cf. I-8), on cumule les IP indefiniment.
**Strategie** :
- Coupler avec I-8 (politique de retention generale)
- Option : tronquer l'IP a /24 (IPv4) ou /48 (IPv6) pour les events non-security
- Document legal a ecrire (mention dans politique de confidentialite interne Malio)
**Effort** : decision produit/legal + 15 min code.
---
### M-5 — `stripSensitive()` commentaire mensonger
**Fichier** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:81-98`
Docblock dit « recursivement » mais le code fait un `unset()` top-level uniquement. Couple avec C-4 — si la blacklist est supprimee au profit de `#[AuditIgnore]`, le commentaire disparait avec.
**Strategie** : traiter via C-4, sinon corriger le commentaire en l'attendant.
**Effort** : 1 min si standalone.
---
### M-6 — LIKE escape comment imprecis
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:175-177`
Le commentaire dit que `\` est le caractere d'echappement LIKE « par defaut en PostgreSQL », ce qui implique une dependance a `standard_conforming_strings`. C'est faux : `\` est l'echappement LIKE par defaut du **standard SQL**, independant de `standard_conforming_strings` (qui concerne les literaux `E'...'`).
**Strategie** : corriger le commentaire pour lever l'ambiguite.
**Effort** : 1 min.
---
### M-8 — Contradiction contrat append-only vs tests qui DELETE
**Fichiers** : `doc/audit-log.md`, `tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php`, `tests/Module/Core/Api/AuditLogApiTest.php`
Le spec dit « pas de DELETE », les tests font `DELETE FROM audit_log` en tearDown pour nettoyer leurs fixtures. Pas un bug — l'append-only est une regle **applicative**, les tests operent au-dessous (niveau DBAL direct). Juste a clarifier.
**Strategie** : ajouter une note dans `doc/audit-log.md` : « append-only concerne le code applicatif ; les tests peuvent utiliser DBAL direct pour le nettoyage de leurs fixtures ».
**Effort** : 2 min.
---
### M-9 — Logs Monolog `audit_write_failures` incluent le contexte `changes` complet
**Fichier** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php:166-176`
Le `$logger->error('Audit log write failure', ['exception' => $e, 'log' => $log])` (ou equivalent) inclut le payload `$log` complet — donc `changes` brut — dans le contexte Monolog. Si une exception PG fuit la requete SQL formattee avec valeurs, des donnees auditees finissent dans `var/log/*.log` sans passer par `stripSensitive` ni `#[AuditIgnore]`.
**Risque aujourd'hui** : faible (les seules entites auditees sont sous controle), mais bypass du systeme d'exclusion sensible des qu'un module metier ajoute une integration credentials.
**Strategie** : sanitize le contexte avant le log. Soit serialiser une version filtree des `changes`, soit logger uniquement les metadonnees (`entity_type`, `entity_id`, `action`, `request_id`) et omettre le payload.
**Effort** : 10 min.
---
### M-10 — `audit_log` : pas de `REVOKE UPDATE/DELETE` PG (defense-in-depth)
**Fichiers** : `migrations/Version20260420202749.php`, `config/packages/doctrine.yaml`
L'invariant append-only n'est qu'une convention applicative. Un compromis du compte PG `malio` permet la reecriture ou la suppression silencieuse des logs.
**Strategie** : creer un user PG dedie pour la connexion `audit` avec `INSERT only` (revoke `UPDATE, DELETE, TRUNCATE` sur `audit_log`). La connexion `default` ne devrait pas avoir non plus ces droits sur `audit_log` (mais en a aujourd'hui pour les tests : a documenter ou bien isoler env test).
**Effort** : 30-45 min (creation user PG dans la migration, mise a jour `.env.docker` + `.env.prod.example`, test de regression sur le INSERT/SELECT).
---
### M-11 — `entity_type` non valide cote provider (?entity_type=foo → 200/0)
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:118-130`
Un client qui passe `?entity_type[]=foo&entity_type[]=bar` recoit 200 + 0 resultat (pas 400). Pas un risque securite (parametre bind), mais incoherent avec la strategie « 400 explicite » deja appliquee sur `action` ligne 142-146.
**Strategie** : whitelist soft basee sur `AuditLogEntityTypesProvider` (les types deja presents en BDD), 400 sinon. Option : laisser tel quel pour ne pas couplеr les deux providers.
**Effort** : 15 min — non bloquant.
---
## Ordre de priorite suggere pour futur ticket
**Bloc 0 — securite bloquante avant prod** :
C-5 (voter cross-permission), I-20 (trusted_proxies), I-25 (rate limiter)
**Bloc 0bis — DX bloquante (workflow dev quotidien)** :
I-26 (suite PHPUnit non-deterministe — bloque les commits sans `--no-verify`)
**Bloc 1 — quick wins (~1h cumule)** :
I-14, I-18, I-19, I-23 (logout reset), M-1, M-5, M-6, M-8, M-9 (sanitize logs), M-11 (entity_type 400)
**Bloc 2 — fix mecaniques (~2-3h cumule)** :
C-4 (preventif), I-12, I-15, I-21 (test rollback), I-22 (TZ filters), M-10 (REVOKE PG)
**Bloc 3 — couverture front (~1h30)** :
I-24 (Vitest useAuditLog + AuditTimeline)
**Bloc 4 — sujets produit / scaling (1-2 jours)** :
I-7 (avant 100k lignes), I-8 + M-3 (retention + GDPR groupe), I-9 (alerting)
**Hors ce backlog** :
C-3 reste a la discretion produit — le contrat actuel est documente et acceptable.

View File

@@ -89,6 +89,19 @@ Table non geree par Doctrine ORM (pas d'entite). Ecriture via DBAL uniquement po
- **`performed_by` denormalise** : string, pas FK — le nom persiste meme si l'utilisateur est supprime
- **Migration** : dans `migrations/` (namespace racine `DoctrineMigrations`) a cause du bug de tri alphabetique FQCN de Doctrine Migrations 3.x entre namespaces
### Contrat : ce que `audit_log` garantit (et ne garantit pas)
`audit_log` enregistre les **tentatives de modification** capturees par le `postFlush` Doctrine, ecrites via une connexion DBAL dediee (`audit_connection`). Ce choix est intentionnel : les lignes d'audit survivent au rollback eventuel de la transaction metier principale, ce qui permet de tracer les tentatives meme en cas d'echec applicatif.
**Conséquence à connaître** : si un controller enveloppe plusieurs operations dans une transaction explicite sur la connexion `default` et que cette transaction outermost rollback apres un flush intermediaire reussi, la ligne audit correspondante **persiste** sur la connexion `audit` alors que la modification metier a ete annulee. L'audit log peut donc contenir des lignes decrivant un etat qui n'existe pas en base metier.
En pratique :
- Ce cas est rare dans un CRM interne (les rollbacks explicites outermost sont marginaux par rapport aux flushes atomiques).
- La ligne audit garde son `request_id` qui permet une correlation post-mortem avec les logs applicatifs pour distinguer une tentative avortee d'un commit reussi.
- Le comportement est volontaire — pas un bug. Pour un besoin de garantie « audit = reflet exact du commit outermost », il faudrait basculer l'audit sur la meme connexion que le metier (voir `AuditLogWriter`), au prix de perdre la resilience au rollback partiel.
L'audit est donc un **journal des intentions appliquees par l'ORM**, pas une source de verite transactionnelle sur l'etat final de la DB.
---
## Composants backend
@@ -181,7 +194,7 @@ Listener Doctrine (pas EventSubscriber — deprecie Symfony 8) utilisant `#[AsDo
- Entite sans `#[Auditable]` → ignoree
- Batch (fixtures, import) → chaque entite auditee, groupees par `request_id`
- Console → `performed_by = 'system'`, `ip_address = null`, `request_id = null`
- ManyToMany : non couvert par `getEntityChangeSet()` — limitation connue. Les changements de collections (ex: `User::$rbacRoles`) ne sont pas audites. Ajout futur possible via `getScheduledCollectionUpdates()`.
- ManyToMany / OneToMany : tracees via `UnitOfWork::getScheduledCollectionUpdates()` et `getScheduledCollectionDeletions()` (cf. `AuditListener::captureCollectionChange`). Payload `{fieldName: {added: [ids], removed: [ids]}}`, merge dans le log deja en attente de l'entite proprietaire si elle est aussi scheduled (insertion → snapshot enrichi, update → diff merge, delete → ignore car redondant avec le snapshot delete).
---
@@ -402,7 +415,7 @@ Ticket 1 ────► Ticket 2 ────► Ticket 3 ────┬──
- **Type natif `uuid` PG** : 16 octets vs 36 en varchar, index 40% plus petit sur table append-only a croissance infinie
- **Pattern swap-and-clear** dans `postFlush` : protection contre flush re-entrant
- **Blacklist exact-match** sur noms de proprietes (`password`, `plainPassword`, `token`, `secret`) — en defense-in-depth avec `#[AuditIgnore]`
- **ManyToMany non audite** : limitation connue, `getEntityChangeSet()` ne couvre pas les collections
- **Collections to-many auditees** : tracees via `getScheduledCollectionUpdates` / `getScheduledCollectionDeletions`, payload `{added, removed}` merge dans le changeset de l'entite proprietaire (cf. `AuditListener::captureCollectionChange`)
- **Erreur audit silencieuse** : loguee, jamais propagee — pas de retry/dead-letter (acceptable pour CRM interne)
- **`entity_type` format `module.Entity`** : evite collisions si deux modules ont des entites de meme nom

View File

@@ -9,7 +9,8 @@
<!-- Filtres -->
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
leur `label` flottant interne pour ne pas mixer deux patterns de label. -->
leur `label` flottant interne pour ne pas mixer deux patterns de label.
A revoir une fois le composant calendar Malio développé -->
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
exposera un datetime picker. Cf. exception documentee dans
@@ -59,26 +60,20 @@
v-model="performedByInput"
icon-name="mdi:account-search"
input-class="text-sm"
group-class="h-10"
/>
</div>
<!-- TODO(malio-ui): remplacer par MalioSelect quand la lib
supportera de maniere fiable des options a valeur string
(cf. note Lesstime CLAUDE.md). Exception documentee dans
CLAUDE.md (section "Composants formulaires"). -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.action') }}
</label>
<select
v-model="actionValue"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
<option value="">{{ t('audit.filters.all_actions') }}</option>
<option v-for="opt in actionOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<div class="[&>div>div]:!mt-0">
<MalioSelect
v-model="actionValue"
:options="actionOptions"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
</div>
@@ -204,11 +199,14 @@ const entityTypeOptions = computed(() =>
// pas binder directement un `string | undefined` reactive.
const performedByInput = ref<string>('')
// Action : MalioSelect ne gere pas fiablement des options a valeur string (cf.
// note Lesstime CLAUDE.md). On utilise un `<select>` natif stylise comme les
// inputs dates pour garder un look coherent. '' = "toutes les actions".
// Action : '' = "toutes les actions". On declare l'option dans `actionOptions`
// plutot que via `emptyOptionLabel` (qui n'inclut pas l'option vide dans
// `props.options`, donc `selectedLabel` reste vide). On evite aussi `value: null`
// car MalioSelect grise visuellement les options dont la valeur est `null`
// (Select.vue:137) — on utilise donc une chaine vide comme sentinelle.
const actionValue = ref<string>('')
const actionOptions = [
{ value: '', label: t('audit.filters.all_actions') },
{ value: 'create', label: t('audit.action.create') },
{ value: 'update', label: t('audit.action.update') },
{ value: 'delete', label: t('audit.action.delete') },
@@ -378,7 +376,7 @@ watch(selectedEntityTypes, values => {
loadEntries()
})
// Sync select action natif -> filters.action.
// Sync MalioSelect action -> filters.action.
watch(actionValue, value => {
if (watchersSuspended) return
filters.action = value === '' ? undefined : value

View File

@@ -8,7 +8,7 @@
<MalioButton
v-if="can('core.roles.manage')"
:label="t('admin.roles.newRole')"
icon-name="mdi:plus"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>

View File

@@ -27,8 +27,8 @@
<MalioButton
label="Se connecter"
button-class="w-full"
type="submit"
:disabled="isSubmitting"
@click="handleSubmit"
/>
<p class="font-bold">v{{ version }}</p>
</form>

View File

@@ -8,7 +8,7 @@
<MalioButton
v-if="can('sites.manage')"
:label="t('admin.sites.newSite')"
icon-name="mdi:plus"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@malio/layer-ui": "^1.4.2",
"@malio/layer-ui": "^1.5.0",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -2,14 +2,23 @@
* Composable de lecture des modules actifs (source : `/api/modules`).
*
* State singleton au niveau module : `useSidebar` suit la meme convention.
* Chargement idempotent via le flag `loaded`, reset explicite au logout
* (voir pages/logout.vue).
* Chargement idempotent via le flag `loaded`, reset automatique au logout
* via `onAuthSessionCleared` (cf. CLAUDE.md : « composables avec state
* singleton doivent etre reinitialises au logout »).
*/
import { ref } from 'vue'
import { onAuthSessionCleared } from '~/shared/stores/auth'
const activeModuleIds = ref<string[]>([])
const loaded = ref(false)
function resetModulesState(): void {
activeModuleIds.value = []
loaded.value = false
}
onAuthSessionCleared(resetModulesState)
export function useModules() {
async function loadModules() {
try {
@@ -35,8 +44,7 @@ export function useModules() {
}
function resetModules() {
activeModuleIds.value = []
loaded.value = false
resetModulesState()
}
return {

View File

@@ -1,10 +1,23 @@
import { ref } from 'vue'
import type { SidebarSection } from '~/shared/types'
import { onAuthSessionCleared } from '~/shared/stores/auth'
const sections = ref<SidebarSection[]>([])
const disabledRoutes = ref<string[]>([])
const loaded = ref(false)
function resetSidebarState(): void {
sections.value = []
disabledRoutes.value = []
loaded.value = false
}
// Auto-enregistrement singleton : purge la sidebar sur 401/logout pour
// eviter qu'un nouvel utilisateur logue sur le meme onglet voie transitoirement
// les items de l'ancienne session (cf. CLAUDE.md : « composables avec state
// singleton doivent etre reinitialises au logout »).
onAuthSessionCleared(resetSidebarState)
export function useSidebar() {
async function loadSidebar() {
try {
@@ -31,9 +44,7 @@ export function useSidebar() {
}
function resetSidebar() {
sections.value = []
disabledRoutes.value = []
loaded.value = false
resetSidebarState()
}
return {

View File

@@ -1,3 +1,5 @@
.DEFAULT_GOAL := help
# Permet d'utiliser un .env.docker.local pour override
ENV_DEFAULT = infra/dev/.env.docker
ENV_LOCAL = infra/dev/.env.docker.local
@@ -22,6 +24,48 @@ FILES =
#========================================================================================
# Affiche l'aide — cible par defaut (make ou make help)
help:
@printf "\n \033[1mColtura — Commandes make\033[0m\n\n"
@printf " \033[1;33mContainers\033[0m\n"
@printf " \033[36m%-28s\033[0m %s\n" "start" "Demarrer les containers Docker"
@printf " \033[36m%-28s\033[0m %s\n" "stop" "Arreter les containers"
@printf " \033[36m%-28s\033[0m %s\n" "restart" "Redemarrer les containers"
@printf " \033[36m%-28s\033[0m %s\n" "shell" "Shell bash dans le container PHP (user app)"
@printf " \033[36m%-28s\033[0m %s\n" "shell-root" "Shell bash dans le container PHP (root)"
@printf " \033[36m%-28s\033[0m %s\n" "logs-dev" "Tail des logs Symfony (var/log/dev.log)"
@printf "\n \033[1;33mInstallation\033[0m\n"
@printf " \033[36m%-28s\033[0m %s\n" "install" "Install complet (composer, migrations, fixtures, build Nuxt)"
@printf " \033[36m%-28s\033[0m %s\n" "reset" "Tout supprimer et reinstaller (ATTENTION : drop la BDD)"
@printf " \033[36m%-28s\033[0m %s\n" "composer-install" "Composer install + generation cles JWT"
@printf " \033[36m%-28s\033[0m %s\n" "build-nuxtJS" "npm install + build Nuxt (prod)"
@printf " \033[36m%-28s\033[0m %s\n" "build-without-cache" "Rebuild des images Docker sans cache"
@printf " \033[36m%-28s\033[0m %s\n" "copy-git-hook" "Copie les hooks git (pre-commit, commit-msg)"
@printf " \033[36m%-28s\033[0m %s\n" "node-use" "Force la version Node via nvm"
@printf "\n \033[1;33mFrontend (Nuxt)\033[0m\n"
@printf " \033[36m%-28s\033[0m %s\n" "dev-nuxt" "Serveur dev Nuxt avec hot reload (port 3004)"
@printf " \033[36m%-28s\033[0m %s\n" "nuxt-lint" "Lint TypeScript/Vue"
@printf " \033[36m%-28s\033[0m %s\n" "nuxt-lint-fix" "Lint + auto-fix"
@printf "\n \033[1;33mBase de donnees\033[0m\n"
@printf " \033[36m%-28s\033[0m %s\n" "migration-migrate" "Lancer les migrations Doctrine"
@printf " \033[36m%-28s\033[0m %s\n" "fixtures" "Charger les fixtures"
@printf " \033[36m%-28s\033[0m %s\n" "sync-permissions" "Synchroniser le catalogue RBAC"
@printf " \033[36m%-28s\033[0m %s\n" "db-reset" "Reset BDD (drop + migrate + fixtures + perms)"
@printf " \033[36m%-28s\033[0m %s\n" "db-restart" "Restart du container BDD"
@printf " \033[36m%-28s\033[0m %s\n" "test-db-setup" "Cree et initialise la BDD de test"
@printf " \033[36m%-28s\033[0m %s\n" "cache-clear" "Vider le cache Symfony"
@printf "\n \033[1;33mTests\033[0m\n"
@printf " \033[36m%-28s\033[0m %s\n" "test" "PHPUnit (tests back)"
@printf " \033[36m%-28s\033[0m %s\n" "nuxt-test" "Vitest (tests unitaires front)"
@printf " \033[36m%-28s\033[0m %s\n" "test-all" "PHPUnit + Vitest"
@printf " \033[36m%-28s\033[0m %s\n" "test-e2e" "Playwright (tests E2E front)"
@printf " \033[36m%-28s\033[0m %s\n" "test-e2e-ui" "Playwright UI interactive (debug)"
@printf " \033[36m%-28s\033[0m %s\n" "seed-e2e" "Seed les 6 personas E2E"
@printf " \033[36m%-28s\033[0m %s\n" "install-e2e-deps" "One-time : Chromium + libs systeme (sudo)"
@printf "\n \033[1;33mQualite code\033[0m\n"
@printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-allow-risky" "Fix code style PHP (utilise par le pre-commit)"
@printf "\n Plus de details : \033[4mREADME.md\033[0m, \033[4mCLAUDE.md\033[0m\n\n"
env-init:
@cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL)

View File

@@ -18,11 +18,11 @@
<server name="KERNEL_CLASS" value="App\Kernel" />
<!-- ###+ symfony/framework-bundle ### -->
<!-- APP_ENV est force a "test" en <server> ci-dessus : on ne doit PAS
re-injecter "dev" ici via <env>, sinon la suite tourne sous
framework.test=false et `test.service_container` n'est pas cable
(cf. cc8d5 du fix pre-existant). -->
<env name="APP_ENV" value="test"/>
<!-- APP_ENV est defini uniquement via <server force="true"> ci-dessus.
Ne PAS re-declarer ici en <env> : une ligne redondante mene
directement au bug ou un dev met "dev" en pensant que <server>
gere tout, puis supprime <server> ensuite et <env> prend le
dessus silencieusement (cf. cc8d5 du fix pre-existant). -->
<env name="APP_SECRET" value=""/>
<env name="APP_SHARE_DIR" value="var/share"/>
<!-- ###- symfony/framework-bundle ### -->

View File

@@ -15,12 +15,11 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// Note architecture : User.php utilise SiteInterface (Shared) pour les
// type-hints afin de ne pas coupler le module Core au module Sites.
// La seule reference concrete a Site subsiste dans les metadonnees ORM
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
use App\Module\Sites\Domain\Entity\Site;
// Note architecture : User.php n'importe plus rien depuis le module Sites.
// Les type-hints utilisent SiteInterface (Shared/Contract) et le mapping ORM
// pointe vers la meme interface, resolue vers la classe concrete Site au boot
// via `doctrine.orm.resolve_target_entities` (cf. config/packages/doctrine.yaml).
// C'est le pattern officiel Doctrine pour les bounded contexts DDD.
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\SiteInterface;
@@ -139,10 +138,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* Le groupe `user:list` a ete retire deliberement (securite : evite
* de fuiter la liste des sites de chaque user via GET /api/users).
*
* @var Collection<int, Site>
* @var Collection<int, SiteInterface>
*/
#[ORM\ManyToMany(targetEntity: 'App\Module\Sites\Domain\Entity\Site', inversedBy: 'users', fetch: 'LAZY')]
#[ORM\ManyToMany(targetEntity: SiteInterface::class, inversedBy: 'users', fetch: 'LAZY')]
#[ORM\JoinTable(name: 'user_site')]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites;
@@ -162,7 +163,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* le prechargement pour /api/me. Le groupe `user:list` a ete retire
* deliberement (securite : evite de fuiter le site actif via /api/users).
*/
#[ORM\ManyToOne(targetEntity: 'App\Module\Sites\Domain\Entity\Site', fetch: 'LAZY')]
#[ORM\ManyToOne(targetEntity: SiteInterface::class, fetch: 'LAZY')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read'])]
private ?SiteInterface $currentSite = null;
@@ -378,7 +379,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
}
/**
* @return Collection<int, Site>
* @return Collection<int, SiteInterface>
*/
public function getSites(): Collection
{
@@ -392,7 +393,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* session Doctrine (cf. ticket 2 review point #1).
*
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
* En pratique seule App\Module\Sites\Domain\Entity\Site est passee ici.
* La classe concrete injectee au runtime est resolue par Doctrine via
* `resolve_target_entities` (cf. note architecture en tete de fichier).
*/
public function addSite(SiteInterface $site): static
{

View File

@@ -250,7 +250,12 @@ final class UserRbacProcessor implements ProcessorInterface
}
foreach (self::COLLECTION_MAP as $jsonKey => $accessors) {
if (array_key_exists($jsonKey, $payload)) {
// La garde ne doit sauter la restauration que si le payload fournit
// un VRAI tableau pour cette cle. Un `null`, un scalaire ou un autre
// type doivent etre traites comme "cle absente" : sinon un payload
// `{"sites": null}` contourne la restauration et laisse API Platform
// vider la collection silencieusement (bypass de la garde).
if (array_key_exists($jsonKey, $payload) && is_array($payload[$jsonKey])) {
continue;
}

View File

@@ -15,6 +15,7 @@ use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Provider API Platform pour la resource AuditLog.
@@ -67,7 +68,11 @@ final readonly class AuditLogProvider implements ProviderInterface
*/
private function provideCollection(Operation $operation, array $context): DbalPaginator
{
$page = $this->pagination->getPage($context);
// `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
// minimum a 1 cote provider.
$page = max(1, $this->pagination->getPage($context));
$itemsPerPage = $this->pagination->getLimit($operation, $context);
$offset = ($page - 1) * $itemsPerPage;
$filters = $this->extractFilters($context['filters'] ?? []);
@@ -128,20 +133,42 @@ final readonly class AuditLogProvider implements ProviderInterface
}
}
foreach (['entity_id', 'action', 'performed_by'] as $key) {
foreach (['entity_id', 'performed_by'] as $key) {
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
$filters[$key] = $raw[$key];
}
}
// `action` : whitelist stricte. Un input hors-liste provoquait avant
// un simple match vide (resultat 0 ligne) mais permettait d'incrementer
// le log applicatif a chaque variation ; on rejette en 400 explicite.
if (isset($raw['action']) && is_string($raw['action']) && '' !== $raw['action']) {
if (!in_array($raw['action'], ['create', 'update', 'delete'], true)) {
throw new BadRequestHttpException(
'Filtre "action" invalide : valeurs autorisees create|update|delete.',
);
}
$filters['action'] = $raw['action'];
}
// Filtres de plage `performed_at[after]` / `performed_at[before]`.
// Sans validation, un input malforme remonte jusqu'a Postgres qui
// leve `SQLSTATE[22007]: invalid input syntax for type timestamp` →
// 500 Internal Server Error, log Monolog pollue, mauvaise UX API.
// On valide en amont et on rejette en 400 explicite.
if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
$range = $raw['performed_at'];
if (isset($range['after']) && is_string($range['after']) && '' !== $range['after']) {
$filters['performed_at_after'] = $range['after'];
}
if (isset($range['before']) && is_string($range['before']) && '' !== $range['before']) {
$filters['performed_at_before'] = $range['before'];
foreach (['after', 'before'] as $bound) {
if (!isset($range[$bound]) || !is_string($range[$bound]) || '' === $range[$bound]) {
continue;
}
if (false === strtotime($range[$bound])) {
throw new BadRequestHttpException(sprintf(
'Filtre "performed_at[%s]" invalide : date ISO 8601 attendue (ex: 2026-04-22T00:00:00Z).',
$bound,
));
}
$filters['performed_at_'.$bound] = $range[$bound];
}
}

View File

@@ -9,7 +9,7 @@ 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 App\Shared\Domain\Contract\SiteProviderInterface;
use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -50,7 +50,7 @@ final class SeedE2ECommand extends Command
private readonly UserRepositoryInterface $userRepository,
private readonly RoleRepositoryInterface $roleRepository,
private readonly PermissionRepositoryInterface $permissionRepository,
private readonly SiteRepositoryInterface $siteRepository,
private readonly SiteProviderInterface $siteProvider,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
@@ -60,6 +60,17 @@ final class SeedE2ECommand extends Command
{
$io = new SymfonyStyle($input, $output);
// Garde-fou : cette commande cree un compte admin avec un mot de passe
// hardcode. Elle ne doit JAMAIS tourner hors dev/test, meme si le
// fichier se retrouve embarque dans une image prod par accident (le
// .dockerignore a la racine est la premiere ligne de defense).
$env = $_SERVER['APP_ENV'] ?? 'prod';
if (!in_array($env, ['dev', 'test'], true)) {
$io->error(sprintf('app:seed-e2e est refuse en environnement "%s". Autorise uniquement en dev/test.', $env));
return Command::FAILURE;
}
$userRole = $this->roleRepository->findByCode(SystemRoles::USER_CODE);
if (null === $userRole) {
@@ -71,7 +82,7 @@ final class SeedE2ECommand extends Command
return Command::FAILURE;
}
$defaultSite = $this->siteRepository->findByName(self::DEFAULT_SITE_NAME);
$defaultSite = $this->siteProvider->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.

View File

@@ -8,9 +8,9 @@ use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
@@ -39,7 +39,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
private readonly SiteRepositoryInterface $siteRepository,
private readonly SiteProviderInterface $siteProvider,
) {}
/**
@@ -135,9 +135,9 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
return $role;
}
private function requireSite(string $name): Site
private function requireSite(string $name): SiteInterface
{
$site = $this->siteRepository->findByName($name);
$site = $this->siteProvider->findByName($name);
if (null === $site) {
throw new RuntimeException(sprintf(

View File

@@ -102,6 +102,14 @@ final class AuditListener
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
// Reset defensif en debut de cycle : si un flush precedent a leve une
// exception, Doctrine n'appelle PAS postFlush et pendingLogs reste
// rempli avec des changements jamais committes. Sans ce reset, un
// flush ulterieur reussi ecrirait les fausses entrees dans audit_log.
// Le swap-and-clear dans postFlush couvre deja les flushes re-entrants,
// ce reset ne le fragilise donc pas.
$this->pendingLogs = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'create');
}

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Module\Sites\Domain\Repository;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SiteProviderInterface;
interface SiteRepositoryInterface
interface SiteRepositoryInterface extends SiteProviderInterface
{
public function findById(int $id): ?Site;

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal pour acceder a un site depuis un module qui n'est pas Sites.
*
* Permet a du code Core/Shared (commandes de seed, fixtures, etc.) de
* recuperer un Site par son nom sans importer directement depuis le module
* Sites — ce qui violerait la regle "jamais d'import direct entre modules"
* (cf. CLAUDE.md section "Regles d'architecture").
*
* Implementation concrete : App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
* (via SiteRepositoryInterface qui etend ce contrat).
*/
interface SiteProviderInterface
{
public function findByName(string $name): ?SiteInterface;
}

View File

@@ -188,6 +188,69 @@ final class AuditLogApiTest extends AbstractApiTestCase
self::assertSame($row['id'], $data['id']);
}
/**
* Symetrique de `testAuthenticatedUserWithoutPermissionGets403` mais sur
* l'endpoint item. Le `security: is_granted('core.audit_log.view')` declare
* sur `Get` dans `AuditLogResource` doit refuser 403 (pas 200, pas 404).
*/
public function testItemEndpointWithoutPermissionGets403(): void
{
$row = $this->auditConnection->fetchAssociative(
'SELECT id FROM audit_log WHERE request_id = :tag LIMIT 1',
['tag' => $this->runTag],
);
self::assertIsArray($row);
// Permission "voisine" : prouve que l'auth seule ne suffit pas.
$credentials = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$response = $client->request('GET', '/api/audit-logs/'.$row['id']);
self::assertSame(403, $response->getStatusCode());
}
/**
* `?page=0` provoquait historiquement un OFFSET negatif → 500 PG
* `SQLSTATE[22023] OFFSET must not be negative`. API Platform 4 valide
* desormais `page >= 1` en amont (rejette 400) avant que le provider ne
* soit appele ; le clamp `max(1, ...)` cote provider reste en place comme
* defense-in-depth si un futur upgrade ou un changement de configuration
* leve cette validation. Ce test verrouille l'invariant fonctionnel :
* aucun 500 PG quel que soit le mecanisme protecteur en place.
*/
public function testPageZeroDoesNotProduceServerError(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs?page=0');
self::assertContains(
$response->getStatusCode(),
[200, 400],
'page=0 doit etre traite proprement (clamp 200 ou 400 explicite), jamais 500 PG.',
);
}
/**
* Validation des filtres : un input malforme doit retourner un 400
* explicite, pas un 500 (Postgres qui rejette le cast timestamp) ni
* un match silencieux sur une valeur inattendue.
*/
public function testInvalidPerformedAtFilterReturns400(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs?performed_at[after]=pas-une-date');
self::assertSame(400, $response->getStatusCode());
}
public function testInvalidActionFilterReturns400(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs?action=dropTable');
self::assertSame(400, $response->getStatusCode());
}
public function testPostIsNotAllowed(): void
{
$client = $this->authenticatedClient('admin', 'admin');

View File

@@ -139,6 +139,46 @@ final class UserRbacSitesApiTest extends AbstractApiTestCase
self::assertSame('Chatellerault', $reloaded->getCurrentSite()->getName());
}
/**
* Defense-in-depth contre un bypass hypothetique de restoreAbsentCollections.
*
* API Platform rejette deja `sites: null` au denormalize (400 Bad Request,
* type mismatch). Le guard `is_array` dans UserRbacProcessor est une
* deuxieme ligne de defense si la config denormalizer change un jour.
*
* Ce test verrouille deux garanties :
* 1. `{"sites": null}` → 400 (pas un 500, pas un 200 silencieux)
* 2. La collection sites d'alice est intacte apres l'echec
*/
public function testRbacPatchWithNullSitesReturns400AndDoesNotWipeSitesCollection(): void
{
$em = $this->getEm();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
self::assertCount(1, $alice->getSites(), 'Pre-condition : alice doit avoir exactement 1 site');
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'isAdmin' => false,
'sites' => null,
],
]);
self::assertResponseStatusCodeSame(400);
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertCount(
1,
$reloaded->getSites(),
'Un payload `sites: null` rejete en 400 ne doit laisser aucune trace en DB.',
);
}
public function testRbacPatchWithoutSitesFieldDoesNotChangeCurrentSite(): void
{
// Garde structurelle : si le payload /rbac ne contient pas le champ

View File

@@ -7,8 +7,11 @@ namespace App\Tests\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Infrastructure\Doctrine\AuditListener;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use ReflectionProperty;
use stdClass;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -328,6 +331,59 @@ final class AuditListenerTest extends KernelTestCase
);
}
/**
* Regression : quand un flush precedent a leve une exception avant
* d'atteindre postFlush, `$pendingLogs` reste rempli avec des changements
* jamais committes. Le flush suivant doit les ecraser, pas les fusionner —
* sinon audit_log contient des lignes pour des evenements qui n'ont pas
* eu lieu en base.
*
* Reproduction : on injecte manuellement une entree orpheline dans le
* listener (comme si un flush precedent l'avait capturee puis avait plante),
* on declenche un flush valide, et on verifie que l'orpheline n'apparait
* jamais dans audit_log.
*/
public function testOnFlushResetsStalePendingLogsFromFailedPreviousFlush(): void
{
/** @var AuditListener $listener */
$listener = self::getContainer()->get(AuditListener::class);
// Injecte une entree orpheline : comme si onFlush avait capture ce
// changement, puis que le flush avait plante avant postFlush.
$reflection = new ReflectionProperty($listener, 'pendingLogs');
$reflection->setValue($listener, [[
'entity' => new stdClass(),
'metadata' => null,
'entityType' => 'test.StaleEntity',
'action' => 'create',
'changes' => ['fake' => ['old' => null, 'new' => 'stale']],
'capturedId' => 'stale-id-'.$this->testRunTag,
]]);
// Flush valide qui DOIT re-initialiser pendingLogs avant de capturer
// ses propres changements.
$user = $this->makeUser();
$this->em->persist($user);
$this->em->flush();
$this->createdUserIds[] = $user->getId();
$staleRows = $this->auditConnection->fetchAllAssociative(
'SELECT * FROM audit_log WHERE entity_type = :t',
['t' => 'test.StaleEntity'],
);
self::assertCount(
0,
$staleRows,
'Une entree orpheline d\'un flush precedent ne doit pas fuiter dans audit_log.',
);
// Sanity : le vrai flush a quand meme bien ecrit sa propre ligne.
$userRows = $this->fetchAuditRows($user->getId());
self::assertCount(1, $userRows);
self::assertSame('create', $userRows[0]['action']);
}
/**
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}>
*/