security = $this->createMock(Security::class); $this->sessionRepository = $this->createMock(SessionRepository::class); $this->refreshTokenRepository = $this->createMock(RefreshTokenRepository::class); $this->eventBus = $this->createMock(MessageBusInterface::class); $this->clock = $this->createMock(Clock::class); $this->controller = new SessionsController( $this->security, $this->sessionRepository, $this->refreshTokenRepository, $this->eventBus, $this->clock, ); } #[Test] public function revokeReturnsNotFoundWhenFamilyIdIsMalformed(): void { $this->mockAuthenticatedUser(); // Assert: SessionRepository::getByFamilyId() should NEVER be called // because we fail before any business logic (InvalidArgumentException on UUID parse) $this->sessionRepository ->expects($this->never()) ->method('getByFamilyId'); $this->expectException(NotFoundHttpException::class); $this->expectExceptionMessage('Session not found'); $request = Request::create('/api/me/sessions/not-a-uuid', 'DELETE'); $this->controller->revoke('not-a-uuid', $request); } #[Test] public function revokeReturnsNotFoundWhenSessionDoesNotExist(): void { $this->mockAuthenticatedUser(); $familyId = TokenFamilyId::generate(); $this->sessionRepository ->expects($this->once()) ->method('getByFamilyId') ->with($this->callback(static fn (TokenFamilyId $id) => $id->equals($familyId))) ->willThrowException(new SessionNotFoundException($familyId)); $this->expectException(NotFoundHttpException::class); $this->expectExceptionMessage('Session not found'); $request = Request::create('/api/me/sessions/' . $familyId, 'DELETE'); $this->controller->revoke((string) $familyId, $request); } #[Test] public function listReturnsSessionsForCurrentUserAndTenant(): void { $this->mockAuthenticatedUser(); $familyId = TokenFamilyId::generate(); $userId = UserId::fromString(self::USER_ID); $tenantId = TenantId::fromString(self::TENANT_ID); $now = new DateTimeImmutable('2024-01-15 10:00:00'); $session = Session::create( familyId: $familyId, userId: $userId, tenantId: $tenantId, deviceInfo: DeviceInfo::reconstitute('Desktop', 'Chrome 120', 'Windows 10', 'Mozilla/5.0'), location: Location::fromIp('192.168.1.1', 'France', 'Paris'), createdAt: $now, ); $this->sessionRepository ->expects($this->once()) ->method('findAllByUserId') ->with($this->callback(static fn (UserId $id) => $id->equals($userId))) ->willReturn([$session]); $request = Request::create('/api/me/sessions', 'GET'); $response = $this->controller->list($request); $data = json_decode($response->getContent(), true); self::assertSame(200, $response->getStatusCode()); self::assertCount(1, $data['sessions']); self::assertSame((string) $familyId, $data['sessions'][0]['family_id']); self::assertSame('Desktop', $data['sessions'][0]['device']); self::assertSame('Chrome 120', $data['sessions'][0]['browser']); self::assertSame('Windows 10', $data['sessions'][0]['os']); self::assertSame('France, Paris', $data['sessions'][0]['location']); self::assertFalse($data['sessions'][0]['is_current']); } #[Test] public function listFilterOutSessionsFromOtherTenants(): void { $this->mockAuthenticatedUser(); $userId = UserId::fromString(self::USER_ID); $currentTenantId = TenantId::fromString(self::TENANT_ID); $otherTenantId = TenantId::fromString(self::OTHER_TENANT_ID); $now = new DateTimeImmutable('2024-01-15 10:00:00'); $sessionSameTenant = Session::create( familyId: TokenFamilyId::generate(), userId: $userId, tenantId: $currentTenantId, deviceInfo: DeviceInfo::reconstitute('Desktop', 'Chrome 120', 'Windows 10', 'Mozilla/5.0'), location: Location::unknown(), createdAt: $now, ); $sessionOtherTenant = Session::create( familyId: TokenFamilyId::generate(), userId: $userId, tenantId: $otherTenantId, deviceInfo: DeviceInfo::reconstitute('Mobile', 'Safari 17', 'iOS 17', 'iPhone'), location: Location::unknown(), createdAt: $now, ); $this->sessionRepository ->method('findAllByUserId') ->willReturn([$sessionSameTenant, $sessionOtherTenant]); $request = Request::create('/api/me/sessions', 'GET'); $response = $this->controller->list($request); $data = json_decode($response->getContent(), true); // Should only return the session from the current tenant self::assertCount(1, $data['sessions']); self::assertSame('Desktop', $data['sessions'][0]['device']); } #[Test] public function listMarksCurrentSessionCorrectly(): void { $this->mockAuthenticatedUser(); $currentFamilyId = TokenFamilyId::generate(); $otherFamilyId = TokenFamilyId::generate(); $userId = UserId::fromString(self::USER_ID); $tenantId = TenantId::fromString(self::TENANT_ID); $now = new DateTimeImmutable('2024-01-15 10:00:00'); $currentSession = Session::create( familyId: $currentFamilyId, userId: $userId, tenantId: $tenantId, deviceInfo: DeviceInfo::reconstitute('Desktop', 'Chrome 120', 'Windows 10', 'UA1'), location: Location::unknown(), createdAt: $now, ); $otherSession = Session::create( familyId: $otherFamilyId, userId: $userId, tenantId: $tenantId, deviceInfo: DeviceInfo::reconstitute('Mobile', 'Safari 17', 'iOS 17', 'UA2'), location: Location::unknown(), createdAt: $now, ); $this->sessionRepository ->method('findAllByUserId') ->willReturn([$currentSession, $otherSession]); // Create a real RefreshToken to identify the current session $refreshToken = RefreshToken::reconstitute( id: RefreshTokenId::generate(), familyId: $currentFamilyId, userId: $userId, tenantId: $tenantId, deviceFingerprint: DeviceFingerprint::fromString('test-fingerprint'), issuedAt: $now, expiresAt: $now->modify('+1 day'), rotatedFrom: null, isRotated: false, ); $this->refreshTokenRepository ->method('find') ->willReturn($refreshToken); // Create request with refresh token cookie $tokenValue = $refreshToken->toTokenString(); $request = Request::create('/api/me/sessions', 'GET'); $request->cookies->set('refresh_token', $tokenValue); $response = $this->controller->list($request); $data = json_decode($response->getContent(), true); // Current session should be first (sorted) and marked as current self::assertCount(2, $data['sessions']); self::assertTrue($data['sessions'][0]['is_current']); self::assertSame((string) $currentFamilyId, $data['sessions'][0]['family_id']); self::assertFalse($data['sessions'][1]['is_current']); } #[Test] public function revokeAllDeletesAllSessionsExceptCurrent(): void { $this->mockAuthenticatedUser(); $currentFamilyId = TokenFamilyId::generate(); $deletedFamilyId1 = TokenFamilyId::generate(); $deletedFamilyId2 = TokenFamilyId::generate(); $userId = UserId::fromString(self::USER_ID); $tenantId = TenantId::fromString(self::TENANT_ID); $now = new DateTimeImmutable('2024-01-15 10:00:00'); $this->clock->method('now')->willReturn($now); // Create a real RefreshToken for current session identification $refreshToken = RefreshToken::reconstitute( id: RefreshTokenId::generate(), familyId: $currentFamilyId, userId: $userId, tenantId: $tenantId, deviceFingerprint: DeviceFingerprint::fromString('test-fingerprint'), issuedAt: $now, expiresAt: $now->modify('+1 day'), rotatedFrom: null, isRotated: false, ); $this->refreshTokenRepository->method('find')->willReturn($refreshToken); // Mock deleteAllExcept $this->sessionRepository ->expects($this->once()) ->method('deleteAllExcept') ->with( $this->callback(static fn (UserId $id) => $id->equals($userId)), $this->callback(static fn (TokenFamilyId $id) => $id->equals($currentFamilyId)), ) ->willReturn([$deletedFamilyId1, $deletedFamilyId2]); // Expect invalidateFamily to be called for each deleted session $this->refreshTokenRepository ->expects($this->exactly(2)) ->method('invalidateFamily'); // Expect event to be dispatched $this->eventBus ->expects($this->once()) ->method('dispatch') ->with($this->isInstanceOf(ToutesSessionsInvalidees::class)) ->willReturn(new Envelope(new stdClass())); $tokenValue = $refreshToken->toTokenString(); $request = Request::create('/api/me/sessions', 'DELETE'); $request->cookies->set('refresh_token', $tokenValue); $request->headers->set('User-Agent', 'Test Browser'); $response = $this->controller->revokeAll($request); $data = json_decode($response->getContent(), true); self::assertSame(200, $response->getStatusCode()); self::assertSame('All other sessions revoked', $data['message']); self::assertSame(2, $data['revoked_count']); } #[Test] public function revokeAllDoesNotDispatchEventWhenNoSessionsRevoked(): void { $this->mockAuthenticatedUser(); $currentFamilyId = TokenFamilyId::generate(); $userId = UserId::fromString(self::USER_ID); $tenantId = TenantId::fromString(self::TENANT_ID); $now = new DateTimeImmutable('2024-01-15 10:00:00'); $this->clock->method('now')->willReturn($now); // Create a real RefreshToken $refreshToken = RefreshToken::reconstitute( id: RefreshTokenId::generate(), familyId: $currentFamilyId, userId: $userId, tenantId: $tenantId, deviceFingerprint: DeviceFingerprint::fromString('test-fingerprint'), issuedAt: $now, expiresAt: $now->modify('+1 day'), rotatedFrom: null, isRotated: false, ); $this->refreshTokenRepository->method('find')->willReturn($refreshToken); // No sessions to delete $this->sessionRepository ->method('deleteAllExcept') ->willReturn([]); // Event should NOT be dispatched $this->eventBus ->expects($this->never()) ->method('dispatch'); $tokenValue = $refreshToken->toTokenString(); $request = Request::create('/api/me/sessions', 'DELETE'); $request->cookies->set('refresh_token', $tokenValue); $response = $this->controller->revokeAll($request); $data = json_decode($response->getContent(), true); self::assertSame(200, $response->getStatusCode()); self::assertSame(0, $data['revoked_count']); } private function mockAuthenticatedUser(): void { $securityUser = new SecurityUser( UserId::fromString(self::USER_ID), 'test@example.com', 'hashed_password', TenantId::fromString(self::TENANT_ID), ['ROLE_USER'], ); $this->security->method('getUser')->willReturn($securityUser); } }