request('POST', '/api/token/logout', [ 'headers' => [ 'Host' => 'localhost', 'Accept' => 'application/json', ], ]); // Should return 200 OK (graceful logout even without token) self::assertResponseStatusCodeSame(200); } #[Test] public function logoutEndpointReturnsSuccessMessage(): void { $client = static::createClient(); $client->request('POST', '/api/token/logout', [ 'headers' => [ 'Host' => 'localhost', 'Accept' => 'application/json', ], ]); self::assertResponseIsSuccessful(); self::assertJsonContains(['message' => 'Logout successful']); } #[Test] public function logoutEndpointDeletesRefreshTokenCookie(): void { $client = static::createClient(); $response = $client->request('POST', '/api/token/logout', [ 'headers' => [ 'Host' => 'localhost', 'Accept' => 'application/json', ], ]); // Check that Set-Cookie header is present (to delete the cookie) $headers = $response->getHeaders(false); self::assertArrayHasKey('set-cookie', $headers); // The cookie should be set to empty with past expiration $setCookieHeader = $headers['set-cookie'][0]; self::assertStringContainsString('refresh_token=', $setCookieHeader); } #[Test] public function logoutEndpointDeletesRefreshTokenCookieOnApiAndLegacyPaths(): void { $client = static::createClient(); $response = $client->request('POST', '/api/token/logout', [ 'headers' => [ 'Host' => 'localhost', 'Accept' => 'application/json', ], ]); $headers = $response->getHeaders(false); self::assertArrayHasKey('set-cookie', $headers); // Should have 2 Set-Cookie headers for refresh_token deletion $setCookieHeaders = $headers['set-cookie']; self::assertGreaterThanOrEqual(2, count($setCookieHeaders), 'Expected at least 2 Set-Cookie headers'); // Collect all refresh_token cookie paths $paths = []; foreach ($setCookieHeaders as $header) { if (stripos($header, 'refresh_token=') !== false) { // Extract path with case-insensitive matching if (preg_match('/path=([^;]+)/i', $header, $matches)) { $paths[] = trim($matches[1]); } } } // Must clear both /api (current) and /api/token (legacy) paths self::assertContains('/api', $paths, 'Missing Set-Cookie for Path=/api'); self::assertContains('/api/token', $paths, 'Missing Set-Cookie for Path=/api/token (legacy)'); } // ========================================================================= // Sessions list endpoint tests - Security // ========================================================================= /** * Without a valid tenant subdomain, the endpoint returns 404. * This is correct security behavior: don't reveal endpoint existence. */ #[Test] public function listSessionsReturns404WithoutTenant(): void { $client = static::createClient(); $client->request('GET', '/api/me/sessions', [ 'headers' => [ 'Host' => 'localhost', 'Accept' => 'application/json', ], ]); // Tenant middleware intercepts and returns 404 (not revealing endpoint) self::assertResponseStatusCodeSame(404); self::assertJsonContains(['message' => 'Resource not found']); } // ========================================================================= // Revoke single session endpoint tests - Security // ========================================================================= /** * Without a valid tenant subdomain, the endpoint returns 404. * This is correct security behavior: don't reveal endpoint existence. */ #[Test] public function revokeSessionReturns404WithoutTenant(): void { $client = static::createClient(); $client->request('DELETE', '/api/me/sessions/00000000-0000-0000-0000-000000000001', [ 'headers' => [ 'Host' => 'localhost', 'Accept' => 'application/json', ], ]); // Tenant middleware intercepts and returns 404 (not revealing endpoint) self::assertResponseStatusCodeSame(404); self::assertJsonContains(['message' => 'Resource not found']); } // ========================================================================= // Revoke all sessions endpoint tests - Security // ========================================================================= /** * Without a valid tenant subdomain, the endpoint returns 404. * This is correct security behavior: don't reveal endpoint existence. */ #[Test] public function revokeAllSessionsReturns404WithoutTenant(): void { $client = static::createClient(); $client->request('DELETE', '/api/me/sessions', [ 'headers' => [ 'Host' => 'localhost', 'Accept' => 'application/json', ], ]); // Tenant middleware intercepts and returns 404 (not revealing endpoint) self::assertResponseStatusCodeSame(404); self::assertJsonContains(['message' => 'Resource not found']); } // ========================================================================= // Authenticated session list tests // ========================================================================= /** * Test that listing sessions requires authentication. * Returns 401 when no JWT token is provided. */ #[Test] public function listSessionsReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', 'http://ecole-alpha.classeo.local/api/me/sessions', [ 'headers' => [ 'Accept' => 'application/json', ], ]); self::assertResponseStatusCodeSame(401); } /** * Test that revoking sessions requires authentication. * Returns 401 when no JWT token is provided. */ #[Test] public function revokeSessionReturns401WithoutAuthentication(): void { $client = static::createClient(); $familyId = TokenFamilyId::generate(); $client->request('DELETE', 'http://ecole-alpha.classeo.local/api/me/sessions/' . $familyId, [ 'headers' => [ 'Accept' => 'application/json', ], ]); self::assertResponseStatusCodeSame(401); } /** * Test that revoking all sessions requires authentication. * Returns 401 when no JWT token is provided. */ #[Test] public function revokeAllSessionsReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('DELETE', 'http://ecole-alpha.classeo.local/api/me/sessions', [ 'headers' => [ 'Accept' => 'application/json', ], ]); self::assertResponseStatusCodeSame(401); } /** * Test that the session repository correctly stores and retrieves sessions. * This is an integration test of the repository without HTTP layer. */ #[Test] public function sessionRepositoryCanStoreAndRetrieveSessions(): void { $container = static::getContainer(); $sessionRepository = $container->get(SessionRepository::class); $familyId = TokenFamilyId::generate(); $userId = UserId::fromString(self::USER_ID); $tenantId = TenantId::fromString(self::TENANT_ID); $session = Session::create( familyId: $familyId, userId: $userId, tenantId: $tenantId, deviceInfo: DeviceInfo::fromUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0'), location: Location::fromIp('192.168.1.1', 'France', 'Paris'), createdAt: new DateTimeImmutable(), ); $sessionRepository->save($session, 86400); // Retrieve by family ID $retrieved = $sessionRepository->findByFamilyId($familyId); self::assertNotNull($retrieved); self::assertTrue($retrieved->familyId->equals($familyId)); self::assertSame('Desktop', $retrieved->deviceInfo->device); self::assertSame('Chrome 120', $retrieved->deviceInfo->browser); // Retrieve by user ID $userSessions = $sessionRepository->findAllByUserId($userId); self::assertNotEmpty($userSessions); // Cleanup $sessionRepository->delete($familyId); self::assertNull($sessionRepository->findByFamilyId($familyId)); } /** * Test that deleteAllExcept removes other sessions but keeps the specified one. */ #[Test] public function sessionRepositoryDeleteAllExceptKeepsSpecifiedSession(): void { $container = static::getContainer(); $sessionRepository = $container->get(SessionRepository::class); $userId = UserId::fromString(self::USER_ID); $tenantId = TenantId::fromString(self::TENANT_ID); // Clean up any existing sessions for this user (from previous test runs) $existingSessions = $sessionRepository->findAllByUserId($userId); foreach ($existingSessions as $existingSession) { $sessionRepository->delete($existingSession->familyId); } // Create multiple sessions $keepFamilyId = TokenFamilyId::generate(); $deleteFamilyId1 = TokenFamilyId::generate(); $deleteFamilyId2 = TokenFamilyId::generate(); foreach ([$keepFamilyId, $deleteFamilyId1, $deleteFamilyId2] as $familyId) { $session = Session::create( familyId: $familyId, userId: $userId, tenantId: $tenantId, deviceInfo: DeviceInfo::fromUserAgent('Test Browser'), location: Location::unknown(), createdAt: new DateTimeImmutable(), ); $sessionRepository->save($session, 86400); } // Delete all except one $deletedIds = $sessionRepository->deleteAllExcept($userId, $keepFamilyId); self::assertCount(2, $deletedIds); self::assertNotNull($sessionRepository->findByFamilyId($keepFamilyId)); self::assertNull($sessionRepository->findByFamilyId($deleteFamilyId1)); self::assertNull($sessionRepository->findByFamilyId($deleteFamilyId2)); // Cleanup $sessionRepository->delete($keepFamilyId); } }