forked from core/php-mcp
test: add comprehensive tests for AuditLogService #2
2 changed files with 1283 additions and 0 deletions
727
src/Mcp/Tests/Unit/AuditLogServiceTest.php
Normal file
727
src/Mcp/Tests/Unit/AuditLogServiceTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
556
src/Mcp/Tests/Unit/CircuitBreakerTest.php
Normal file
556
src/Mcp/Tests/Unit/CircuitBreakerTest.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue