test: add comprehensive tests for AuditLogService #2

Open
Charon wants to merge 2 commits from feat/test-audit-log-service into dev
2 changed files with 1283 additions and 0 deletions

View file

@ -0,0 +1,727 @@
<?php
declare(strict_types=1);
/**
* Unit: Audit Log Service
*
* Tests for MCP audit log service covering:
* - record() method and log entry creation
* - Security audit fields (actor, IP, sensitivity)
* - Sensitive tool registration and field redaction
* - Hash chain integrity and tamper detection
* - Query/retrieval via export and stats
* - Consent requirements
*
* @see https://forge.lthn.ai/core/php-mcp/issues/5
*/
use Core\Mcp\Models\McpAuditLog;
use Core\Mcp\Models\McpSensitiveTool;
use Core\Mcp\Services\AuditLogService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
// =============================================================================
// record() — Basic Log Entry Creation
// =============================================================================
describe('record() creates audit log entries', function () {
beforeEach(function () {
$this->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();
});
});

View file

@ -0,0 +1,556 @@
<?php
declare(strict_types=1);
namespace Core\Mcp\Tests\Unit;
use Core\Mcp\Exceptions\CircuitOpenException;
use Core\Mcp\Services\CircuitBreaker;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use RuntimeException;
use Tests\TestCase;
class CircuitBreakerTest extends TestCase
{
protected CircuitBreaker $breaker;
protected function setUp(): void
{
parent::setUp();
$this->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,
);
}
}