diff --git a/src/Mcp/Tests/Unit/AuditLogServiceTest.php b/src/Mcp/Tests/Unit/AuditLogServiceTest.php new file mode 100644 index 0000000..cea4e14 --- /dev/null +++ b/src/Mcp/Tests/Unit/AuditLogServiceTest.php @@ -0,0 +1,727 @@ +service = new AuditLogService(); + }); + + it('creates an audit log entry with required fields', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + ); + + expect($entry)->toBeInstanceOf(McpAuditLog::class); + expect($entry->exists)->toBeTrue(); + expect($entry->server_id)->toBe('mcp-server-1'); + expect($entry->tool_name)->toBe('query_database'); + expect($entry->success)->toBeTrue(); + }); + + it('stores input parameters as JSON', function () { + $params = ['query' => 'SELECT * FROM users', 'limit' => 10]; + + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + inputParams: $params, + ); + + expect($entry->input_params)->toBe($params); + }); + + it('stores output summary', function () { + $output = ['rows_returned' => 5, 'columns' => ['id', 'name']]; + + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + outputSummary: $output, + ); + + expect($entry->output_summary)->toBe($output); + }); + + it('records a failed execution', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + success: false, + errorCode: 'QUERY_BLOCKED', + errorMessage: 'INSERT statements are not allowed', + ); + + expect($entry->success)->toBeFalse(); + expect($entry->error_code)->toBe('QUERY_BLOCKED'); + expect($entry->error_message)->toBe('INSERT statements are not allowed'); + }); + + it('records execution duration', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + durationMs: 250, + ); + + expect($entry->duration_ms)->toBe(250); + }); + + it('records session and workspace context', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + sessionId: 'session-abc-123', + workspaceId: null, + ); + + expect($entry->session_id)->toBe('session-abc-123'); + }); + + it('records agent type and plan slug', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'deploy_service', + agentType: 'charon', + planSlug: 'deploy-api-v2', + ); + + expect($entry->agent_type)->toBe('charon'); + expect($entry->plan_slug)->toBe('deploy-api-v2'); + }); + + it('persists entries to the database', function () { + $this->service->record( + serverId: 'mcp-server-1', + toolName: 'tool_a', + ); + + $this->service->record( + serverId: 'mcp-server-1', + toolName: 'tool_b', + ); + + expect(McpAuditLog::count())->toBe(2); + }); +}); + +// ============================================================================= +// Security Audit Fields (Actor, IP, Sensitivity) +// ============================================================================= + +describe('Security audit fields', function () { + beforeEach(function () { + $this->service = new AuditLogService(); + }); + + it('records actor type and actor ID for user actors', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + actorType: McpAuditLog::ACTOR_USER, + actorId: 42, + ); + + expect($entry->actor_type)->toBe('user'); + expect($entry->actor_id)->toBe(42); + }); + + it('records actor type for API key actors', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + actorType: McpAuditLog::ACTOR_API_KEY, + actorId: 7, + ); + + expect($entry->actor_type)->toBe('api_key'); + expect($entry->actor_id)->toBe(7); + }); + + it('records actor type for system actors', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'system_cleanup', + actorType: McpAuditLog::ACTOR_SYSTEM, + ); + + expect($entry->actor_type)->toBe('system'); + }); + + it('records actor IP address', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + actorType: McpAuditLog::ACTOR_USER, + actorId: 1, + actorIp: '192.168.1.100', + ); + + expect($entry->actor_ip)->toBe('192.168.1.100'); + }); + + it('records IPv6 actor IP address', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + actorIp: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + ); + + expect($entry->actor_ip)->toBe('2001:0db8:85a3:0000:0000:8a2e:0370:7334'); + }); + + it('marks non-sensitive tools as not sensitive', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'list_files', + ); + + expect($entry->is_sensitive)->toBeFalse(); + expect($entry->sensitivity_reason)->toBeNull(); + }); + + it('marks registered sensitive tools as sensitive', function () { + McpSensitiveTool::register( + 'database_admin', + 'Administrative database access', + ['connection_string'], + ); + + Cache::forget('mcp:audit:sensitive_tools'); + + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'database_admin', + ); + + expect($entry->is_sensitive)->toBeTrue(); + expect($entry->sensitivity_reason)->toBe('Administrative database access'); + }); +}); + +// ============================================================================= +// Sensitive Tool Registration and Field Redaction +// ============================================================================= + +describe('Sensitive tool registration', function () { + beforeEach(function () { + $this->service = new AuditLogService(); + }); + + it('registers a sensitive tool', function () { + $this->service->registerSensitiveTool( + toolName: 'payment_process', + reason: 'Handles financial transactions', + redactFields: ['card_number', 'cvv'], + requireConsent: true, + ); + + $tool = McpSensitiveTool::where('tool_name', 'payment_process')->first(); + + expect($tool)->not->toBeNull(); + expect($tool->reason)->toBe('Handles financial transactions'); + expect($tool->redact_fields)->toBe(['card_number', 'cvv']); + expect($tool->require_explicit_consent)->toBeTrue(); + }); + + it('unregisters a sensitive tool', function () { + $this->service->registerSensitiveTool( + 'temp_tool', + 'Temporary sensitive tool', + ); + + $result = $this->service->unregisterSensitiveTool('temp_tool'); + + expect($result)->toBeTrue(); + expect(McpSensitiveTool::where('tool_name', 'temp_tool')->exists())->toBeFalse(); + }); + + it('returns false when unregistering non-existent tool', function () { + $result = $this->service->unregisterSensitiveTool('nonexistent_tool'); + + expect($result)->toBeFalse(); + }); + + it('lists all registered sensitive tools', function () { + $this->service->registerSensitiveTool('tool_a', 'Reason A'); + $this->service->registerSensitiveTool('tool_b', 'Reason B'); + + $tools = $this->service->getSensitiveTools(); + + expect($tools)->toHaveCount(2); + }); + + it('clears cache when registering a sensitive tool', function () { + Cache::put('mcp:audit:sensitive_tools', ['stale' => 'data'], 300); + + $this->service->registerSensitiveTool('new_tool', 'New reason'); + + expect(Cache::has('mcp:audit:sensitive_tools'))->toBeFalse(); + }); + + it('clears cache when unregistering a sensitive tool', function () { + $this->service->registerSensitiveTool('cached_tool', 'Cached reason'); + Cache::put('mcp:audit:sensitive_tools', ['stale' => 'data'], 300); + + $this->service->unregisterSensitiveTool('cached_tool'); + + expect(Cache::has('mcp:audit:sensitive_tools'))->toBeFalse(); + }); +}); + +describe('Field redaction', function () { + beforeEach(function () { + $this->service = new AuditLogService(); + }); + + it('redacts default sensitive fields from input params', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'api_call', + inputParams: [ + 'url' => 'https://api.example.com', + 'password' => 'super-secret-123', + 'api_key' => 'sk-abc123', + ], + ); + + expect($entry->input_params['url'])->toBe('https://api.example.com'); + expect($entry->input_params['password'])->toBe('[REDACTED]'); + expect($entry->input_params['api_key'])->toBe('[REDACTED]'); + }); + + it('redacts default sensitive fields from output summary', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'auth_check', + outputSummary: [ + 'status' => 'authenticated', + 'access_token' => 'eyJhbG...', + 'refresh_token' => 'dGhpcyBp...', + ], + ); + + expect($entry->output_summary['status'])->toBe('authenticated'); + expect($entry->output_summary['access_token'])->toBe('[REDACTED]'); + expect($entry->output_summary['refresh_token'])->toBe('[REDACTED]'); + }); + + it('redacts nested sensitive fields recursively', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'config_read', + inputParams: [ + 'config' => [ + 'database' => [ + 'host' => 'localhost', + 'password' => 'db-secret', + ], + 'api' => [ + 'token' => 'bearer-xyz', + ], + ], + ], + ); + + expect($entry->input_params['config']['database']['host'])->toBe('localhost'); + expect($entry->input_params['config']['database']['password'])->toBe('[REDACTED]'); + expect($entry->input_params['config']['api']['token'])->toBe('[REDACTED]'); + }); + + it('redacts additional tool-specific fields for sensitive tools', function () { + McpSensitiveTool::register( + 'payment_process', + 'Handles payments', + ['merchant_id', 'routing_number'], + ); + + Cache::forget('mcp:audit:sensitive_tools'); + + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'payment_process', + inputParams: [ + 'amount' => 99.99, + 'merchant_id' => 'MERCH-001', + 'routing_number' => '123456789', + 'password' => 'also-redacted', + ], + ); + + expect($entry->input_params['amount'])->toBe(99.99); + expect($entry->input_params['merchant_id'])->toBe('[REDACTED]'); + expect($entry->input_params['routing_number'])->toBe('[REDACTED]'); + expect($entry->input_params['password'])->toBe('[REDACTED]'); + }); + + it('redacts fields using case-insensitive matching', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'mixed_case_tool', + inputParams: [ + 'Password' => 'secret1', + 'API_KEY' => 'secret2', + 'apiKey' => 'secret3', + 'CreditCard' => '4111111111111111', + ], + ); + + expect($entry->input_params['Password'])->toBe('[REDACTED]'); + expect($entry->input_params['API_KEY'])->toBe('[REDACTED]'); + expect($entry->input_params['apiKey'])->toBe('[REDACTED]'); + expect($entry->input_params['CreditCard'])->toBe('[REDACTED]'); + }); + + it('does not redact non-sensitive fields', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'safe_tool', + inputParams: [ + 'name' => 'John', + 'email' => 'john@example.com', + 'query' => 'SELECT 1', + ], + ); + + expect($entry->input_params['name'])->toBe('John'); + expect($entry->input_params['email'])->toBe('john@example.com'); + expect($entry->input_params['query'])->toBe('SELECT 1'); + }); +}); + +// ============================================================================= +// Hash Chain Integrity +// ============================================================================= + +describe('Hash chain integrity', function () { + beforeEach(function () { + $this->service = new AuditLogService(); + }); + + it('generates an entry hash for each record', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + ); + + expect($entry->entry_hash)->not->toBeNull(); + expect($entry->entry_hash)->toHaveLength(64); // SHA-256 hex + }); + + it('first entry has null previous_hash', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + ); + + expect($entry->previous_hash)->toBeNull(); + }); + + it('links entries via previous_hash chain', function () { + $first = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'tool_a', + ); + + $second = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'tool_b', + ); + + expect($second->previous_hash)->toBe($first->entry_hash); + }); + + it('builds a chain across multiple entries', function () { + $entries = []; + for ($i = 0; $i < 5; $i++) { + $entries[] = $this->service->record( + serverId: 'mcp-server-1', + toolName: "tool_{$i}", + ); + } + + for ($i = 1; $i < 5; $i++) { + expect($entries[$i]->previous_hash)->toBe($entries[$i - 1]->entry_hash); + } + }); + + it('produces a valid hash that can be verified', function () { + $entry = $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + inputParams: ['query' => 'SELECT 1'], + durationMs: 50, + ); + + expect($entry->verifyHash())->toBeTrue(); + }); +}); + +// ============================================================================= +// Chain Verification +// ============================================================================= + +describe('verifyChain()', function () { + beforeEach(function () { + $this->service = new AuditLogService(); + }); + + it('reports valid chain for properly linked entries', function () { + $this->service->record('mcp-server-1', 'tool_a'); + $this->service->record('mcp-server-1', 'tool_b'); + $this->service->record('mcp-server-1', 'tool_c'); + + $result = $this->service->verifyChain(); + + expect($result['valid'])->toBeTrue(); + expect($result['total'])->toBe(3); + expect($result['verified'])->toBe(3); + expect($result['issues'])->toBeEmpty(); + }); + + it('supports verifying a subset of the chain by ID range', function () { + $first = $this->service->record('mcp-server-1', 'tool_a'); + $second = $this->service->record('mcp-server-1', 'tool_b'); + $third = $this->service->record('mcp-server-1', 'tool_c'); + + $result = $this->service->verifyChain(fromId: $second->id, toId: $third->id); + + expect($result['valid'])->toBeTrue(); + expect($result['total'])->toBe(2); + expect($result['verified'])->toBe(2); + }); + + it('reports valid for empty audit log', function () { + $result = $this->service->verifyChain(); + + expect($result['valid'])->toBeTrue(); + expect($result['total'])->toBe(0); + expect($result['verified'])->toBe(0); + }); +}); + +// ============================================================================= +// Consent Requirements +// ============================================================================= + +describe('Consent requirements', function () { + beforeEach(function () { + $this->service = new AuditLogService(); + }); + + it('returns true for tools requiring consent', function () { + $this->service->registerSensitiveTool( + 'dangerous_tool', + 'Can modify production data', + [], + requireConsent: true, + ); + + expect($this->service->requiresConsent('dangerous_tool'))->toBeTrue(); + }); + + it('returns false for tools not requiring consent', function () { + $this->service->registerSensitiveTool( + 'monitored_tool', + 'Just needs logging', + [], + requireConsent: false, + ); + + expect($this->service->requiresConsent('monitored_tool'))->toBeFalse(); + }); + + it('returns false for unregistered tools', function () { + expect($this->service->requiresConsent('unknown_tool'))->toBeFalse(); + }); +}); + +// ============================================================================= +// Export and Retrieval +// ============================================================================= + +describe('export()', function () { + beforeEach(function () { + $this->service = new AuditLogService(); + }); + + it('exports all entries when no filters given', function () { + $this->service->record('mcp-server-1', 'tool_a'); + $this->service->record('mcp-server-1', 'tool_b'); + $this->service->record('mcp-server-1', 'tool_c'); + + $exported = $this->service->export(); + + expect($exported)->toHaveCount(3); + }); + + it('filters by tool name', function () { + $this->service->record('mcp-server-1', 'query_database'); + $this->service->record('mcp-server-1', 'file_read'); + $this->service->record('mcp-server-1', 'query_database'); + + $exported = $this->service->export(toolName: 'query_database'); + + expect($exported)->toHaveCount(2); + }); + + it('filters sensitive-only entries', function () { + McpSensitiveTool::register('secret_tool', 'Top secret'); + Cache::forget('mcp:audit:sensitive_tools'); + + $this->service->record('mcp-server-1', 'secret_tool'); + $this->service->record('mcp-server-1', 'normal_tool'); + + $exported = $this->service->export(sensitiveOnly: true); + + expect($exported)->toHaveCount(1); + expect($exported->first()['tool_name'])->toBe('secret_tool'); + }); + + it('returns entries in export array format', function () { + $this->service->record( + serverId: 'mcp-server-1', + toolName: 'query_database', + actorType: 'user', + actorId: 5, + ); + + $exported = $this->service->export(); + $entry = $exported->first(); + + expect($entry)->toHaveKeys([ + 'id', 'timestamp', 'server_id', 'tool_name', + 'success', 'entry_hash', 'actor_type', 'actor_id', + ]); + expect($entry['server_id'])->toBe('mcp-server-1'); + expect($entry['tool_name'])->toBe('query_database'); + }); +}); + +describe('exportToJson()', function () { + beforeEach(function () { + $this->service = new AuditLogService(); + }); + + it('produces valid JSON with integrity metadata', function () { + $this->service->record('mcp-server-1', 'tool_a'); + $this->service->record('mcp-server-1', 'tool_b'); + + $json = $this->service->exportToJson(); + $data = json_decode($json, true); + + expect($data)->toHaveKeys(['exported_at', 'integrity', 'filters', 'entries']); + expect($data['integrity']['valid'])->toBeTrue(); + expect($data['integrity']['total_entries'])->toBe(2); + expect($data['entries'])->toHaveCount(2); + }); +}); + +describe('exportToCsv()', function () { + beforeEach(function () { + $this->service = new AuditLogService(); + }); + + it('produces CSV with headers', function () { + $this->service->record('mcp-server-1', 'tool_a'); + + $csv = $this->service->exportToCsv(); + + expect($csv)->toContain('server_id'); + expect($csv)->toContain('tool_name'); + expect($csv)->toContain('mcp-server-1'); + expect($csv)->toContain('tool_a'); + }); + + it('returns empty string when no entries', function () { + $csv = $this->service->exportToCsv(); + + expect($csv)->toBe(''); + }); +}); + +// ============================================================================= +// Statistics +// ============================================================================= + +describe('getStats()', function () { + beforeEach(function () { + $this->service = new AuditLogService(); + }); + + it('returns correct totals', function () { + $this->service->record('mcp-server-1', 'tool_a', success: true); + $this->service->record('mcp-server-1', 'tool_a', success: true); + $this->service->record('mcp-server-1', 'tool_a', success: false, errorCode: 'ERR'); + + $stats = $this->service->getStats(); + + expect($stats['total'])->toBe(3); + expect($stats['successful'])->toBe(2); + expect($stats['failed'])->toBe(1); + expect($stats['success_rate'])->toBe(66.67); + }); + + it('counts sensitive calls', function () { + McpSensitiveTool::register('secret_tool', 'Classified'); + Cache::forget('mcp:audit:sensitive_tools'); + + $this->service->record('mcp-server-1', 'secret_tool'); + $this->service->record('mcp-server-1', 'normal_tool'); + + $stats = $this->service->getStats(); + + expect($stats['sensitive_calls'])->toBe(1); + }); + + it('lists top tools by usage', function () { + for ($i = 0; $i < 5; $i++) { + $this->service->record('mcp-server-1', 'popular_tool'); + } + for ($i = 0; $i < 2; $i++) { + $this->service->record('mcp-server-1', 'rare_tool'); + } + + $stats = $this->service->getStats(); + + expect($stats['top_tools'])->toHaveKey('popular_tool'); + expect($stats['top_tools']['popular_tool'])->toBe(5); + expect($stats['top_tools']['rare_tool'])->toBe(2); + }); + + it('returns zero stats for empty log', function () { + $stats = $this->service->getStats(); + + expect($stats['total'])->toBe(0); + expect($stats['successful'])->toBe(0); + expect($stats['failed'])->toBe(0); + expect($stats['success_rate'])->toBe(0); + expect($stats['sensitive_calls'])->toBe(0); + expect($stats['top_tools'])->toBeEmpty(); + }); +}); diff --git a/src/Mcp/Tests/Unit/CircuitBreakerTest.php b/src/Mcp/Tests/Unit/CircuitBreakerTest.php new file mode 100644 index 0000000..72647a5 --- /dev/null +++ b/src/Mcp/Tests/Unit/CircuitBreakerTest.php @@ -0,0 +1,556 @@ +breaker = new CircuitBreaker; + Cache::flush(); + } + + // --------------------------------------------------------------- + // Closed state (default) - requests pass through + // --------------------------------------------------------------- + + public function test_default_state_is_closed(): void + { + $state = $this->breaker->getState('test-service'); + + $this->assertSame(CircuitBreaker::STATE_CLOSED, $state); + } + + public function test_closed_circuit_passes_operation_through(): void + { + $result = $this->breaker->call('test-service', fn () => 'success'); + + $this->assertSame('success', $result); + } + + public function test_closed_circuit_returns_operation_result(): void + { + $result = $this->breaker->call('test-service', fn () => ['key' => 'value']); + + $this->assertSame(['key' => 'value'], $result); + } + + public function test_closed_circuit_propagates_exceptions(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('operation failed'); + + $this->breaker->call('test-service', function () { + throw new RuntimeException('operation failed'); + }); + } + + public function test_closed_circuit_uses_fallback_on_recoverable_error(): void + { + $result = $this->breaker->call( + 'test-service', + function () { + throw new RuntimeException('Connection refused'); + }, + fn () => 'fallback-value', + ); + + $this->assertSame('fallback-value', $result); + } + + public function test_closed_circuit_does_not_use_fallback_on_non_recoverable_error(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('logic error'); + + $this->breaker->call( + 'test-service', + function () { + throw new RuntimeException('logic error'); + }, + fn () => 'fallback-value', + ); + } + + public function test_service_is_available_when_closed(): void + { + $this->assertTrue($this->breaker->isAvailable('test-service')); + } + + // --------------------------------------------------------------- + // Failure threshold - trips circuit after N failures + // --------------------------------------------------------------- + + public function test_circuit_trips_after_reaching_failure_threshold(): void + { + // Default threshold is 5 + for ($i = 0; $i < 5; $i++) { + try { + $this->breaker->call('test-service', function () { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + // Expected + } + } + + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service')); + } + + public function test_circuit_does_not_trip_below_threshold(): void + { + // Default threshold is 5, so 4 failures should not trip + for ($i = 0; $i < 4; $i++) { + try { + $this->breaker->call('test-service', function () { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + // Expected + } + } + + $this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('test-service')); + } + + public function test_circuit_respects_custom_threshold(): void + { + Config::set('mcp.circuit_breaker.test-service.threshold', 2); + + for ($i = 0; $i < 2; $i++) { + try { + $this->breaker->call('test-service', function () { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + // Expected + } + } + + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service')); + } + + public function test_service_is_not_available_when_open(): void + { + $this->tripCircuit('test-service'); + + $this->assertFalse($this->breaker->isAvailable('test-service')); + } + + // --------------------------------------------------------------- + // Open state - fails fast + // --------------------------------------------------------------- + + public function test_open_circuit_throws_circuit_open_exception(): void + { + $this->tripCircuit('test-service'); + + $this->expectException(CircuitOpenException::class); + + $this->breaker->call('test-service', fn () => 'should not run'); + } + + public function test_open_circuit_exception_contains_service_name(): void + { + $this->tripCircuit('my-api'); + + try { + $this->breaker->call('my-api', fn () => 'should not run'); + $this->fail('Expected CircuitOpenException'); + } catch (CircuitOpenException $e) { + $this->assertSame('my-api', $e->service); + $this->assertStringContainsString('my-api', $e->getMessage()); + } + } + + public function test_open_circuit_uses_fallback_when_provided(): void + { + $this->tripCircuit('test-service'); + + $result = $this->breaker->call( + 'test-service', + fn () => 'should not run', + fn () => 'fallback-result', + ); + + $this->assertSame('fallback-result', $result); + } + + public function test_open_circuit_does_not_execute_operation(): void + { + $this->tripCircuit('test-service'); + + $executed = false; + + try { + $this->breaker->call('test-service', function () use (&$executed) { + $executed = true; + }); + } catch (CircuitOpenException) { + // Expected + } + + $this->assertFalse($executed); + } + + // --------------------------------------------------------------- + // Half-open state - probe / recovery + // --------------------------------------------------------------- + + public function test_circuit_transitions_to_half_open_after_reset_timeout(): void + { + $this->tripCircuit('test-service'); + + // Simulate time passing beyond the reset timeout (default 60s) + $this->simulateTimePassing('test-service', 61); + + $state = $this->breaker->getState('test-service'); + + $this->assertSame(CircuitBreaker::STATE_HALF_OPEN, $state); + } + + public function test_circuit_stays_open_before_reset_timeout(): void + { + $this->tripCircuit('test-service'); + + // Simulate time passing but not enough (default reset_timeout is 60s) + $this->simulateTimePassing('test-service', 30); + + $state = $this->breaker->getState('test-service'); + + $this->assertSame(CircuitBreaker::STATE_OPEN, $state); + } + + public function test_half_open_circuit_allows_probe_request(): void + { + $this->tripCircuit('test-service'); + $this->simulateTimePassing('test-service', 61); + + $result = $this->breaker->call('test-service', fn () => 'probe-success'); + + $this->assertSame('probe-success', $result); + } + + public function test_half_open_circuit_closes_on_successful_probe(): void + { + $this->tripCircuit('test-service'); + $this->simulateTimePassing('test-service', 61); + + // Successful probe should close the circuit + $this->breaker->call('test-service', fn () => 'ok'); + + $this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('test-service')); + } + + public function test_half_open_circuit_reopens_on_failed_probe(): void + { + Config::set('mcp.circuit_breaker.test-service.threshold', 1); + + $this->tripCircuit('test-service'); + $this->simulateTimePassing('test-service', 61); + + // Failed probe should re-trip the circuit + try { + $this->breaker->call('test-service', function () { + throw new RuntimeException('still broken'); + }); + } catch (RuntimeException) { + // Expected + } + + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service')); + } + + public function test_half_open_uses_fallback_when_trial_lock_taken(): void + { + $this->tripCircuit('test-service'); + $this->simulateTimePassing('test-service', 61); + + // Simulate another request holding the trial lock + Cache::put('circuit_breaker:test-service:trial_lock', true, 30); + + $result = $this->breaker->call( + 'test-service', + fn () => 'should not run', + fn () => 'locked-fallback', + ); + + $this->assertSame('locked-fallback', $result); + } + + public function test_half_open_throws_when_trial_lock_taken_and_no_fallback(): void + { + $this->tripCircuit('test-service'); + $this->simulateTimePassing('test-service', 61); + + // Simulate another request holding the trial lock + Cache::put('circuit_breaker:test-service:trial_lock', true, 30); + + $this->expectException(CircuitOpenException::class); + + $this->breaker->call('test-service', fn () => 'should not run'); + } + + public function test_custom_reset_timeout(): void + { + Config::set('mcp.circuit_breaker.test-service.reset_timeout', 120); + + $this->tripCircuit('test-service'); + + // 61 seconds is not enough with custom 120s timeout + $this->simulateTimePassing('test-service', 61); + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service')); + + // 121 seconds should trigger half-open + $this->simulateTimePassing('test-service', 121); + $this->assertSame(CircuitBreaker::STATE_HALF_OPEN, $this->breaker->getState('test-service')); + } + + // --------------------------------------------------------------- + // Manual reset + // --------------------------------------------------------------- + + public function test_manual_reset_closes_circuit(): void + { + $this->tripCircuit('test-service'); + + $this->breaker->reset('test-service'); + + $this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('test-service')); + } + + public function test_manual_reset_clears_failure_counters(): void + { + $this->tripCircuit('test-service'); + + $this->breaker->reset('test-service'); + + $stats = $this->breaker->getStats('test-service'); + + $this->assertSame(0, $stats['failures']); + $this->assertSame(0, $stats['successes']); + $this->assertNull($stats['last_failure']); + $this->assertNull($stats['opened_at']); + } + + public function test_reset_allows_operations_again(): void + { + $this->tripCircuit('test-service'); + + $this->breaker->reset('test-service'); + + $result = $this->breaker->call('test-service', fn () => 'working again'); + + $this->assertSame('working again', $result); + } + + // --------------------------------------------------------------- + // Statistics + // --------------------------------------------------------------- + + public function test_stats_track_failures(): void + { + try { + $this->breaker->call('test-service', function () { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + // Expected + } + + $stats = $this->breaker->getStats('test-service'); + + $this->assertSame(1, $stats['failures']); + $this->assertSame('test-service', $stats['service']); + $this->assertNotNull($stats['last_failure']); + $this->assertSame(RuntimeException::class, $stats['last_failure']['class']); + } + + public function test_stats_track_successes(): void + { + $this->breaker->call('test-service', fn () => 'ok'); + + $stats = $this->breaker->getStats('test-service'); + + $this->assertSame(1, $stats['successes']); + } + + public function test_stats_report_threshold_and_timeout(): void + { + $stats = $this->breaker->getStats('test-service'); + + $this->assertSame(5, $stats['threshold']); + $this->assertSame(60, $stats['reset_timeout']); + } + + public function test_stats_report_custom_config(): void + { + Config::set('mcp.circuit_breaker.custom-svc.threshold', 10); + Config::set('mcp.circuit_breaker.custom-svc.reset_timeout', 180); + + $stats = $this->breaker->getStats('custom-svc'); + + $this->assertSame(10, $stats['threshold']); + $this->assertSame(180, $stats['reset_timeout']); + } + + // --------------------------------------------------------------- + // Recoverable error detection + // --------------------------------------------------------------- + + public function test_sqlstate_error_is_recoverable(): void + { + $result = $this->breaker->call( + 'test-service', + function () { + throw new RuntimeException('SQLSTATE[HY000]: General error'); + }, + fn () => 'recovered', + ); + + $this->assertSame('recovered', $result); + } + + public function test_connection_timeout_is_recoverable(): void + { + $result = $this->breaker->call( + 'test-service', + function () { + throw new RuntimeException('Connection timed out after 30 seconds'); + }, + fn () => 'recovered', + ); + + $this->assertSame('recovered', $result); + } + + public function test_too_many_connections_is_recoverable(): void + { + $result = $this->breaker->call( + 'test-service', + function () { + throw new RuntimeException('Too many connections'); + }, + fn () => 'recovered', + ); + + $this->assertSame('recovered', $result); + } + + public function test_table_not_found_is_recoverable(): void + { + $result = $this->breaker->call( + 'test-service', + function () { + throw new RuntimeException("Base table or view not found: 1146 Table 'db.table' doesn't exist"); + }, + fn () => 'recovered', + ); + + $this->assertSame('recovered', $result); + } + + // --------------------------------------------------------------- + // Service isolation + // --------------------------------------------------------------- + + public function test_circuits_are_isolated_per_service(): void + { + $this->tripCircuit('service-a'); + + // service-a should be open + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('service-a')); + + // service-b should still be closed + $this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('service-b')); + } + + public function test_resetting_one_service_does_not_affect_another(): void + { + $this->tripCircuit('service-a'); + $this->tripCircuit('service-b'); + + $this->breaker->reset('service-a'); + + $this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('service-a')); + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('service-b')); + } + + // --------------------------------------------------------------- + // Success decay + // --------------------------------------------------------------- + + public function test_successful_calls_decay_failure_count(): void + { + // Record 3 failures + for ($i = 0; $i < 3; $i++) { + try { + $this->breaker->call('test-service', function () { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + // Expected + } + } + + $this->assertSame(3, $this->breaker->getStats('test-service')['failures']); + + // A successful call should decrement the failure count + $this->breaker->call('test-service', fn () => 'ok'); + + $this->assertSame(2, $this->breaker->getStats('test-service')['failures']); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * Trip the circuit by reaching the failure threshold. + */ + protected function tripCircuit(string $service): void + { + $threshold = (int) config( + "mcp.circuit_breaker.{$service}.threshold", + config('mcp.circuit_breaker.default_threshold', 5), + ); + + for ($i = 0; $i < $threshold; $i++) { + try { + $this->breaker->call($service, function () { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + // Expected - Connection refused is recoverable so fallback would be used + // but without a fallback it re-throws after recording the failure + } + } + + // Verify the circuit actually tripped + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState($service)); + } + + /** + * Simulate time passing by adjusting the opened_at timestamp in cache. + */ + protected function simulateTimePassing(string $service, int $seconds): void + { + Cache::put( + "circuit_breaker:{$service}:opened_at", + time() - $seconds, + 86400, + ); + } +}