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'], ); } }