connection = $this->createMock(Connection::class); $this->tenantContext = new TenantContext(); $this->tokenStorage = $this->createMock(TokenStorageInterface::class); $this->requestStack = new RequestStack(); $this->clock = $this->createMock(Clock::class); $this->auditLogger = new AuditLogger( $this->connection, $this->tenantContext, $this->tokenStorage, $this->requestStack, $this->clock, 'test-secret', ); CorrelationIdHolder::clear(); } protected function tearDown(): void { CorrelationIdHolder::clear(); $this->tenantContext->clear(); } public function testLogAuthenticationInsertsIntoDatabase(): void { $now = new DateTimeImmutable('2026-02-03 10:30:00'); $this->clock->method('now')->willReturn($now); $this->tokenStorage->method('getToken')->willReturn(null); $userId = Uuid::uuid4(); $this->connection->expects($this->once()) ->method('insert') ->with( 'audit_log', $this->callback(static function (array $data) use ($userId): bool { return $data['aggregate_type'] === 'User' && $data['aggregate_id'] === $userId->toString() && $data['event_type'] === 'TestEvent' && $data['payload']['test_key'] === 'test_value'; }), $this->anything(), ); $this->auditLogger->logAuthentication('TestEvent', $userId, ['test_key' => 'test_value']); } public function testLogAuthenticationWithNullUserIdSetsNullAggregateId(): void { $now = new DateTimeImmutable('2026-02-03 10:30:00'); $this->clock->method('now')->willReturn($now); $this->tokenStorage->method('getToken')->willReturn(null); $this->connection->expects($this->once()) ->method('insert') ->with( 'audit_log', $this->callback(static fn (array $data): bool => $data['aggregate_id'] === null), $this->anything(), ); $this->auditLogger->logAuthentication('FailedLogin', null, []); } public function testLogDataChangeIncludesOldAndNewValues(): void { $now = new DateTimeImmutable('2026-02-03 10:30:00'); $this->clock->method('now')->willReturn($now); $this->tokenStorage->method('getToken')->willReturn(null); $aggregateId = Uuid::uuid4(); $this->connection->expects($this->once()) ->method('insert') ->with( 'audit_log', $this->callback(static function (array $data) use ($aggregateId): bool { $payload = $data['payload']; return $data['aggregate_type'] === 'Note' && $data['aggregate_id'] === $aggregateId->toString() && $data['event_type'] === 'NoteModified' && $payload['old_values']['value'] === 12.5 && $payload['new_values']['value'] === 14.0 && $payload['reason'] === 'Correction'; }), $this->anything(), ); $this->auditLogger->logDataChange( 'Note', $aggregateId, 'NoteModified', ['value' => 12.5], ['value' => 14.0], 'Correction', ); } public function testLogExportIncludesExportDetails(): void { $now = new DateTimeImmutable('2026-02-03 10:30:00'); $this->clock->method('now')->willReturn($now); $this->tokenStorage->method('getToken')->willReturn(null); $this->connection->expects($this->once()) ->method('insert') ->with( 'audit_log', $this->callback(static function (array $data): bool { $payload = $data['payload']; return $data['aggregate_type'] === 'Export' && $data['event_type'] === 'ExportGenerated' && $payload['export_type'] === 'CSV' && $payload['record_count'] === 150 && $payload['target'] === 'students_list'; }), $this->anything(), ); $this->auditLogger->logExport('CSV', 150, 'students_list'); } public function testLogAccessIncludesResourceAndContext(): void { $now = new DateTimeImmutable('2026-02-03 10:30:00'); $this->clock->method('now')->willReturn($now); $this->tokenStorage->method('getToken')->willReturn(null); $resourceId = Uuid::uuid4(); $this->connection->expects($this->once()) ->method('insert') ->with( 'audit_log', $this->callback(static function (array $data) use ($resourceId): bool { $payload = $data['payload']; return $data['aggregate_type'] === 'Student' && $data['aggregate_id'] === $resourceId->toString() && $data['event_type'] === 'ResourceAccessed' && $payload['screen'] === 'profile' && $payload['action'] === 'view'; }), $this->anything(), ); $this->auditLogger->logAccess('Student', $resourceId, ['screen' => 'profile', 'action' => 'view']); } public function testMetadataIncludesTenantIdWhenAvailable(): void { $now = new DateTimeImmutable('2026-02-03 10:30:00'); $this->clock->method('now')->willReturn($now); $this->tokenStorage->method('getToken')->willReturn(null); $tenantId = TenantId::generate(); $this->setCurrentTenant($tenantId); $this->connection->expects($this->once()) ->method('insert') ->with( 'audit_log', $this->callback(static function (array $data) use ($tenantId): bool { $metadata = $data['metadata']; return $metadata['tenant_id'] === (string) $tenantId; }), $this->anything(), ); $this->auditLogger->logAuthentication('Test', null, []); } public function testMetadataIncludesCorrelationIdWhenSet(): void { $now = new DateTimeImmutable('2026-02-03 10:30:00'); $this->clock->method('now')->willReturn($now); $this->tokenStorage->method('getToken')->willReturn(null); $correlationId = CorrelationId::generate(); CorrelationIdHolder::set($correlationId); $this->connection->expects($this->once()) ->method('insert') ->with( 'audit_log', $this->callback(static function (array $data) use ($correlationId): bool { $metadata = $data['metadata']; return $metadata['correlation_id'] === $correlationId->value(); }), $this->anything(), ); $this->auditLogger->logAuthentication('Test', null, []); } public function testMetadataIncludesHashedIpFromRequest(): void { $now = new DateTimeImmutable('2026-02-03 10:30:00'); $this->clock->method('now')->willReturn($now); $this->tokenStorage->method('getToken')->willReturn(null); $request = Request::create('/test'); $request->server->set('REMOTE_ADDR', '192.168.1.100'); $this->requestStack->push($request); $expectedIpHash = hash('sha256', '192.168.1.100test-secret'); $this->connection->expects($this->once()) ->method('insert') ->with( 'audit_log', $this->callback(static function (array $data) use ($expectedIpHash): bool { $metadata = $data['metadata']; return $metadata['ip_hash'] === $expectedIpHash; }), $this->anything(), ); $this->auditLogger->logAuthentication('Test', null, []); } public function testMetadataIncludesUserAgentHash(): void { $now = new DateTimeImmutable('2026-02-03 10:30:00'); $this->clock->method('now')->willReturn($now); $this->tokenStorage->method('getToken')->willReturn(null); $request = Request::create('/test'); $request->headers->set('User-Agent', 'Mozilla/5.0 TestBrowser'); $this->requestStack->push($request); $expectedUaHash = hash('sha256', 'Mozilla/5.0 TestBrowser'); $this->connection->expects($this->once()) ->method('insert') ->with( 'audit_log', $this->callback(static function (array $data) use ($expectedUaHash): bool { $metadata = $data['metadata']; return $metadata['user_agent_hash'] === $expectedUaHash; }), $this->anything(), ); $this->auditLogger->logAuthentication('Test', null, []); } public function testLogAuthenticationWithTenantIdOverride(): void { $now = new DateTimeImmutable('2026-02-03 10:30:00'); $this->clock->method('now')->willReturn($now); $this->tokenStorage->method('getToken')->willReturn(null); $overrideTenantId = 'override-tenant-uuid-1234'; $this->connection->expects($this->once()) ->method('insert') ->with( 'audit_log', $this->callback(static function (array $data) use ($overrideTenantId): bool { $metadata = $data['metadata']; return $metadata['tenant_id'] === $overrideTenantId; }), $this->anything(), ); // No TenantContext set, but override should be used $this->auditLogger->logAuthentication('Test', null, [], $overrideTenantId); } public function testTenantIdOverrideTakesPrecedenceOverContext(): void { $now = new DateTimeImmutable('2026-02-03 10:30:00'); $this->clock->method('now')->willReturn($now); $this->tokenStorage->method('getToken')->willReturn(null); // Set a tenant in context $contextTenantId = TenantId::generate(); $this->setCurrentTenant($contextTenantId); // But use a different override $overrideTenantId = 'override-tenant-uuid-5678'; $this->connection->expects($this->once()) ->method('insert') ->with( 'audit_log', $this->callback(static function (array $data) use ($overrideTenantId): bool { $metadata = $data['metadata']; // Override should take precedence over context return $metadata['tenant_id'] === $overrideTenantId; }), $this->anything(), ); $this->auditLogger->logAuthentication('Test', null, [], $overrideTenantId); } private function setCurrentTenant(TenantId $tenantId): void { $config = new TenantConfig( tenantId: InfrastructureTenantId::fromString((string) $tenantId), subdomain: 'test-tenant', databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_test', ); $this->tenantContext->setCurrentTenant($config); } }