Compare commits

...

1 Commits

Author SHA1 Message Date
Matthieu dfd6e5251d fix(tests) : fiabilise la suite PHPUnit contre la derive d'horloge (ERP-98)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 3m39s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m15s
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.
2026-06-02 17:35:12 +02:00
4 changed files with 69 additions and 16 deletions
@@ -3,6 +3,14 @@ lexik_jwt_authentication:
public_key: '%env(resolve:JWT_PUBLIC_KEY)%' public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%' pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: '%env(int:JWT_TOKEN_TTL)%' 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 remove_token_from_body_when_cookies_used: true
token_extractors: token_extractors:
authorization_header: authorization_header:
@@ -6,12 +6,12 @@ namespace App\Shared\Infrastructure\Doctrine;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs; use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs; use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
/** /**
@@ -30,12 +30,19 @@ use Symfony\Component\Security\Core\User\UserInterface;
#[AsDoctrineListener(event: Events::preUpdate)] #[AsDoctrineListener(event: Events::preUpdate)]
final class TimestampableBlamableSubscriber 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 public function prePersist(PrePersistEventArgs $args): void
{ {
$entity = $args->getObject(); $entity = $args->getObject();
$now = new DateTimeImmutable(); $now = $this->clock->now();
$user = $this->security->getUser(); $user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) { if ($entity instanceof TimestampableInterface) {
@@ -55,7 +62,7 @@ final class TimestampableBlamableSubscriber
$user = $this->security->getUser(); $user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) { if ($entity instanceof TimestampableInterface) {
$entity->setUpdatedAt(new DateTimeImmutable()); $entity->setUpdatedAt($this->clock->now());
} }
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) { if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
@@ -7,6 +7,8 @@ namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Category; use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Clock\Test\ClockSensitiveTrait;
/** /**
* Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir * 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 * - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
* Doctrine declenche le subscriber) * 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 * @internal
*/ */
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase 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 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(); $type = $this->createCategoryType();
/** @var User $admin */ /** @var User $admin */
@@ -33,9 +62,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
self::assertNotNull($admin); self::assertNotNull($admin);
$adminId = $admin->getId(); $adminId = $admin->getId();
$before = new DateTimeImmutable(); $before = $clock->now();
// Petit decalage pour absorber les arrondis a la seconde de Postgres.
sleep(1);
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [ $response = $client->request('POST', '/api/categories', [
@@ -103,6 +130,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
public function testPatchUpdatesUpdatedFieldsOnly(): void public function testPatchUpdatesUpdatedFieldsOnly(): void
{ {
$clock = $this->freezeClock();
// Etape 1 : creation par admin pour figer createdBy=admin. // Etape 1 : creation par admin pour figer createdBy=admin.
$type = $this->createCategoryType(); $type = $this->createCategoryType();
$adminClient = $this->createAdminClient(); $adminClient = $this->createAdminClient();
@@ -127,9 +156,9 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
$initialUpdatedAt = $initial->getUpdatedAt(); $initialUpdatedAt = $initial->getUpdatedAt();
$initialCreatedById = $initial->getCreatedBy()->getId(); $initialCreatedById = $initial->getCreatedBy()->getId();
// Decalage temporel suffisant pour que la precision PG (seconde) // Avance deterministe de l'horloge mockee : garantit un updatedAt
// capte un updatedAt different. // strictement superieur cote PG (precision seconde) sans sleep reel.
sleep(1); $clock->sleep(1);
// Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob". // Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
$manage = $this->createManageClient(); $manage = $this->createManageClient();
@@ -180,6 +209,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
public function testSoftDeleteAlsoUpdatesUpdatedFields(): void public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
{ {
$clock = $this->freezeClock();
// RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber // RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
// doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt. // doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
$type = $this->createCategoryType(); $type = $this->createCategoryType();
@@ -202,7 +233,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
$initial = $em->getRepository(Category::class)->find($createdId); $initial = $em->getRepository(Category::class)->find($createdId);
$initialUpdatedAt = $initial->getUpdatedAt(); $initialUpdatedAt = $initial->getUpdatedAt();
sleep(1); // Avance deterministe de l'horloge mockee (cf. testPatch).
$clock->sleep(1);
// Soft delete par un manager non-admin. // Soft delete par un manager non-admin.
$manage = $this->createManageClient(); $manage = $this->createManageClient();
@@ -14,6 +14,7 @@ use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs; use Doctrine\ORM\Event\PreUpdateEventArgs;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
/** /**
@@ -30,7 +31,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPrePersistWithUser(): void public function testPrePersistWithUser(): void
{ {
$user = $this->createStub(UserInterface::class); $user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user)); $subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
$entity = new FullAuditableFixture(); $entity = new FullAuditableFixture();
$subscriber->prePersist($this->prePersistArgs($entity)); $subscriber->prePersist($this->prePersistArgs($entity));
@@ -45,7 +46,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPrePersistWithoutUser(): void public function testPrePersistWithoutUser(): void
{ {
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null)); $subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null), new MockClock());
$entity = new FullAuditableFixture(); $entity = new FullAuditableFixture();
$subscriber->prePersist($this->prePersistArgs($entity)); $subscriber->prePersist($this->prePersistArgs($entity));
@@ -59,8 +60,13 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPreUpdate(): void public function testPreUpdate(): void
{ {
$user = $this->createStub(UserInterface::class); $user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user)); // 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, // On simule une entite deja persistee : createdAt fige dans le passe,
// createdBy positionne par une creation anterieure. // createdBy positionne par une creation anterieure.
@@ -80,7 +86,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPartialEntityTimestampableOnly(): void public function testPartialEntityTimestampableOnly(): void
{ {
$user = $this->createStub(UserInterface::class); $user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user)); $subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
$entity = new TimestampableOnlyFixture(); $entity = new TimestampableOnlyFixture();
// Entite Timestampable mais NON Blamable : seules les dates sont posees, // Entite Timestampable mais NON Blamable : seules les dates sont posees,