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:
@@ -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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user