From dfd6e5251d011def433ac07185b9191d01988758 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 2 Jun 2026 17:35:12 +0200 Subject: [PATCH] fix(tests) : fiabilise la suite PHPUnit contre la derive d'horloge (ERP-98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La suite echouait de facon intermittente (~1 run sur 2), faisant planter le hook pre-commit. Cause racine unique : 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. - JWT « Invalid JWT Token » (401) : lexik validait iat/nbf/exp avec clock_skew=0 (LooseValidAt PT0S). Un recul d'horloge apres /login_check rendait le token « dans le futur ». Ajout de clock_skew: 15 (benefice prod aussi si les noeuds derivent). - Horodatages « meme seconde » : colonnes TIMESTAMP(0) + sleep(1) reel. L'ecart floor-seconde n'etait nul que si l'horloge reculait. Le subscriber injecte desormais ClockInterface (comportement prod inchange via NativeClock) et les tests pilotent un MockClock fige/avance (ClockSensitiveTrait), sans sleep reel -> deterministe et plus rapide. Le mock est seede dans le fuseau PHP par defaut pour eviter le decalage UTC <-> Europe/Paris au round-trip des colonnes TIMESTAMP WITHOUT TIME ZONE. make test : 464 tests verts ; test timestamp 5/5 deterministe. --- config/packages/lexik_jwt_authentication.yaml | 8 ++++ .../TimestampableBlamableSubscriber.php | 15 ++++-- .../Api/CategoryTimestampableBlamableTest.php | 46 ++++++++++++++++--- .../TimestampableBlamableSubscriberTest.php | 16 +++++-- 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/config/packages/lexik_jwt_authentication.yaml b/config/packages/lexik_jwt_authentication.yaml index ade6ecd..90239ad 100644 --- a/config/packages/lexik_jwt_authentication.yaml +++ b/config/packages/lexik_jwt_authentication.yaml @@ -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: diff --git a/src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php b/src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php index 99233f6..f4fc88f 100644 --- a/src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php +++ b/src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php @@ -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) { diff --git a/tests/Module/Catalog/Api/CategoryTimestampableBlamableTest.php b/tests/Module/Catalog/Api/CategoryTimestampableBlamableTest.php index 75a90ba..e7886d2 100644 --- a/tests/Module/Catalog/Api/CategoryTimestampableBlamableTest.php +++ b/tests/Module/Catalog/Api/CategoryTimestampableBlamableTest.php @@ -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(); diff --git a/tests/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriberTest.php b/tests/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriberTest.php index b101606..6eab29f 100644 --- a/tests/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriberTest.php +++ b/tests/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriberTest.php @@ -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,