test: add comprehensive tests for AuditLogService

Cover record(), log entry creation, security audit fields (actor type,
IP, sensitivity), sensitive tool registration, field redaction (default
and tool-specific), hash chain integrity, chain verification, consent
requirements, export/retrieval (JSON, CSV), and statistics.

Fixes #5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-24 16:11:19 +00:00
parent fa867cfa7d
commit 4f11d7d435
No known key found for this signature in database
GPG key ID: AF404715446AEB41

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();
});
});