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