Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions daeb8b3003 chore: bump version to v0.1.69
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 19s
2026-06-03 09:14:20 +00:00
matthieu 9c311cb58b fix(tests) : fiabilise la suite PHPUnit contre la derive d'horloge (ERP-98) (#47)
Auto Tag Develop / tag (push) Successful in 11s
## Probleme (ERP-98)

Suite PHPUnit flaky ~1 run sur 2 -> hook pre-commit qui plante, recours au `--no-verify` sur des commits sains.

## Cause racine

Une seule cause commune : l'horloge `CLOCK_REALTIME` du conteneur n'est pas monotone sous WSL2/Docker (saut arriere sous charge), alors que le code et les tests supposaient une horloge stable.

- **401 « Invalid JWT Token »** : lexik validait `iat`/`nbf`/`exp` avec `clock_skew: 0` (`LooseValidAt(.., PT0S)` cote lcobucci). Un recul d'horloge apres `/login_check` rend le token « dans le futur » -> rejet.
- **Horodatages « meme seconde »** (`1780402904 > 1780402904`) : colonnes `TIMESTAMP(0)` + `sleep(1)` reel. L'ecart floor-seconde n'est nul que si l'horloge recule.

## Correctifs

| Fichier | Modif |
|---|---|
| `config/packages/lexik_jwt_authentication.yaml` | `clock_skew: 15` -> tolere la derive (benefice prod aussi) |
| `TimestampableBlamableSubscriber` | injection `ClockInterface` (prod inchange via NativeClock) |
| `CategoryTimestampableBlamableTest` | `ClockSensitiveTrait` + MockClock fige/avance, suppression des `sleep(1)` |
| `TimestampableBlamableSubscriberTest` | MockClock injecte dans les 4 instanciations |

**Subtilite** : `mockTime()` cree un MockClock en UTC ; les colonnes `TIMESTAMP WITHOUT TIME ZONE` round-trippent via le fuseau PHP (Europe/Paris) -> decalage 2h. Le mock est seede dans le fuseau par defaut (comme le NativeClock prod).

## Verifications

- `make test` : **464 tests verts**, 0 echec / 0 erreur
- Test timestamp cible : **5/5 deterministe** (et plus rapide, sleeps reels supprimes)
- `make php-cs-fixer-allow-risky` : 0 fichier a corriger
- Deprecations/notices PHPUnit preexistantes (hors perimetre)

Pas de migration, pas de changement front, RBAC intact.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #47
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 09:14:09 +00:00
gitea-actions 5a33815584 chore: bump version to v0.1.68
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 39s
2026-06-03 09:09:46 +00:00
matthieu 052a39092b fix(audit) : libellés i18n des types d'entité + garde-fou (ERP-99) (#48)
Auto Tag Develop / tag (push) Successful in 8s
## Contexte
Le filtre « Type d'entité » de l'audit-log est dynamique (`GET /audit-log-entity-types`). Toute entité `#[Auditable]` dont la clé i18n manquait s'affichait en **type technique brut** (ex: `commercial.Client`), le rendu retombant **silencieusement** sur le fallback.

## Décisions (cœur du ticket ERP-99)
- **Schéma de clé** : flat `audit.entity.<module>_<entity>` (inchangé, zéro régression).
- **Emplacement** : centralisé dans `frontend/i18n/locales/fr.json` (migration per-module = ticket infra i18n dédié).
- **Source de vérité** : `entity_type` = `strtolower(module).Entity` (confirmé dans `AuditListener::formatEntityType`).

## Changements
- **Complétude** : ajout des clés `audit.entity.*` manquantes (catalog + commercial) → 9 entités `#[Auditable]` couvertes.
- **Convention** : `.claude/rules/backend.md` § Audit — ajouter sa clé de libellé audit fait partie de la définition de fini d'une entité auditée.
- **Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entités `#[Auditable]` et échoue si une clé `audit.entity.*` manque ou est vide (rend le manque bloquant en CI).

## Vérifications
- Suite PHPUnit complète : **465 tests OK** (1604 assertions).
- Garde-fou : vert (9 entités) + test négatif confirmé rouge (clé retirée → échec actionnable).
- JSON `fr.json` valide, php-cs-fixer OK.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #48
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 09:09:37 +00:00
8 changed files with 264 additions and 21 deletions
+18
View File
@@ -98,6 +98,24 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
- Spec complete : @doc/audit-log.md
### Libelle i18n du type d'entite (obligatoire avec `#[Auditable]`)
**Toute entite `#[Auditable]` doit avoir son libelle FR dans le bloc `audit.entity` de `frontend/i18n/locales/fr.json`.** C'est la contrepartie i18n de l'attribut : sans elle, le filtre « Type d'entite » de l'audit-log affiche le type technique brut (ex: `commercial.Client`) au lieu d'un libelle lisible.
Pourquoi : le filtre est dynamique (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents en base) ; des qu'un module audite une entite, son type y apparait. Le front (`formatEntityType`, `audit-log.vue`) construit la cle `audit.entity.<module>_<entity>` et, faute de traduction, **retombe silencieusement** sur le type brut.
Derivation de la cle (emplacement centralise + schema flat — decision ERP-99) :
| FQCN entite | `entity_type` (back) | Cle i18n (flat) |
|---|---|---|
| `App\Module\Commercial\Domain\Entity\Client` | `commercial.Client` | `commercial_client` |
| `App\Module\Commercial\Domain\Entity\ClientAddress` | `commercial.ClientAddress` | `commercial_clientaddress` |
| `App\Module\Catalog\Domain\Entity\Category` | `catalog.Category` | `catalog_category` |
Regle : `strtolower(module)` + `_` + `strtolower(Entity)`. Ajouter sa cle de libelle audit fait partie de la **definition de fini** d'une entite metier auditee.
**Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entites `#[Auditable]` et echoue si une seule n'a pas sa cle `audit.entity.*`. Conclusion : creer une entite `#[Auditable]` sans son libelle i18n casse `make test`.
## Timestampable + Blamable (obligatoire pour entites metier)
Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes a ajouter a l'entite :
@@ -3,6 +3,14 @@ lexik_jwt_authentication:
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: '%env(int:JWT_TOKEN_TTL)%'
# Tolerance d'horloge (en secondes) appliquee a la validation des claims
# temporels iat / nbf / exp (LooseValidAt cote lcobucci). Sans cette marge
# (defaut 0), un recul d'horloge entre la signature (/login_check) et la
# requete suivante rend iat/nbf « dans le futur » -> « Invalid JWT Token »
# (401). Observe en dev sous WSL2/Docker (horloge CLOCK_REALTIME non
# monotone) : flakes intermittents de la suite PHPUnit (ERP-98). Benefice
# aussi en prod si les noeuds derivent legerement entre eux.
clock_skew: 15
remove_token_from_body_when_cookies_used: true
token_extractors:
authorization_header:
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.67'
app.version: '0.1.69'
+9 -4
View File
@@ -222,10 +222,15 @@
"delete": "Suppression"
},
"entity": {
"core_user": "Utilisateur",
"core_role": "Rôle",
"core_permission": "Permission",
"sites_site": "Site"
"core_user": "Utilisateur",
"core_role": "Rôle",
"core_permission": "Permission",
"sites_site": "Site",
"catalog_category": "Catégorie",
"commercial_client": "Client",
"commercial_clientaddress": "Adresse client",
"commercial_clientcontact": "Contact client",
"commercial_clientrib": "RIB client"
},
"empty": "Aucune activité enregistrée",
"no_results": "Aucun résultat pour ces filtres",
@@ -6,12 +6,12 @@ namespace App\Shared\Infrastructure\Doctrine;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
@@ -30,12 +30,19 @@ use Symfony\Component\Security\Core\User\UserInterface;
#[AsDoctrineListener(event: Events::preUpdate)]
final class TimestampableBlamableSubscriber
{
public function __construct(private readonly Security $security) {}
// L'horloge est injectee (et non un `new DateTimeImmutable()` direct) pour
// que les tests puissent figer/avancer le temps de facon deterministe via
// ClockSensitiveTrait (cf. ERP-98). En prod, le service `clock` delegue a
// l'horloge systeme reelle.
public function __construct(
private readonly Security $security,
private readonly ClockInterface $clock,
) {}
public function prePersist(PrePersistEventArgs $args): void
{
$entity = $args->getObject();
$now = new DateTimeImmutable();
$now = $this->clock->now();
$user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) {
@@ -55,7 +62,7 @@ final class TimestampableBlamableSubscriber
$user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) {
$entity->setUpdatedAt(new DateTimeImmutable());
$entity->setUpdatedAt($this->clock->now());
}
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Tests\Architecture;
use App\Shared\Domain\Attribute\Auditable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\Finder\Finder;
use function is_string;
use function sprintf;
use const JSON_THROW_ON_ERROR;
/**
* Garde-fou architecture : toute entite `#[Auditable]` doit avoir son libelle
* i18n dans le bloc `audit.entity` du `fr.json` du shell.
*
* Pourquoi : le filtre « Type d'entite » de l'audit-log est dynamique
* (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents
* en base). Des qu'un module audite une entite, un nouveau type apparait. Le
* rendu front (`formatEntityType`, audit-log.vue) construit la cle
* `audit.entity.<module>_<entity>` et, faute de traduction, retombe
* SILENCIEUSEMENT sur le type technique brut (ex: `commercial.Client`). Le
* manque passe donc inapercu jusqu'a observation dans l'UI.
*
* Ce test rend le manque BLOQUANT (meme esprit que ColumnsHaveSqlCommentTest) :
* il scanne les entites `#[Auditable]` sous `src/Module/<m>/Domain/Entity/`,
* derive la cle attendue comme le fait le front, et echoue si elle est absente
* du `fr.json`.
*
* Derivation de la cle (miroir exact de AuditListener::formatEntityType + de
* formatEntityType cote front) :
* FQCN `App\Module\Commercial\Domain\Entity\ClientAddress`
* -> entity_type `commercial.ClientAddress` (module en minuscules, Entity intacte)
* -> cle i18n `commercial_clientaddress` (tout en minuscules, `.` -> `_`)
*
* @internal
*/
final class AuditableEntitiesHaveI18nLabelTest extends TestCase
{
/**
* Chemin du fichier de traductions FR du shell. Source unique des libelles
* d'entite audit (decision ERP-99 : emplacement centralise, schema flat).
*/
private const LOCALE_FILE = __DIR__.'/../../frontend/i18n/locales/fr.json';
public function testEveryAuditableEntityHasAnI18nLabel(): void
{
$labels = $this->loadAuditEntityLabels();
$finder = new Finder()
->files()
->in(__DIR__.'/../../src/Module')
->path('Domain/Entity')
->name('*.php')
;
// Garde : si le scan ne trouve rien, le chemin est casse — le test
// deviendrait un faux positif vert. On verifie qu'il a du grain a moudre.
self::assertNotEmpty(iterator_to_array($finder), 'Aucune entite scannee : chemin src/Module invalide ?');
$checked = 0;
foreach ($finder as $file) {
$fqcn = $this->extractFqcn($file->getRealPath());
if (null === $fqcn) {
continue;
}
$reflection = new ReflectionClass($fqcn);
// On ne s'interesse qu'aux entites reellement auditees.
if ($reflection->isAbstract() || [] === $reflection->getAttributes(Auditable::class)) {
continue;
}
$key = $this->deriveI18nKey($fqcn);
self::assertNotNull(
$key,
sprintf('Entite %s hors structure modulaire attendue (App\Module\<M>\Domain\Entity\<E>).', $fqcn),
);
self::assertArrayHasKey(
$key,
$labels,
sprintf(
'L\'entite auditable %s n\'a pas de libelle i18n. Ajouter "%s" dans le bloc '
.'`audit.entity` de frontend/i18n/locales/fr.json (sinon le filtre audit-log '
.'affiche le type technique brut). Cf. ERP-99 + .claude/rules/backend.md § Audit.',
$fqcn,
$key,
),
);
self::assertNotSame('', trim($labels[$key]), sprintf('Le libelle audit "%s" est vide.', $key));
++$checked;
}
// Garde : au moins une entite auditable doit avoir ete verifiee, sinon
// la detection de l'attribut est cassee (faux positif vert).
self::assertGreaterThan(0, $checked, 'Aucune entite #[Auditable] detectee : detection d\'attribut cassee ?');
}
/**
* Charge le bloc `audit.entity` du fr.json sous forme de map cle -> libelle.
*
* @return array<string, string>
*/
private function loadAuditEntityLabels(): array
{
$raw = file_get_contents(self::LOCALE_FILE);
self::assertIsString($raw, sprintf('Fichier de locale introuvable : %s', self::LOCALE_FILE));
/** @var array<string, mixed> $json */
$json = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
$entity = $json['audit']['entity'] ?? null;
self::assertIsArray($entity, 'Bloc `audit.entity` absent ou invalide dans fr.json.');
$labels = [];
foreach ($entity as $key => $value) {
if (is_string($key) && is_string($value)) {
$labels[$key] = $value;
}
}
return $labels;
}
/**
* Derive la cle i18n `<module>_<entity>` depuis le FQCN, en miroir de
* AuditListener::formatEntityType (module en minuscules) suivi de
* l'aplatissement front (tout en minuscules, `.` -> `_`).
*
* Retourne null si le FQCN ne respecte pas la structure modulaire.
*/
private function deriveI18nKey(string $fqcn): ?string
{
if (1 !== preg_match('#^App\\\Module\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $fqcn, $m)) {
return null;
}
return strtolower($m['module']).'_'.strtolower($m['entity']);
}
/**
* Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du
* source, sans charger le fichier.
*/
private function extractFqcn(string $path): ?string
{
$source = file_get_contents($path);
if (false === $source) {
return null;
}
if (
1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
|| 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
) {
return null;
}
return trim($nsMatch[1]).'\\'.$classMatch[1];
}
}
@@ -7,6 +7,8 @@ namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Core\Domain\Entity\User;
use DateTimeImmutable;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Clock\Test\ClockSensitiveTrait;
/**
* Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir
@@ -20,12 +22,39 @@ use DateTimeImmutable;
* - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
* Doctrine declenche le subscriber)
*
* ERP-98 : ces tests pilotent une horloge mockee (ClockSensitiveTrait) plutot
* que de dependre d'un `sleep(1)` reel. Le subscriber lit le service `clock`,
* que `self::mockTime()` remplace par un MockClock fige au niveau du process —
* ce qui survit aux reboots de kernel entre requetes (POST admin / PATCH bob)
* et reste insensible a la derive d'horloge WSL2 a l'origine des flakes.
*
* @internal
*/
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
{
use ClockSensitiveTrait;
/**
* Fige l'horloge globale sur l'instant courant DANS LE FUSEAU PHP par
* defaut, et la retourne pour la piloter (`sleep()`).
*
* Subtilite : `self::mockTime()` cree par defaut un MockClock en UTC, or
* les colonnes `TIMESTAMP WITHOUT TIME ZONE` round-trippent via le fuseau
* PHP (Europe/Paris). Un MockClock UTC decalerait createdAt de l'offset
* (2h) au rechargement. On seede donc avec `new DateTimeImmutable()`
* (fuseau par defaut), exactement comme le NativeClock en prod.
*/
private function freezeClock(): ClockInterface
{
return self::mockTime(new DateTimeImmutable());
}
public function testCreatedByAdminOnPost(): void
{
// Horloge figee : le subscriber posera createdAt/updatedAt sur cet
// instant exact, insensible a tout decalage d'horloge reel.
$clock = $this->freezeClock();
$type = $this->createCategoryType();
/** @var User $admin */
@@ -33,9 +62,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
self::assertNotNull($admin);
$adminId = $admin->getId();
$before = new DateTimeImmutable();
// Petit decalage pour absorber les arrondis a la seconde de Postgres.
sleep(1);
$before = $clock->now();
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [
@@ -103,6 +130,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
public function testPatchUpdatesUpdatedFieldsOnly(): void
{
$clock = $this->freezeClock();
// Etape 1 : creation par admin pour figer createdBy=admin.
$type = $this->createCategoryType();
$adminClient = $this->createAdminClient();
@@ -127,9 +156,9 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
$initialUpdatedAt = $initial->getUpdatedAt();
$initialCreatedById = $initial->getCreatedBy()->getId();
// Decalage temporel suffisant pour que la precision PG (seconde)
// capte un updatedAt different.
sleep(1);
// Avance deterministe de l'horloge mockee : garantit un updatedAt
// strictement superieur cote PG (precision seconde) sans sleep reel.
$clock->sleep(1);
// Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
$manage = $this->createManageClient();
@@ -180,6 +209,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
{
$clock = $this->freezeClock();
// RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
// doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
$type = $this->createCategoryType();
@@ -202,7 +233,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
$initial = $em->getRepository(Category::class)->find($createdId);
$initialUpdatedAt = $initial->getUpdatedAt();
sleep(1);
// Avance deterministe de l'horloge mockee (cf. testPatch).
$clock->sleep(1);
// Soft delete par un manager non-admin.
$manage = $this->createManageClient();
@@ -14,6 +14,7 @@ use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Security\Core\User\UserInterface;
/**
@@ -30,7 +31,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPrePersistWithUser(): void
{
$user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
$entity = new FullAuditableFixture();
$subscriber->prePersist($this->prePersistArgs($entity));
@@ -45,7 +46,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPrePersistWithoutUser(): void
{
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null));
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null), new MockClock());
$entity = new FullAuditableFixture();
$subscriber->prePersist($this->prePersistArgs($entity));
@@ -59,8 +60,13 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPreUpdate(): void
{
$user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
$user = $this->createStub(UserInterface::class);
// Horloge figee 1s apres le createdAt simule : updatedAt doit avancer
// de facon deterministe, sans dependre de l'heure reelle.
$subscriber = new TimestampableBlamableSubscriber(
$this->securityReturning($user),
new MockClock(new DateTimeImmutable('2020-01-01 10:00:01')),
);
// On simule une entite deja persistee : createdAt fige dans le passe,
// createdBy positionne par une creation anterieure.
@@ -80,7 +86,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPartialEntityTimestampableOnly(): void
{
$user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
$entity = new TimestampableOnlyFixture();
// Entite Timestampable mais NON Blamable : seules les dates sont posees,