feat: Audit trail pour actions sensibles

Story 1.7 - Implémente un système complet d'audit trail pour tracer
toutes les actions sensibles (authentification, modifications de données,
exports) avec immuabilité garantie par PostgreSQL.

Fonctionnalités principales:
- Table audit_log append-only avec contraintes PostgreSQL (RULE)
- AuditLogger centralisé avec injection automatique du contexte
- Correlation ID pour traçabilité distribuée (HTTP + async)
- Handlers pour événements d'authentification
- Commande d'archivage des logs anciens
- Pas de PII dans les logs (emails/IPs hashés)

Infrastructure:
- Middlewares Messenger pour propagation du Correlation ID
- HTTP middleware pour génération/propagation du Correlation ID
- Support multi-tenant avec TenantResolver
This commit is contained in:
2026-02-04 00:11:58 +01:00
parent b823479658
commit 2ed60fdcc1
38 changed files with 4179 additions and 81 deletions

View File

@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Controller;
use App\Administration\Domain\Event\Deconnexion;
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use App\Administration\Domain\Repository\SessionRepository;
use App\Administration\Infrastructure\Api\Controller\LogoutController;
use App\Shared\Domain\Clock;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* [P0] Tests for LogoutController - critical session invalidation.
*
* Verifies:
* - Token family invalidation on logout
* - Session deletion
* - Cookie clearing
* - Deconnexion event dispatch
* - Graceful handling of missing/malformed tokens
*/
final class LogoutControllerTest extends TestCase
{
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string IP_ADDRESS = '192.168.1.100';
private const string USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
private RefreshTokenRepository $refreshTokenRepository;
private SessionRepository $sessionRepository;
private Clock $clock;
/** @var DomainEvent[] */
private array $dispatchedEvents = [];
private LogoutController $controller;
protected function setUp(): void
{
$this->refreshTokenRepository = $this->createMock(RefreshTokenRepository::class);
$this->sessionRepository = $this->createMock(SessionRepository::class);
$this->clock = new class implements Clock {
#[Override]
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-01-28 10:00:00');
}
};
$this->dispatchedEvents = [];
$eventBus = new class($this->dispatchedEvents) implements MessageBusInterface {
/** @param DomainEvent[] $events */
public function __construct(private array &$events)
{
}
#[Override]
public function dispatch(object $message, array $stamps = []): Envelope
{
$this->events[] = $message;
return new Envelope($message);
}
};
$this->controller = new LogoutController(
$this->refreshTokenRepository,
$this->sessionRepository,
$eventBus,
$this->clock,
);
}
#[Test]
public function itInvalidatesTokenFamilyOnLogout(): void
{
// GIVEN: A request with a valid refresh token cookie
$refreshToken = $this->createRefreshToken();
$tokenString = $refreshToken->toTokenString();
$request = Request::create('/api/token/logout', 'POST', [], [
'refresh_token' => $tokenString,
], [], [
'REMOTE_ADDR' => self::IP_ADDRESS,
'HTTP_USER_AGENT' => self::USER_AGENT,
]);
$this->refreshTokenRepository
->expects($this->once())
->method('find')
->with($refreshToken->id)
->willReturn($refreshToken);
// THEN: Family should be invalidated
$this->refreshTokenRepository
->expects($this->once())
->method('invalidateFamily')
->with($refreshToken->familyId);
// WHEN: Logout is invoked
$response = ($this->controller)($request);
// THEN: Returns success
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
}
#[Test]
public function itDeletesSessionOnLogout(): void
{
// GIVEN: A valid refresh token
$refreshToken = $this->createRefreshToken();
$request = Request::create('/api/token/logout', 'POST', [], [
'refresh_token' => $refreshToken->toTokenString(),
], [], [
'REMOTE_ADDR' => self::IP_ADDRESS,
'HTTP_USER_AGENT' => self::USER_AGENT,
]);
$this->refreshTokenRepository
->method('find')
->willReturn($refreshToken);
// THEN: Session should be deleted
$this->sessionRepository
->expects($this->once())
->method('delete')
->with($refreshToken->familyId);
// WHEN: Logout is invoked
($this->controller)($request);
}
#[Test]
public function itDispatchesDeconnexionEvent(): void
{
// GIVEN: A valid refresh token
$refreshToken = $this->createRefreshToken();
$request = Request::create('/api/token/logout', 'POST', [], [
'refresh_token' => $refreshToken->toTokenString(),
], [], [
'REMOTE_ADDR' => self::IP_ADDRESS,
'HTTP_USER_AGENT' => self::USER_AGENT,
]);
$this->refreshTokenRepository
->method('find')
->willReturn($refreshToken);
// WHEN: Logout is invoked
($this->controller)($request);
// THEN: Deconnexion event is dispatched with correct data
$logoutEvents = array_filter(
$this->dispatchedEvents,
static fn ($e) => $e instanceof Deconnexion,
);
$this->assertCount(1, $logoutEvents);
$event = reset($logoutEvents);
$this->assertSame((string) $refreshToken->userId, $event->userId);
$this->assertSame((string) $refreshToken->familyId, $event->familyId);
$this->assertSame(self::IP_ADDRESS, $event->ipAddress);
$this->assertSame(self::USER_AGENT, $event->userAgent);
}
#[Test]
public function itClearsCookiesOnLogout(): void
{
// GIVEN: A valid refresh token
$refreshToken = $this->createRefreshToken();
$request = Request::create('/api/token/logout', 'POST', [], [
'refresh_token' => $refreshToken->toTokenString(),
], [], [
'REMOTE_ADDR' => self::IP_ADDRESS,
'HTTP_USER_AGENT' => self::USER_AGENT,
]);
$this->refreshTokenRepository
->method('find')
->willReturn($refreshToken);
// WHEN: Logout is invoked
$response = ($this->controller)($request);
// THEN: Cookies are cleared (expired)
$cookies = $response->headers->getCookies();
$this->assertCount(2, $cookies); // /api and /api/token (legacy)
foreach ($cookies as $cookie) {
$this->assertSame('refresh_token', $cookie->getName());
$this->assertSame('', $cookie->getValue());
$this->assertTrue($cookie->isCleared()); // Expiry in the past
}
}
#[Test]
public function itHandlesMissingCookieGracefully(): void
{
// GIVEN: A request without refresh_token cookie
$request = Request::create('/api/token/logout', 'POST', [], [], [], [
'REMOTE_ADDR' => self::IP_ADDRESS,
'HTTP_USER_AGENT' => self::USER_AGENT,
]);
// THEN: No repository operations
$this->refreshTokenRepository
->expects($this->never())
->method('find');
$this->refreshTokenRepository
->expects($this->never())
->method('invalidateFamily');
$this->sessionRepository
->expects($this->never())
->method('delete');
// WHEN: Logout is invoked
$response = ($this->controller)($request);
// THEN: Still returns success (idempotent)
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
// THEN: Cookies are still cleared
$this->assertNotEmpty($response->headers->getCookies());
}
#[Test]
public function itHandlesMalformedTokenGracefully(): void
{
// GIVEN: A request with malformed token
$request = Request::create('/api/token/logout', 'POST', [], [
'refresh_token' => 'malformed-token-not-base64',
], [], [
'REMOTE_ADDR' => self::IP_ADDRESS,
'HTTP_USER_AGENT' => self::USER_AGENT,
]);
// THEN: No repository operations (exception caught)
$this->refreshTokenRepository
->expects($this->never())
->method('invalidateFamily');
// WHEN: Logout is invoked
$response = ($this->controller)($request);
// THEN: Still returns success
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
}
#[Test]
public function itHandlesNonExistentTokenGracefully(): void
{
// GIVEN: A valid token format but not in database
$refreshToken = $this->createRefreshToken();
$request = Request::create('/api/token/logout', 'POST', [], [
'refresh_token' => $refreshToken->toTokenString(),
], [], [
'REMOTE_ADDR' => self::IP_ADDRESS,
'HTTP_USER_AGENT' => self::USER_AGENT,
]);
$this->refreshTokenRepository
->method('find')
->willReturn(null);
// THEN: No invalidation attempted
$this->refreshTokenRepository
->expects($this->never())
->method('invalidateFamily');
// WHEN: Logout is invoked
$response = ($this->controller)($request);
// THEN: Still returns success (idempotent)
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
}
private function createRefreshToken(): RefreshToken
{
return RefreshToken::create(
userId: UserId::fromString(self::USER_ID),
tenantId: TenantId::fromString(self::TENANT_ID),
deviceFingerprint: DeviceFingerprint::fromRequest(self::USER_AGENT, self::IP_ADDRESS),
issuedAt: $this->clock->now(),
);
}
}

View File

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryRefreshTokenRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* [P1] Tests for InMemoryRefreshTokenRepository.
*
* Verifies:
* - Token save and retrieval
* - Token deletion
* - Family invalidation (all tokens in family)
* - User invalidation (all families for user)
* - Index maintenance
*/
final class InMemoryRefreshTokenRepositoryTest extends TestCase
{
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string USER_ID_2 = '550e8400-e29b-41d4-a716-446655440003';
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryRefreshTokenRepository $repository;
private DateTimeImmutable $now;
protected function setUp(): void
{
$this->repository = new InMemoryRefreshTokenRepository();
$this->now = new DateTimeImmutable('2026-01-28 10:00:00');
}
#[Test]
public function itSavesAndRetrievesToken(): void
{
// GIVEN: A refresh token
$token = $this->createToken(self::USER_ID);
// WHEN: Token is saved
$this->repository->save($token);
// THEN: Token can be retrieved by ID
$found = $this->repository->find($token->id);
$this->assertNotNull($found);
$this->assertSame((string) $token->id, (string) $found->id);
$this->assertSame((string) $token->familyId, (string) $found->familyId);
$this->assertSame((string) $token->userId, (string) $found->userId);
}
#[Test]
public function itReturnsNullForNonExistentToken(): void
{
// GIVEN: Empty repository
// WHEN: Searching for non-existent token
$token = $this->createToken(self::USER_ID);
$found = $this->repository->find($token->id);
// THEN: Returns null
$this->assertNull($found);
}
#[Test]
public function itDeletesTokenById(): void
{
// GIVEN: A saved token
$token = $this->createToken(self::USER_ID);
$this->repository->save($token);
// WHEN: Token is deleted
$this->repository->delete($token->id);
// THEN: Token is no longer found
$found = $this->repository->find($token->id);
$this->assertNull($found);
}
#[Test]
public function itInvalidatesEntireFamily(): void
{
// GIVEN: Multiple tokens in the same family
$token1 = $this->createToken(self::USER_ID);
$token2 = $this->rotateToken($token1);
$token3 = $this->rotateToken($token2);
$this->repository->save($token1);
$this->repository->save($token2);
$this->repository->save($token3);
// WHEN: Family is invalidated
$this->repository->invalidateFamily($token1->familyId);
// THEN: All tokens in family are deleted
$this->assertNull($this->repository->find($token1->id));
$this->assertNull($this->repository->find($token2->id));
$this->assertNull($this->repository->find($token3->id));
}
#[Test]
public function itDoesNotAffectOtherFamilies(): void
{
// GIVEN: Tokens in different families
$token1 = $this->createToken(self::USER_ID);
$token2 = $this->createToken(self::USER_ID); // Different family (new login session)
$this->repository->save($token1);
$this->repository->save($token2);
// WHEN: One family is invalidated
$this->repository->invalidateFamily($token1->familyId);
// THEN: Other family is intact
$this->assertNull($this->repository->find($token1->id));
$this->assertNotNull($this->repository->find($token2->id));
}
#[Test]
public function itInvalidatesAllFamiliesForUser(): void
{
// GIVEN: Multiple families for the same user (multiple devices)
$token1 = $this->createToken(self::USER_ID);
$token2 = $this->createToken(self::USER_ID);
$token3 = $this->createToken(self::USER_ID);
$this->repository->save($token1);
$this->repository->save($token2);
$this->repository->save($token3);
// All belong to same user but different families
$userId = UserId::fromString(self::USER_ID);
// Verify user has active sessions
$this->assertTrue($this->repository->hasActiveSessionsForUser($userId));
// WHEN: All tokens for user are invalidated
$this->repository->invalidateAllForUser($userId);
// THEN: No sessions remain for user
$this->assertFalse($this->repository->hasActiveSessionsForUser($userId));
$this->assertNull($this->repository->find($token1->id));
$this->assertNull($this->repository->find($token2->id));
$this->assertNull($this->repository->find($token3->id));
}
#[Test]
public function itDoesNotAffectOtherUsers(): void
{
// GIVEN: Tokens for different users
$token1 = $this->createToken(self::USER_ID);
$token2 = $this->createToken(self::USER_ID_2);
$this->repository->save($token1);
$this->repository->save($token2);
// WHEN: First user's tokens are invalidated
$this->repository->invalidateAllForUser(UserId::fromString(self::USER_ID));
// THEN: Second user's token is intact
$this->assertNull($this->repository->find($token1->id));
$this->assertNotNull($this->repository->find($token2->id));
}
#[Test]
public function itTracksActiveSessionsForUser(): void
{
// GIVEN: No tokens for user
$userId = UserId::fromString(self::USER_ID);
$this->assertFalse($this->repository->hasActiveSessionsForUser($userId));
// WHEN: Token is saved
$token = $this->createToken(self::USER_ID);
$this->repository->save($token);
// THEN: User has active sessions
$this->assertTrue($this->repository->hasActiveSessionsForUser($userId));
}
#[Test]
public function itHandlesInvalidationOfNonExistentFamily(): void
{
// GIVEN: Non-existent family ID
$familyId = TokenFamilyId::generate();
// WHEN: Invalidating non-existent family
$this->repository->invalidateFamily($familyId);
// THEN: No exception thrown (idempotent operation)
$this->assertTrue(true);
}
#[Test]
public function itHandlesInvalidationOfNonExistentUser(): void
{
// GIVEN: Non-existent user ID
$userId = UserId::fromString(self::USER_ID);
// WHEN: Invalidating non-existent user's tokens
$this->repository->invalidateAllForUser($userId);
// THEN: No exception thrown (idempotent operation)
$this->assertTrue(true);
}
#[Test]
public function itHandlesDuplicateSavesIdempotently(): void
{
// GIVEN: A token
$token = $this->createToken(self::USER_ID);
// WHEN: Token is saved multiple times
$this->repository->save($token);
$this->repository->save($token);
$this->repository->save($token);
// THEN: Token exists once (no duplicates in indexes)
$found = $this->repository->find($token->id);
$this->assertNotNull($found);
// Invalidating family should clean everything properly
$this->repository->invalidateFamily($token->familyId);
$this->assertNull($this->repository->find($token->id));
}
private function createToken(string $userId): RefreshToken
{
return RefreshToken::create(
userId: UserId::fromString($userId),
tenantId: TenantId::fromString(self::TENANT_ID),
deviceFingerprint: DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
issuedAt: $this->now,
);
}
private function rotateToken(RefreshToken $token): RefreshToken
{
[$newToken, $oldToken] = $token->rotate($this->now->modify('+1 minute'));
return $newToken;
}
}

View File

@@ -0,0 +1,331 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Security;
use App\Administration\Application\Port\GeoLocationService;
use App\Administration\Application\Service\RefreshTokenManager;
use App\Administration\Domain\Event\ConnexionReussie;
use App\Administration\Domain\Model\Session\Location;
use App\Administration\Domain\Model\Session\Session;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\SessionRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryRefreshTokenRepository;
use App\Administration\Infrastructure\Security\LoginSuccessHandler;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Domain\Clock;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
use DateTimeImmutable;
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
use Override;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* [P0] Tests for LoginSuccessHandler - critical authentication flow.
*
* Verifies:
* - Refresh token creation on successful login
* - Session creation with device info and geolocation
* - Rate limiter reset after successful login
* - ConnexionReussie event dispatch
* - HttpOnly cookie configuration
*/
final class LoginSuccessHandlerTest extends TestCase
{
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string EMAIL = 'user@example.com';
private const string IP_ADDRESS = '192.168.1.100';
private const string USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
private InMemoryRefreshTokenRepository $refreshTokenRepository;
private RefreshTokenManager $refreshTokenManager;
private SessionRepository $sessionRepository;
private GeoLocationService $geoLocationService;
private LoginRateLimiterInterface $rateLimiter;
private Clock $clock;
private RequestStack $requestStack;
/** @var DomainEvent[] */
private array $dispatchedEvents = [];
private LoginSuccessHandler $handler;
protected function setUp(): void
{
$this->clock = new class implements Clock {
#[Override]
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-01-28 10:00:00');
}
};
$this->refreshTokenRepository = new InMemoryRefreshTokenRepository();
$this->refreshTokenManager = new RefreshTokenManager(
$this->refreshTokenRepository,
$this->clock,
);
$this->sessionRepository = $this->createMock(SessionRepository::class);
$this->geoLocationService = $this->createMock(GeoLocationService::class);
$this->rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
$this->requestStack = new RequestStack();
$this->dispatchedEvents = [];
$eventBus = new class($this->dispatchedEvents) implements MessageBusInterface {
/** @param DomainEvent[] $events */
public function __construct(private array &$events)
{
}
#[Override]
public function dispatch(object $message, array $stamps = []): Envelope
{
$this->events[] = $message;
return new Envelope($message);
}
};
$this->handler = new LoginSuccessHandler(
$this->refreshTokenManager,
$this->sessionRepository,
$this->geoLocationService,
$this->rateLimiter,
$eventBus,
$this->clock,
$this->requestStack,
);
}
#[Test]
public function itCreatesRefreshTokenOnSuccessfulLogin(): void
{
// GIVEN: A successful authentication event with SecurityUser
$request = $this->createRequest();
$this->requestStack->push($request);
$securityUser = $this->createSecurityUser();
$response = new Response();
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
$this->geoLocationService
->method('locate')
->willReturn(Location::unknown());
$this->sessionRepository
->expects($this->once())
->method('save')
->with(
$this->isInstanceOf(Session::class),
$this->greaterThan(0),
);
// WHEN: Handler processes the event
$this->handler->onAuthenticationSuccess($event);
// THEN: Refresh token cookie is set
$cookies = $response->headers->getCookies();
$this->assertCount(1, $cookies);
$this->assertSame('refresh_token', $cookies[0]->getName());
$this->assertTrue($cookies[0]->isHttpOnly());
$this->assertSame('/api', $cookies[0]->getPath());
// THEN: Refresh token is saved in repository
$this->assertTrue(
$this->refreshTokenRepository->hasActiveSessionsForUser(
UserId::fromString(self::USER_ID),
),
);
}
#[Test]
public function itCreatesSessionWithDeviceInfoAndLocation(): void
{
// GIVEN: A successful authentication with request context
$request = $this->createRequest();
$this->requestStack->push($request);
$securityUser = $this->createSecurityUser();
$response = new Response();
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
$expectedLocation = Location::fromIp(self::IP_ADDRESS, 'France', 'Paris');
$this->geoLocationService
->expects($this->once())
->method('locate')
->with(self::IP_ADDRESS)
->willReturn($expectedLocation);
$savedSession = null;
$this->sessionRepository
->expects($this->once())
->method('save')
->willReturnCallback(static function (Session $session, int $ttl) use (&$savedSession): void {
$savedSession = $session;
});
// WHEN: Handler processes the event
$this->handler->onAuthenticationSuccess($event);
// THEN: Session is created with correct data
$this->assertNotNull($savedSession);
$this->assertSame(self::USER_ID, (string) $savedSession->userId);
}
#[Test]
public function itResetsRateLimiterAfterSuccessfulLogin(): void
{
// GIVEN: A successful authentication
$request = $this->createRequest();
$this->requestStack->push($request);
$securityUser = $this->createSecurityUser();
$response = new Response();
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
$this->geoLocationService
->method('locate')
->willReturn(Location::unknown());
// THEN: Rate limiter should be reset for the user's email
$this->rateLimiter
->expects($this->once())
->method('reset')
->with(self::EMAIL);
// WHEN: Handler processes the event
$this->handler->onAuthenticationSuccess($event);
}
#[Test]
public function itDispatchesConnexionReussieEvent(): void
{
// GIVEN: A successful authentication
$request = $this->createRequest();
$this->requestStack->push($request);
$securityUser = $this->createSecurityUser();
$response = new Response();
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
$this->geoLocationService
->method('locate')
->willReturn(Location::unknown());
// WHEN: Handler processes the event
$this->handler->onAuthenticationSuccess($event);
// THEN: ConnexionReussie event is dispatched
$loginEvents = array_filter(
$this->dispatchedEvents,
static fn ($e) => $e instanceof ConnexionReussie,
);
$this->assertCount(1, $loginEvents);
$loginEvent = reset($loginEvents);
$this->assertSame(self::USER_ID, $loginEvent->userId);
$this->assertSame(self::EMAIL, $loginEvent->email);
$this->assertSame(self::IP_ADDRESS, $loginEvent->ipAddress);
}
#[Test]
public function itIgnoresNonSecurityUserAuthentication(): void
{
// GIVEN: An authentication event with a non-SecurityUser user
$request = $this->createRequest();
$this->requestStack->push($request);
$genericUser = $this->createMock(UserInterface::class);
$response = new Response();
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $genericUser, $response);
// THEN: No operations should be performed
$this->sessionRepository
->expects($this->never())
->method('save');
// WHEN: Handler processes the event
$this->handler->onAuthenticationSuccess($event);
// THEN: No cookies added, no events dispatched
$this->assertEmpty($response->headers->getCookies());
$this->assertEmpty($this->dispatchedEvents);
}
#[Test]
public function itIgnoresEventWithoutRequest(): void
{
// GIVEN: No request in the stack
$securityUser = $this->createSecurityUser();
$response = new Response();
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
// THEN: No operations should be performed
$this->sessionRepository
->expects($this->never())
->method('save');
// WHEN: Handler processes the event
$this->handler->onAuthenticationSuccess($event);
// THEN: No cookies added
$this->assertEmpty($response->headers->getCookies());
}
#[Test]
public function itSetsSecureCookieOnlyForHttps(): void
{
// GIVEN: An HTTP request (not HTTPS)
$request = Request::create('http://localhost/login', 'POST', [], [], [], [
'REMOTE_ADDR' => self::IP_ADDRESS,
'HTTP_USER_AGENT' => self::USER_AGENT,
]);
$this->requestStack->push($request);
$securityUser = $this->createSecurityUser();
$response = new Response();
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
$this->geoLocationService
->method('locate')
->willReturn(Location::unknown());
// WHEN: Handler processes the event
$this->handler->onAuthenticationSuccess($event);
// THEN: Cookie is NOT marked as secure (HTTP)
$cookies = $response->headers->getCookies();
$this->assertCount(1, $cookies);
$this->assertFalse($cookies[0]->isSecure());
}
private function createRequest(): Request
{
return Request::create('/login', 'POST', [], [], [], [
'REMOTE_ADDR' => self::IP_ADDRESS,
'HTTP_USER_AGENT' => self::USER_AGENT,
]);
}
private function createSecurityUser(): SecurityUser
{
return new SecurityUser(
userId: UserId::fromString(self::USER_ID),
email: self::EMAIL,
hashedPassword: '$argon2id$hashed',
tenantId: TenantId::fromString(self::TENANT_ID),
roles: ['ROLE_PROF'],
);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Unit\Shared\Domain;
use App\Shared\Domain\CorrelationId;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
@@ -48,4 +49,19 @@ final class CorrelationIdTest extends TestCase
$this->assertNotSame($id1->value(), $id2->value());
}
public function testFromStringRejectsInvalidUuid(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid correlation ID format');
CorrelationId::fromString('not-a-valid-uuid');
}
public function testFromStringRejectsEmptyString(): void
{
$this->expectException(InvalidArgumentException::class);
CorrelationId::fromString('');
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
use App\Shared\Infrastructure\Audit\AuditLogEntry;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
/**
* @see Story 1.7 - T9: Requetes d'investigation
*/
final class AuditLogEntryTest extends TestCase
{
public function testFromDatabaseRow(): void
{
$id = Uuid::uuid4()->toString();
$aggregateId = Uuid::uuid4()->toString();
$row = [
'id' => $id,
'aggregate_type' => 'User',
'aggregate_id' => $aggregateId,
'event_type' => 'ConnexionReussie',
'payload' => '{"email_hash":"abc123","result":"success"}',
'metadata' => '{"tenant_id":"tenant-1","user_id":"user-1","correlation_id":"corr-1"}',
'occurred_at' => '2026-02-03T10:30:00+00:00',
'sequence_number' => '42',
];
$entry = AuditLogEntry::fromDatabaseRow($row);
$this->assertEquals($id, $entry->id->toString());
$this->assertSame('User', $entry->aggregateType);
$this->assertEquals($aggregateId, $entry->aggregateId?->toString());
$this->assertSame('ConnexionReussie', $entry->eventType);
$this->assertSame(['email_hash' => 'abc123', 'result' => 'success'], $entry->payload);
$this->assertSame('tenant-1', $entry->tenantId());
$this->assertSame('user-1', $entry->userId());
$this->assertSame('corr-1', $entry->correlationId());
$this->assertSame(42, $entry->sequenceNumber);
}
public function testFromDatabaseRowWithNullAggregateId(): void
{
$row = [
'id' => Uuid::uuid4()->toString(),
'aggregate_type' => 'Export',
'aggregate_id' => null,
'event_type' => 'ExportGenerated',
'payload' => '{}',
'metadata' => '{}',
'occurred_at' => '2026-02-03T10:30:00+00:00',
'sequence_number' => '1',
];
$entry = AuditLogEntry::fromDatabaseRow($row);
$this->assertNull($entry->aggregateId);
}
public function testMetadataAccessorsReturnNullWhenMissing(): void
{
$row = [
'id' => Uuid::uuid4()->toString(),
'aggregate_type' => 'User',
'aggregate_id' => null,
'event_type' => 'Test',
'payload' => '{}',
'metadata' => '{}',
'occurred_at' => '2026-02-03T10:30:00+00:00',
'sequence_number' => '1',
];
$entry = AuditLogEntry::fromDatabaseRow($row);
$this->assertNull($entry->tenantId());
$this->assertNull($entry->userId());
$this->assertNull($entry->correlationId());
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Audit\AuditLogEntry;
use App\Shared\Infrastructure\Audit\AuditLogRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\DBAL\Result;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
/**
* @see Story 1.7 - T9: Requetes d'investigation
*/
final class AuditLogRepositoryTest extends TestCase
{
private Connection&MockObject $connection;
private AuditLogRepository $repository;
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->repository = new AuditLogRepository($this->connection);
}
public function testFindByUserReturnsAuditLogEntries(): void
{
$userId = Uuid::uuid4();
$tenantId = TenantId::generate();
$rows = [
$this->createRowData(),
$this->createRowData(),
];
$queryBuilder = $this->createQueryBuilderMock($rows);
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$result = $this->repository->findByUser($userId, $tenantId);
$this->assertCount(2, $result);
$this->assertContainsOnlyInstancesOf(AuditLogEntry::class, $result);
}
public function testFindByUserAppliesFilters(): void
{
$userId = Uuid::uuid4();
$tenantId = TenantId::generate();
$from = new DateTimeImmutable('2026-01-01');
$to = new DateTimeImmutable('2026-02-01');
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->method('select')->willReturnSelf();
$queryBuilder->method('from')->willReturnSelf();
$queryBuilder->method('where')->willReturnSelf();
$queryBuilder->method('orderBy')->willReturnSelf();
// Verify andWhere is called for each filter
$queryBuilder->expects($this->atLeast(4))
->method('andWhere')
->willReturnSelf();
// Verify setParameter is called with expected parameters
$capturedParams = [];
$queryBuilder->method('setParameter')
->willReturnCallback(static function (string $key, mixed $value) use ($queryBuilder, &$capturedParams) {
$capturedParams[$key] = $value;
return $queryBuilder;
});
// Verify pagination
$queryBuilder->expects($this->once())
->method('setMaxResults')
->with(50)
->willReturnSelf();
$queryBuilder->expects($this->once())
->method('setFirstResult')
->with(10)
->willReturnSelf();
$result = $this->createMock(Result::class);
$result->method('fetchAllAssociative')->willReturn([]);
$queryBuilder->method('executeQuery')->willReturn($result);
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$this->repository->findByUser(
$userId,
$tenantId,
$from,
$to,
'ConnexionReussie',
50,
10,
);
// Verify the parameters were captured
$this->assertArrayHasKey('user_id', $capturedParams);
$this->assertArrayHasKey('tenant_id', $capturedParams);
$this->assertArrayHasKey('from', $capturedParams);
$this->assertArrayHasKey('to', $capturedParams);
$this->assertArrayHasKey('event_type', $capturedParams);
$this->assertSame('ConnexionReussie', $capturedParams['event_type']);
}
public function testFindByResourceReturnsResults(): void
{
$aggregateId = Uuid::uuid4();
$tenantId = TenantId::generate();
$rows = [$this->createRowData()];
$queryBuilder = $this->createQueryBuilderMock($rows);
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$result = $this->repository->findByResource('Note', $aggregateId, $tenantId);
$this->assertCount(1, $result);
}
public function testFindByCorrelationIdReturnsResults(): void
{
$correlationId = Uuid::uuid4()->toString();
$tenantId = TenantId::generate();
$rows = [$this->createRowData()];
$queryBuilder = $this->createQueryBuilderMock($rows);
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$result = $this->repository->findByCorrelationId($correlationId, $tenantId);
$this->assertCount(1, $result);
}
public function testSearchWithAllFilters(): void
{
$tenantId = TenantId::generate();
$from = new DateTimeImmutable('2026-01-01');
$to = new DateTimeImmutable('2026-02-01');
$rows = [$this->createRowData()];
$queryBuilder = $this->createQueryBuilderMock($rows);
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$result = $this->repository->search(
$tenantId,
$from,
$to,
'ConnexionReussie',
'User',
100,
0,
);
$this->assertCount(1, $result);
}
public function testEmptyResultReturnsEmptyArray(): void
{
$userId = Uuid::uuid4();
$tenantId = TenantId::generate();
$queryBuilder = $this->createQueryBuilderMock([]);
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$result = $this->repository->findByUser($userId, $tenantId);
$this->assertSame([], $result);
}
/**
* @return array<string, mixed>
*/
private function createRowData(): array
{
return [
'id' => Uuid::uuid4()->toString(),
'aggregate_type' => 'User',
'aggregate_id' => Uuid::uuid4()->toString(),
'event_type' => 'ConnexionReussie',
'payload' => '{"email_hash":"abc123"}',
'metadata' => '{"tenant_id":"tenant-1","user_id":"user-1"}',
'occurred_at' => '2026-02-03T10:30:00+00:00',
'sequence_number' => '1',
];
}
/**
* @param list<array<string, mixed>> $rows
*/
private function createQueryBuilderMock(array $rows): QueryBuilder&MockObject
{
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->method('select')->willReturnSelf();
$queryBuilder->method('from')->willReturnSelf();
$queryBuilder->method('where')->willReturnSelf();
$queryBuilder->method('andWhere')->willReturnSelf();
$queryBuilder->method('setParameter')->willReturnSelf();
$queryBuilder->method('orderBy')->willReturnSelf();
$queryBuilder->method('setMaxResults')->willReturnSelf();
$queryBuilder->method('setFirstResult')->willReturnSelf();
$result = $this->createMock(Result::class);
$result->method('fetchAllAssociative')->willReturn($rows);
$queryBuilder->method('executeQuery')->willReturn($result);
return $queryBuilder;
}
}

View File

@@ -0,0 +1,354 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
use App\Shared\Domain\Clock;
use App\Shared\Domain\CorrelationId;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Audit\AuditLogger;
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId as InfrastructureTenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* @see Story 1.7 - T2: AuditLogger Service
*/
final class AuditLoggerTest extends TestCase
{
private Connection&MockObject $connection;
private TenantContext $tenantContext;
private TokenStorageInterface&MockObject $tokenStorage;
private RequestStack $requestStack;
private Clock&MockObject $clock;
private AuditLogger $auditLogger;
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->tenantContext = new TenantContext();
$this->tokenStorage = $this->createMock(TokenStorageInterface::class);
$this->requestStack = new RequestStack();
$this->clock = $this->createMock(Clock::class);
$this->auditLogger = new AuditLogger(
$this->connection,
$this->tenantContext,
$this->tokenStorage,
$this->requestStack,
$this->clock,
'test-secret',
);
CorrelationIdHolder::clear();
}
protected function tearDown(): void
{
CorrelationIdHolder::clear();
$this->tenantContext->clear();
}
public function testLogAuthenticationInsertsIntoDatabase(): void
{
$now = new DateTimeImmutable('2026-02-03 10:30:00');
$this->clock->method('now')->willReturn($now);
$this->tokenStorage->method('getToken')->willReturn(null);
$userId = Uuid::uuid4();
$this->connection->expects($this->once())
->method('insert')
->with(
'audit_log',
$this->callback(static function (array $data) use ($userId): bool {
return $data['aggregate_type'] === 'User'
&& $data['aggregate_id'] === $userId->toString()
&& $data['event_type'] === 'TestEvent'
&& $data['payload']['test_key'] === 'test_value';
}),
$this->anything(),
);
$this->auditLogger->logAuthentication('TestEvent', $userId, ['test_key' => 'test_value']);
}
public function testLogAuthenticationWithNullUserIdSetsNullAggregateId(): void
{
$now = new DateTimeImmutable('2026-02-03 10:30:00');
$this->clock->method('now')->willReturn($now);
$this->tokenStorage->method('getToken')->willReturn(null);
$this->connection->expects($this->once())
->method('insert')
->with(
'audit_log',
$this->callback(static fn (array $data): bool => $data['aggregate_id'] === null),
$this->anything(),
);
$this->auditLogger->logAuthentication('FailedLogin', null, []);
}
public function testLogDataChangeIncludesOldAndNewValues(): void
{
$now = new DateTimeImmutable('2026-02-03 10:30:00');
$this->clock->method('now')->willReturn($now);
$this->tokenStorage->method('getToken')->willReturn(null);
$aggregateId = Uuid::uuid4();
$this->connection->expects($this->once())
->method('insert')
->with(
'audit_log',
$this->callback(static function (array $data) use ($aggregateId): bool {
$payload = $data['payload'];
return $data['aggregate_type'] === 'Note'
&& $data['aggregate_id'] === $aggregateId->toString()
&& $data['event_type'] === 'NoteModified'
&& $payload['old_values']['value'] === 12.5
&& $payload['new_values']['value'] === 14.0
&& $payload['reason'] === 'Correction';
}),
$this->anything(),
);
$this->auditLogger->logDataChange(
'Note',
$aggregateId,
'NoteModified',
['value' => 12.5],
['value' => 14.0],
'Correction',
);
}
public function testLogExportIncludesExportDetails(): void
{
$now = new DateTimeImmutable('2026-02-03 10:30:00');
$this->clock->method('now')->willReturn($now);
$this->tokenStorage->method('getToken')->willReturn(null);
$this->connection->expects($this->once())
->method('insert')
->with(
'audit_log',
$this->callback(static function (array $data): bool {
$payload = $data['payload'];
return $data['aggregate_type'] === 'Export'
&& $data['event_type'] === 'ExportGenerated'
&& $payload['export_type'] === 'CSV'
&& $payload['record_count'] === 150
&& $payload['target'] === 'students_list';
}),
$this->anything(),
);
$this->auditLogger->logExport('CSV', 150, 'students_list');
}
public function testLogAccessIncludesResourceAndContext(): void
{
$now = new DateTimeImmutable('2026-02-03 10:30:00');
$this->clock->method('now')->willReturn($now);
$this->tokenStorage->method('getToken')->willReturn(null);
$resourceId = Uuid::uuid4();
$this->connection->expects($this->once())
->method('insert')
->with(
'audit_log',
$this->callback(static function (array $data) use ($resourceId): bool {
$payload = $data['payload'];
return $data['aggregate_type'] === 'Student'
&& $data['aggregate_id'] === $resourceId->toString()
&& $data['event_type'] === 'ResourceAccessed'
&& $payload['screen'] === 'profile'
&& $payload['action'] === 'view';
}),
$this->anything(),
);
$this->auditLogger->logAccess('Student', $resourceId, ['screen' => 'profile', 'action' => 'view']);
}
public function testMetadataIncludesTenantIdWhenAvailable(): void
{
$now = new DateTimeImmutable('2026-02-03 10:30:00');
$this->clock->method('now')->willReturn($now);
$this->tokenStorage->method('getToken')->willReturn(null);
$tenantId = TenantId::generate();
$this->setCurrentTenant($tenantId);
$this->connection->expects($this->once())
->method('insert')
->with(
'audit_log',
$this->callback(static function (array $data) use ($tenantId): bool {
$metadata = $data['metadata'];
return $metadata['tenant_id'] === (string) $tenantId;
}),
$this->anything(),
);
$this->auditLogger->logAuthentication('Test', null, []);
}
public function testMetadataIncludesCorrelationIdWhenSet(): void
{
$now = new DateTimeImmutable('2026-02-03 10:30:00');
$this->clock->method('now')->willReturn($now);
$this->tokenStorage->method('getToken')->willReturn(null);
$correlationId = CorrelationId::generate();
CorrelationIdHolder::set($correlationId);
$this->connection->expects($this->once())
->method('insert')
->with(
'audit_log',
$this->callback(static function (array $data) use ($correlationId): bool {
$metadata = $data['metadata'];
return $metadata['correlation_id'] === $correlationId->value();
}),
$this->anything(),
);
$this->auditLogger->logAuthentication('Test', null, []);
}
public function testMetadataIncludesHashedIpFromRequest(): void
{
$now = new DateTimeImmutable('2026-02-03 10:30:00');
$this->clock->method('now')->willReturn($now);
$this->tokenStorage->method('getToken')->willReturn(null);
$request = Request::create('/test');
$request->server->set('REMOTE_ADDR', '192.168.1.100');
$this->requestStack->push($request);
$expectedIpHash = hash('sha256', '192.168.1.100test-secret');
$this->connection->expects($this->once())
->method('insert')
->with(
'audit_log',
$this->callback(static function (array $data) use ($expectedIpHash): bool {
$metadata = $data['metadata'];
return $metadata['ip_hash'] === $expectedIpHash;
}),
$this->anything(),
);
$this->auditLogger->logAuthentication('Test', null, []);
}
public function testMetadataIncludesUserAgentHash(): void
{
$now = new DateTimeImmutable('2026-02-03 10:30:00');
$this->clock->method('now')->willReturn($now);
$this->tokenStorage->method('getToken')->willReturn(null);
$request = Request::create('/test');
$request->headers->set('User-Agent', 'Mozilla/5.0 TestBrowser');
$this->requestStack->push($request);
$expectedUaHash = hash('sha256', 'Mozilla/5.0 TestBrowser');
$this->connection->expects($this->once())
->method('insert')
->with(
'audit_log',
$this->callback(static function (array $data) use ($expectedUaHash): bool {
$metadata = $data['metadata'];
return $metadata['user_agent_hash'] === $expectedUaHash;
}),
$this->anything(),
);
$this->auditLogger->logAuthentication('Test', null, []);
}
public function testLogAuthenticationWithTenantIdOverride(): void
{
$now = new DateTimeImmutable('2026-02-03 10:30:00');
$this->clock->method('now')->willReturn($now);
$this->tokenStorage->method('getToken')->willReturn(null);
$overrideTenantId = 'override-tenant-uuid-1234';
$this->connection->expects($this->once())
->method('insert')
->with(
'audit_log',
$this->callback(static function (array $data) use ($overrideTenantId): bool {
$metadata = $data['metadata'];
return $metadata['tenant_id'] === $overrideTenantId;
}),
$this->anything(),
);
// No TenantContext set, but override should be used
$this->auditLogger->logAuthentication('Test', null, [], $overrideTenantId);
}
public function testTenantIdOverrideTakesPrecedenceOverContext(): void
{
$now = new DateTimeImmutable('2026-02-03 10:30:00');
$this->clock->method('now')->willReturn($now);
$this->tokenStorage->method('getToken')->willReturn(null);
// Set a tenant in context
$contextTenantId = TenantId::generate();
$this->setCurrentTenant($contextTenantId);
// But use a different override
$overrideTenantId = 'override-tenant-uuid-5678';
$this->connection->expects($this->once())
->method('insert')
->with(
'audit_log',
$this->callback(static function (array $data) use ($overrideTenantId): bool {
$metadata = $data['metadata'];
// Override should take precedence over context
return $metadata['tenant_id'] === $overrideTenantId;
}),
$this->anything(),
);
$this->auditLogger->logAuthentication('Test', null, [], $overrideTenantId);
}
private function setCurrentTenant(TenantId $tenantId): void
{
$config = new TenantConfig(
tenantId: InfrastructureTenantId::fromString((string) $tenantId),
subdomain: 'test-tenant',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_test',
);
$this->tenantContext->setCurrentTenant($config);
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
use App\Shared\Domain\CorrelationId;
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
use PHPUnit\Framework\TestCase;
/**
* @see Story 1.7 - T3: Correlation ID
*/
final class CorrelationIdHolderTest extends TestCase
{
protected function tearDown(): void
{
CorrelationIdHolder::clear();
}
public function testInitiallyReturnsNull(): void
{
CorrelationIdHolder::clear();
$this->assertNull(CorrelationIdHolder::get());
}
public function testSetAndGetCorrelationId(): void
{
$correlationId = CorrelationId::generate();
CorrelationIdHolder::set($correlationId);
$this->assertSame($correlationId, CorrelationIdHolder::get());
}
public function testClearRemovesCorrelationId(): void
{
CorrelationIdHolder::set(CorrelationId::generate());
CorrelationIdHolder::clear();
$this->assertNull(CorrelationIdHolder::get());
}
public function testSetOverwritesPreviousValue(): void
{
$first = CorrelationId::generate();
$second = CorrelationId::generate();
CorrelationIdHolder::set($first);
CorrelationIdHolder::set($second);
$this->assertSame($second, CorrelationIdHolder::get());
}
public function testGetOrGenerateReturnsExistingId(): void
{
$existing = CorrelationId::generate();
CorrelationIdHolder::set($existing);
$result = CorrelationIdHolder::getOrGenerate();
$this->assertSame($existing, $result);
}
public function testGetOrGenerateCreatesNewIdWhenNoneSet(): void
{
CorrelationIdHolder::clear();
$result = CorrelationIdHolder::getOrGenerate();
$this->assertNotNull($result);
$this->assertInstanceOf(CorrelationId::class, $result);
}
public function testGetOrGenerateStoresGeneratedId(): void
{
CorrelationIdHolder::clear();
$first = CorrelationIdHolder::getOrGenerate();
$second = CorrelationIdHolder::getOrGenerate();
// Should return the same generated ID, not create a new one each time
$this->assertSame($first, $second);
}
}

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
use App\Shared\Domain\CorrelationId;
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
use App\Shared\Infrastructure\Middleware\CorrelationIdMiddleware;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* @see Story 1.7 - T3: Correlation ID
*/
final class CorrelationIdMiddlewareTest extends TestCase
{
private CorrelationIdMiddleware $middleware;
protected function setUp(): void
{
$this->middleware = new CorrelationIdMiddleware();
CorrelationIdHolder::clear();
}
protected function tearDown(): void
{
CorrelationIdHolder::clear();
}
public function testGeneratesCorrelationIdWhenHeaderMissing(): void
{
$request = Request::create('/test');
$event = $this->createRequestEvent($request, true);
$this->middleware->onKernelRequest($event);
$correlationId = CorrelationIdHolder::get();
$this->assertNotNull($correlationId);
$this->assertTrue(Uuid::isValid($correlationId->value()));
}
public function testUsesExistingCorrelationIdFromHeader(): void
{
$existingId = '550e8400-e29b-41d4-a716-446655440000';
$request = Request::create('/test');
$request->headers->set('X-Correlation-Id', $existingId);
$event = $this->createRequestEvent($request, true);
$this->middleware->onKernelRequest($event);
$correlationId = CorrelationIdHolder::get();
$this->assertNotNull($correlationId);
$this->assertSame($existingId, $correlationId->value());
}
public function testGeneratesNewIdWhenHeaderContainsInvalidUuid(): void
{
$request = Request::create('/test');
$request->headers->set('X-Correlation-Id', 'not-a-valid-uuid');
$event = $this->createRequestEvent($request, true);
$this->middleware->onKernelRequest($event);
$correlationId = CorrelationIdHolder::get();
$this->assertNotNull($correlationId);
// Should have generated a new valid UUID, not kept the invalid one
$this->assertTrue(Uuid::isValid($correlationId->value()));
$this->assertNotSame('not-a-valid-uuid', $correlationId->value());
}
public function testStoresCorrelationIdInRequestAttributes(): void
{
$request = Request::create('/test');
$event = $this->createRequestEvent($request, true);
$this->middleware->onKernelRequest($event);
$this->assertInstanceOf(CorrelationId::class, $request->attributes->get('correlation_id'));
}
public function testIgnoresSubRequests(): void
{
$request = Request::create('/test');
$event = $this->createRequestEvent($request, false);
$this->middleware->onKernelRequest($event);
$this->assertNull(CorrelationIdHolder::get());
}
public function testAddsCorrelationIdHeaderToResponse(): void
{
$correlationId = CorrelationId::generate();
CorrelationIdHolder::set($correlationId);
$request = Request::create('/test');
$response = new Response();
$event = $this->createResponseEvent($request, $response, true);
$this->middleware->onKernelResponse($event);
$this->assertSame(
$correlationId->value(),
$response->headers->get('X-Correlation-Id'),
);
}
public function testDoesNotAddHeaderToSubRequestResponse(): void
{
$correlationId = CorrelationId::generate();
CorrelationIdHolder::set($correlationId);
$request = Request::create('/test');
$response = new Response();
$event = $this->createResponseEvent($request, $response, false);
$this->middleware->onKernelResponse($event);
$this->assertNull($response->headers->get('X-Correlation-Id'));
}
public function testClearsCorrelationIdOnTerminate(): void
{
CorrelationIdHolder::set(CorrelationId::generate());
$request = Request::create('/test');
$response = new Response();
$event = $this->createTerminateEvent($request, $response);
$this->middleware->onKernelTerminate($event);
$this->assertNull(CorrelationIdHolder::get());
}
public function testDefensiveClearOnNewRequest(): void
{
// Simulate a stale correlation ID from a previous request that didn't clean up
$staleId = CorrelationId::generate();
CorrelationIdHolder::set($staleId);
$request = Request::create('/test');
$event = $this->createRequestEvent($request, true);
$this->middleware->onKernelRequest($event);
// Should have a NEW correlation ID, not the stale one
$newId = CorrelationIdHolder::get();
$this->assertNotNull($newId);
$this->assertNotSame($staleId->value(), $newId->value());
}
private function createRequestEvent(Request $request, bool $isMainRequest): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
return new RequestEvent(
$kernel,
$request,
$isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST,
);
}
private function createResponseEvent(Request $request, Response $response, bool $isMainRequest): ResponseEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
return new ResponseEvent(
$kernel,
$request,
$isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST,
$response,
);
}
private function createTerminateEvent(Request $request, Response $response): TerminateEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
return new TerminateEvent($kernel, $request, $response);
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Audit\Handler;
use App\Administration\Domain\Event\CompteBloqueTemporairement;
use App\Administration\Domain\Event\ConnexionEchouee;
use App\Administration\Domain\Event\ConnexionReussie;
use App\Administration\Domain\Event\MotDePasseChange;
use App\Shared\Application\Port\AuditLogger;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Audit\Handler\AuditAuthenticationHandler;
use DateTimeImmutable;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
/**
* @see Story 1.7 - T4: Listeners Authentification
*/
final class AuditAuthenticationHandlerTest extends TestCase
{
private AuditLogger&MockObject $auditLogger;
private AuditAuthenticationHandler $handler;
protected function setUp(): void
{
$this->auditLogger = $this->createMock(AuditLogger::class);
$this->handler = new AuditAuthenticationHandler(
$this->auditLogger,
'test-secret',
);
}
public function testHandleConnexionReussieLogsSuccessfulLogin(): void
{
$userId = Uuid::uuid4()->toString();
$event = new ConnexionReussie(
userId: $userId,
email: 'user@example.com',
tenantId: TenantId::generate(),
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
occurredOn: new DateTimeImmutable(),
);
$this->auditLogger->expects($this->once())
->method('logAuthentication')
->with(
$this->equalTo('ConnexionReussie'),
$this->callback(static fn ($uuid) => $uuid->toString() === $userId),
$this->callback(static fn ($payload) => $payload['result'] === 'success'
&& $payload['method'] === 'password'
&& isset($payload['email_hash'])
),
);
$this->handler->handleConnexionReussie($event);
}
public function testHandleConnexionEchoueeLogsFailedLogin(): void
{
$tenantId = TenantId::generate();
$event = new ConnexionEchouee(
email: 'user@example.com',
tenantId: $tenantId,
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
reason: 'invalid_credentials',
occurredOn: new DateTimeImmutable(),
);
$this->auditLogger->expects($this->once())
->method('logAuthentication')
->with(
$this->equalTo('ConnexionEchouee'),
$this->isNull(),
$this->callback(static fn ($payload) => $payload['result'] === 'failure'
&& $payload['reason'] === 'invalid_credentials'
&& isset($payload['email_hash'])
),
$this->equalTo((string) $tenantId),
);
$this->handler->handleConnexionEchouee($event);
}
public function testHandleCompteBloqueTemporairementLogsLockout(): void
{
$tenantId = TenantId::generate();
$event = new CompteBloqueTemporairement(
email: 'user@example.com',
tenantId: $tenantId,
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
blockedForSeconds: 300,
failedAttempts: 5,
occurredOn: new DateTimeImmutable(),
);
$this->auditLogger->expects($this->once())
->method('logAuthentication')
->with(
$this->equalTo('CompteBloqueTemporairement'),
$this->isNull(),
$this->callback(static fn ($payload) => $payload['blocked_for_seconds'] === 300
&& $payload['failed_attempts'] === 5
&& isset($payload['email_hash'])
),
$this->equalTo((string) $tenantId),
);
$this->handler->handleCompteBloqueTemporairement($event);
}
public function testHandleMotDePasseChangeLogsPasswordChange(): void
{
$userId = Uuid::uuid4()->toString();
$event = new MotDePasseChange(
userId: $userId,
email: 'user@example.com',
tenantId: TenantId::generate(),
occurredOn: new DateTimeImmutable(),
);
$this->auditLogger->expects($this->once())
->method('logAuthentication')
->with(
$this->equalTo('MotDePasseChange'),
$this->callback(static fn ($uuid) => $uuid->toString() === $userId),
$this->callback(static fn ($payload) => isset($payload['email_hash'])),
);
$this->handler->handleMotDePasseChange($event);
}
public function testEmailIsHashedConsistently(): void
{
$email = 'user@example.com';
$expectedHash = hash('sha256', strtolower($email) . 'test-secret');
$capturedPayload = null;
$this->auditLogger->expects($this->once())
->method('logAuthentication')
->willReturnCallback(static function ($eventType, $userId, $payload) use (&$capturedPayload) {
$capturedPayload = $payload;
});
$event = new ConnexionReussie(
userId: Uuid::uuid4()->toString(),
email: $email,
tenantId: TenantId::generate(),
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
occurredOn: new DateTimeImmutable(),
);
$this->handler->handleConnexionReussie($event);
$this->assertSame($expectedHash, $capturedPayload['email_hash']);
}
public function testEmailHashIsCaseInsensitive(): void
{
$lowerEmail = 'user@example.com';
$upperEmail = 'USER@EXAMPLE.COM';
$expectedHash = hash('sha256', strtolower($lowerEmail) . 'test-secret');
$payloads = [];
$this->auditLogger->expects($this->exactly(2))
->method('logAuthentication')
->willReturnCallback(static function ($eventType, $userId, $payload) use (&$payloads) {
$payloads[] = $payload;
});
$event1 = new ConnexionReussie(
userId: Uuid::uuid4()->toString(),
email: $lowerEmail,
tenantId: TenantId::generate(),
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
occurredOn: new DateTimeImmutable(),
);
$event2 = new ConnexionReussie(
userId: Uuid::uuid4()->toString(),
email: $upperEmail,
tenantId: TenantId::generate(),
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
occurredOn: new DateTimeImmutable(),
);
$this->handler->handleConnexionReussie($event1);
$this->handler->handleConnexionReussie($event2);
$this->assertSame($payloads[0]['email_hash'], $payloads[1]['email_hash']);
}
}

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Console;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Console\ArchiveAuditLogsCommand;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @see Story 1.7 - T8: Archivage
*/
final class ArchiveAuditLogsCommandTest extends TestCase
{
private Connection&MockObject $connection;
private Clock&MockObject $clock;
private ArchiveAuditLogsCommand $command;
private CommandTester $commandTester;
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->clock = $this->createMock(Clock::class);
$this->command = new ArchiveAuditLogsCommand(
$this->connection,
$this->clock,
);
$this->commandTester = new CommandTester($this->command);
}
public function testCommandNameIsCorrect(): void
{
$this->assertSame('app:audit:archive', $this->command->getName());
}
public function testCommandDescription(): void
{
$this->assertSame(
'Archive audit log entries older than 5 years',
$this->command->getDescription(),
);
}
public function testNoEntriesToArchiveReturnsSuccess(): void
{
$now = new DateTimeImmutable('2026-02-03 10:00:00');
$this->clock->method('now')->willReturn($now);
$this->connection->method('fetchOne')
->willReturnOnConsecutiveCalls(0); // COUNT returns 0
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('No entries to archive', $output);
$this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
}
public function testDryRunDoesNotCallArchiveFunction(): void
{
$now = new DateTimeImmutable('2026-02-03 10:00:00');
$this->clock->method('now')->willReturn($now);
$this->connection->expects($this->once())
->method('fetchOne')
->willReturn(100); // 100 entries to archive
// archive_audit_entries should NOT be called in dry-run mode
$this->connection->expects($this->never())
->method('executeStatement');
$this->commandTester->execute(['--dry-run' => true]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('DRY RUN', $output);
$this->assertStringContainsString('Would archive 100 entries', $output);
$this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
}
public function testArchivesBatchesUntilComplete(): void
{
$now = new DateTimeImmutable('2026-02-03 10:00:00');
$this->clock->method('now')->willReturn($now);
// First call: COUNT returns 150
// Subsequent calls: archive_audit_entries returns batch counts
$this->connection->method('fetchOne')
->willReturnOnConsecutiveCalls(
150, // COUNT query
100, // First batch (full)
50, // Second batch (partial, stops)
);
$this->commandTester->execute(['--batch-size' => '100']);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Successfully archived 150', $output);
$this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
}
public function testCustomRetentionYears(): void
{
$now = new DateTimeImmutable('2026-02-03 10:00:00');
$this->clock->method('now')->willReturn($now);
$this->connection->method('fetchOne')->willReturn(0);
$this->commandTester->execute(['--retention-years' => '3']);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('(3 years retention)', $output);
}
public function testCustomBatchSize(): void
{
$now = new DateTimeImmutable('2026-02-03 10:00:00');
$this->clock->method('now')->willReturn($now);
// Return 500 entries to archive, then archive in 500-entry batches
$this->connection->method('fetchOne')
->willReturnOnConsecutiveCalls(
500, // COUNT
500, // First batch (equal to batch size)
0, // Second batch (none left)
);
$this->commandTester->execute(['--batch-size' => '500']);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Successfully archived 500', $output);
}
public function testShowsProgressBar(): void
{
$now = new DateTimeImmutable('2026-02-03 10:00:00');
$this->clock->method('now')->willReturn($now);
$this->connection->method('fetchOne')
->willReturnOnConsecutiveCalls(
50, // COUNT
50, // First batch
);
$this->commandTester->execute([]);
// Progress bar output includes percentage
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Successfully archived 50', $output);
}
public function testCalculatesCutoffDateCorrectly(): void
{
$now = new DateTimeImmutable('2026-02-03 10:00:00');
$this->clock->method('now')->willReturn($now);
$capturedCutoff = null;
$this->connection->method('fetchOne')
->willReturnCallback(static function (string $sql, array $params) use (&$capturedCutoff) {
if (str_contains($sql, 'COUNT')) {
$capturedCutoff = $params['cutoff'];
return 0;
}
return 0;
});
$this->commandTester->execute(['--retention-years' => '5']);
// Cutoff should be 5 years before now (2021-02-03)
$this->assertNotNull($capturedCutoff);
$this->assertStringContainsString('2021-02-03', $capturedCutoff);
}
public function testZeroBatchSizeReturnsFailure(): void
{
$now = new DateTimeImmutable('2026-02-03 10:00:00');
$this->clock->method('now')->willReturn($now);
$this->commandTester->execute(['--batch-size' => '0']);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Batch size must be a positive integer', $output);
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
}
public function testNegativeBatchSizeReturnsFailure(): void
{
$now = new DateTimeImmutable('2026-02-03 10:00:00');
$this->clock->method('now')->willReturn($now);
$this->commandTester->execute(['--batch-size' => '-5']);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Batch size must be a positive integer', $output);
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
}
public function testZeroRetentionYearsReturnsFailure(): void
{
$this->commandTester->execute(['--retention-years' => '0']);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Retention years must be a positive integer', $output);
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
}
public function testNegativeRetentionYearsReturnsFailure(): void
{
$this->commandTester->execute(['--retention-years' => '-5']);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Retention years must be a positive integer', $output);
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
use App\Shared\Domain\CorrelationId;
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
use App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware;
use App\Shared\Infrastructure\Messenger\CorrelationIdStamp;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
/**
* @see Story 1.7 - T3: Correlation ID
*/
final class AddCorrelationIdStampMiddlewareTest extends TestCase
{
private AddCorrelationIdStampMiddleware $middleware;
protected function setUp(): void
{
$this->middleware = new AddCorrelationIdStampMiddleware();
CorrelationIdHolder::clear();
}
protected function tearDown(): void
{
CorrelationIdHolder::clear();
}
public function testAddsStampWhenCorrelationIdIsSet(): void
{
$correlationId = CorrelationId::generate();
CorrelationIdHolder::set($correlationId);
$envelope = new Envelope(new stdClass());
$stack = $this->createCapturingStack();
$result = $this->middleware->handle($envelope, $stack);
$stamp = $result->last(CorrelationIdStamp::class);
$this->assertInstanceOf(CorrelationIdStamp::class, $stamp);
$this->assertSame($correlationId->value(), $stamp->correlationId);
}
public function testDoesNotAddStampWhenNoCorrelationId(): void
{
CorrelationIdHolder::clear();
$envelope = new Envelope(new stdClass());
$stack = $this->createCapturingStack();
$result = $this->middleware->handle($envelope, $stack);
$stamp = $result->last(CorrelationIdStamp::class);
$this->assertNull($stamp);
}
public function testDoesNotOverwriteExistingStamp(): void
{
$existingId = '11111111-1111-1111-1111-111111111111';
$currentId = CorrelationId::generate();
CorrelationIdHolder::set($currentId);
$envelope = new Envelope(new stdClass(), [new CorrelationIdStamp($existingId)]);
$stack = $this->createCapturingStack();
$result = $this->middleware->handle($envelope, $stack);
$stamp = $result->last(CorrelationIdStamp::class);
$this->assertInstanceOf(CorrelationIdStamp::class, $stamp);
// Should keep the existing stamp, not overwrite with current
$this->assertSame($existingId, $stamp->correlationId);
}
private function createCapturingStack(): StackInterface
{
return new class implements StackInterface {
public function next(): MiddlewareInterface
{
return new class implements MiddlewareInterface {
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
return $envelope;
}
};
}
};
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
use App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware;
use App\Shared\Infrastructure\Messenger\CorrelationIdStamp;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
use RuntimeException;
use stdClass;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
/**
* @see Story 1.7 - T3: Correlation ID
*/
final class CorrelationIdMiddlewareTest extends TestCase
{
private CorrelationIdMiddleware $middleware;
protected function setUp(): void
{
$this->middleware = new CorrelationIdMiddleware();
CorrelationIdHolder::clear();
}
protected function tearDown(): void
{
CorrelationIdHolder::clear();
}
public function testUsesCorrelationIdFromStampWhenPresent(): void
{
$expectedId = '550e8400-e29b-41d4-a716-446655440000';
$envelope = new Envelope(new stdClass(), [new CorrelationIdStamp($expectedId)]);
$stack = $this->createCapturingStack();
$this->middleware->handle($envelope, $stack);
// Verify the correlation ID was set during handling
$this->assertSame($expectedId, $stack->capturedCorrelationId);
}
public function testGeneratesNewCorrelationIdWhenNoStampAndNoExistingId(): void
{
// Async worker context: no existing ID, no stamp
$envelope = new Envelope(new stdClass());
$stack = $this->createCapturingStack();
$this->middleware->handle($envelope, $stack);
// Should have generated a valid UUID
$this->assertNotNull($stack->capturedCorrelationId);
$this->assertTrue(Uuid::isValid($stack->capturedCorrelationId));
}
public function testUsesExistingCorrelationIdDuringSynchronousDispatch(): void
{
// HTTP context: existing ID is set by HTTP middleware
$existingId = '99999999-9999-9999-9999-999999999999';
CorrelationIdHolder::set(\App\Shared\Domain\CorrelationId::fromString($existingId));
$envelope = new Envelope(new stdClass());
$stack = $this->createCapturingStack();
$this->middleware->handle($envelope, $stack);
// Should use the existing ID, not generate a new one
$this->assertSame($existingId, $stack->capturedCorrelationId);
}
public function testDoesNotClearCorrelationIdDuringSynchronousDispatch(): void
{
// HTTP context: existing ID should be preserved after dispatch
$existingId = '99999999-9999-9999-9999-999999999999';
CorrelationIdHolder::set(\App\Shared\Domain\CorrelationId::fromString($existingId));
$envelope = new Envelope(new stdClass());
$stack = $this->createPassthroughStack();
$this->middleware->handle($envelope, $stack);
// Should NOT be cleared - HTTP middleware handles that
$this->assertNotNull(CorrelationIdHolder::get());
$this->assertSame($existingId, CorrelationIdHolder::get()?->value());
}
public function testClearsCorrelationIdInAsyncContext(): void
{
// Async worker context: no existing ID, no stamp
$envelope = new Envelope(new stdClass());
$stack = $this->createPassthroughStack();
$this->middleware->handle($envelope, $stack);
// Should be cleared after handling in async context
$this->assertNull(CorrelationIdHolder::get());
}
public function testClearsCorrelationIdWhenStampPresent(): void
{
// Async worker receiving message from HTTP: has stamp
$envelope = new Envelope(new stdClass(), [new CorrelationIdStamp('11111111-1111-1111-1111-111111111111')]);
$stack = $this->createPassthroughStack();
$this->middleware->handle($envelope, $stack);
// Should be cleared after handling (async context)
$this->assertNull(CorrelationIdHolder::get());
}
public function testClearsCorrelationIdEvenOnException(): void
{
// Async worker context
$envelope = new Envelope(new stdClass());
$stack = $this->createThrowingStack();
try {
$this->middleware->handle($envelope, $stack);
$this->fail('Expected exception to be thrown');
} catch (RuntimeException) {
// Expected
}
// Should be cleared even after exception in async context
$this->assertNull(CorrelationIdHolder::get());
}
public function testDoesNotLeakBetweenMessages(): void
{
$envelope1 = new Envelope(new stdClass(), [new CorrelationIdStamp('11111111-1111-1111-1111-111111111111')]);
$envelope2 = new Envelope(new stdClass(), [new CorrelationIdStamp('22222222-2222-2222-2222-222222222222')]);
$stack1 = $this->createCapturingStack();
$stack2 = $this->createCapturingStack();
$this->middleware->handle($envelope1, $stack1);
$this->middleware->handle($envelope2, $stack2);
$this->assertSame('11111111-1111-1111-1111-111111111111', $stack1->capturedCorrelationId);
$this->assertSame('22222222-2222-2222-2222-222222222222', $stack2->capturedCorrelationId);
}
private function createCapturingStack(): CapturingStack
{
return new CapturingStack();
}
private function createPassthroughStack(): StackInterface
{
return new class implements StackInterface {
public function next(): MiddlewareInterface
{
return new class implements MiddlewareInterface {
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
return $envelope;
}
};
}
};
}
private function createThrowingStack(): StackInterface
{
return new class implements StackInterface {
public function next(): MiddlewareInterface
{
return new class implements MiddlewareInterface {
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
throw new RuntimeException('Handler failed');
}
};
}
};
}
}
/**
* Stack that captures the correlation ID during handling.
*/
final class CapturingStack implements StackInterface
{
public ?string $capturedCorrelationId = null;
public function next(): MiddlewareInterface
{
return new CapturingMiddleware($this);
}
}
/**
* @internal
*/
final class CapturingMiddleware implements MiddlewareInterface
{
public function __construct(private CapturingStack $stack)
{
}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$this->stack->capturedCorrelationId = CorrelationIdHolder::get()?->value();
return $envelope;
}
}