store = $this->createMock(FirewallStore::class); $this->tenantContext = $this->createMock(TenantContextInterface::class); $this->events = $this->createMock(EventDispatcherInterface::class); $this->currentTenant = 'tenant-a'; $this->currentConfiguration = null; $this->tenantContext->method('identifier')->willReturnCallback( fn(): ?string => $this->currentTenant ); $this->tenantContext->method('configuration')->willReturnCallback( fn(): ?TenantConfiguration => $this->currentConfiguration ); $cache = new FirewallRuleCache($this->store); $manager = new FirewallRuleManager($this->store, $cache, $this->events); $this->service = new FirewallService( $this->store, $this->tenantContext, $this->events, $manager, $cache ); } #[TestDox('System blocks cannot be overridden by tenant allows')] public function testSystemBlockPrecedence(): void { $systemBlock = $this->rule( 'system-block', FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::ACTION_BLOCK, null ); $tenantAllow = $this->rule( 'tenant-allow', FirewallRuleObject::SCOPE_TENANT, FirewallRuleObject::ACTION_ALLOW, 'tenant-a' ); $this->store->expects($this->once())->method('listSystemRules')->willReturn([$systemBlock]); $this->store->expects($this->once())->method('listRules')->with('tenant-a')->willReturn([$tenantAllow]); $this->events->expects($this->once())->method('dispatch'); $result = $this->service->analyze('203.0.113.10'); self::assertTrue($result->isBlocked()); self::assertSame('system-block', $result->ruleId); } #[TestDox('Tenant blocks override system allows')] public function testTenantBlockPrecedence(): void { $systemAllow = $this->rule( 'system-allow', FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::ACTION_ALLOW, null ); $tenantBlock = $this->rule( 'tenant-block', FirewallRuleObject::SCOPE_TENANT, FirewallRuleObject::ACTION_BLOCK, 'tenant-a' ); $this->store->method('listSystemRules')->willReturn([$systemAllow]); $this->store->method('listRules')->with('tenant-a')->willReturn([$tenantBlock]); $this->events->expects($this->once())->method('dispatch'); $result = $this->service->analyze('203.0.113.10'); self::assertTrue($result->isBlocked()); self::assertSame('tenant-block', $result->ruleId); } #[TestDox('Rule caches are isolated by tenant')] public function testTenantCacheIsolation(): void { $this->store->expects($this->once())->method('listSystemRules')->willReturn([]); $this->store->expects($this->exactly(2)) ->method('listRules') ->willReturnCallback(static fn(string $tenantId): array => [ (new FirewallRuleObject()) ->setId($tenantId) ->setScope(FirewallRuleObject::SCOPE_TENANT) ->setTenantId($tenantId) ->setType(FirewallRuleObject::TYPE_IP) ->setAction(FirewallRuleObject::ACTION_BLOCK) ->setValue('203.0.113.10'), ]); self::assertSame('tenant-a', $this->service->analyze('203.0.113.10')->ruleId); $this->currentTenant = 'tenant-b'; self::assertSame('tenant-b', $this->service->analyze('203.0.113.10')->ruleId); } #[TestDox('System blocks apply when no tenant is resolved')] public function testSystemBlockWithoutTenant(): void { $this->currentTenant = null; $this->store->method('listSystemRules')->willReturn([ $this->rule( 'system-block', FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::ACTION_BLOCK, null ), ]); $this->store->expects($this->never())->method('listRules'); self::assertSame('system-block', $this->service->analyze('203.0.113.10')->ruleId); } #[TestDox('System allows can match when no tenant is resolved')] public function testSystemAllowWithoutTenant(): void { $this->currentTenant = null; $this->store->method('listSystemRules')->willReturn([ $this->rule( 'system-allow', FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::ACTION_ALLOW, null ), ]); $result = $this->service->analyze('203.0.113.10'); self::assertTrue($result->isAllowed()); self::assertSame('system-allow', $result->ruleId); } #[TestDox('System blocks remain active when the tenant firewall is disabled')] public function testSystemBlockWithDisabledTenant(): void { $this->disableTenantFirewall(); $this->store->method('listSystemRules')->willReturn([ $this->rule( 'system-block', FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::ACTION_BLOCK, null ), ]); $this->store->expects($this->never())->method('listRules'); self::assertSame('system-block', $this->service->analyze('203.0.113.10')->ruleId); } #[TestDox('Tenant rules are ignored when the tenant firewall is disabled')] public function testTenantRulesDisabled(): void { $this->disableTenantFirewall(); $this->store->method('listSystemRules')->willReturn([]); $this->store->expects($this->never())->method('listRules'); self::assertTrue($this->service->analyze('203.0.113.10')->isAllowed()); } #[TestDox('Requests without a tenant and without a system match are allowed')] public function testNoTenantDefault(): void { $this->currentTenant = null; $this->store->method('listSystemRules')->willReturn([]); $this->store->expects($this->never())->method('listRules'); self::assertTrue($this->service->analyze('203.0.113.10')->isAllowed()); } #[TestDox('Tenant rule-match logs persist dedicated rule ID and scope fields')] public function testTenantRuleAuditContext(): void { $this->store->expects($this->once()) ->method('createLog') ->with(self::callback(static function (FirewallLogObject $log): bool { return $log->getTenantId() === 'tenant-a' && $log->getRuleId() === 'tenant-rule' && $log->getRuleScope() === FirewallRuleObject::SCOPE_TENANT && $log->getEventType() === FirewallLogObject::EVENT_RULE_MATCH; })) ->willReturnArgument(0); $event = \KTXC\Security\Event\SecurityEvent::accessDenied( '203.0.113.10', null, 'tenant-rule', FirewallRuleObject::SCOPE_TENANT, 'Tenant block', 'tenant-a', ); $this->service->logSecurityEvent($event); } #[TestDox('System rule matches are logged even when no tenant is resolved')] public function testSystemRuleAuditContext(): void { $this->currentTenant = null; $this->store->expects($this->once()) ->method('createLog') ->with(self::callback(static function (FirewallLogObject $log): bool { return $log->getTenantId() === null && $log->getRuleId() === 'system-rule' && $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM; })) ->willReturnArgument(0); $event = \KTXC\Security\Event\SecurityEvent::accessDenied( '203.0.113.10', null, 'system-rule', FirewallRuleObject::SCOPE_SYSTEM, 'System block' ); $this->service->logSecurityEvent($event); } #[TestDox('Tenantless security events without system rule context are ignored')] public function testTenantlessAuditBoundary(): void { $this->currentTenant = null; $this->store->expects($this->never())->method('createLog'); $this->service->logSecurityEvent( \KTXC\Security\Event\SecurityEvent::authFailure('203.0.113.10') ); } #[TestDox('Rate-limit events retain their request and threshold audit data')] public function testRateLimitAudit(): void { $this->store->expects($this->once()) ->method('createLog') ->with(self::callback(static function (FirewallLogObject $log): bool { $metadata = $log->getMetadata(); return $log->getEventType() === FirewallLogObject::EVENT_RATE_LIMIT && $log->getResult() === FirewallLogObject::RESULT_BLOCKED && $log->getIpAddress() === '203.0.113.10' && $log->getRequestPath() === '/login' && $metadata['requestCount'] === 101 && $metadata['windowSeconds'] === 60; })) ->willReturnArgument(0); $event = \KTXC\Security\Event\SecurityEvent::rateLimitExceeded( '203.0.113.10', 101, 60, '/login', 'tenant-a', ); $this->service->logSecurityEvent($event); } #[TestDox('Suspicious-activity events retain request and detection metadata')] public function testSuspiciousActivityAudit(): void { $this->store->expects($this->once()) ->method('createLog') ->with(self::callback(static function (FirewallLogObject $log): bool { return $log->getEventType() === FirewallLogObject::EVENT_SUSPICIOUS && $log->getResult() === FirewallLogObject::RESULT_BLOCKED && $log->getIpAddress() === '203.0.113.20' && $log->getRequestPath() === '/admin' && $log->getRequestMethod() === 'POST' && $log->getMetadata()['detector'] === 'payload-signature'; })) ->willReturnArgument(0); $event = \KTXC\Security\Event\SecurityEvent::create( \KTXC\Security\Event\SecurityEvent::SUSPICIOUS_ACTIVITY, '203.0.113.20', null, ['detector' => 'payload-signature'], tenantId: 'tenant-a', requestPath: '/admin', requestMethod: 'POST', ); $this->service->logSecurityEvent($event); } #[TestDox('Rule lifecycle events map to recorded audit entries')] public function testRuleLifecycleAudit(): void { $this->currentTenant = null; $this->store->expects($this->once()) ->method('createLog') ->with(self::callback(static function (FirewallLogObject $log): bool { return $log->getEventType() === FirewallLogObject::EVENT_RULE_DISABLED && $log->getResult() === FirewallLogObject::RESULT_RECORDED && $log->getRuleId() === 'rule-123' && $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM && $log->getIdentityId() === 'operator'; })) ->willReturnArgument(0); $event = new \KTXC\Security\Event\SecurityEvent( \KTXC\Security\Event\SecurityEvent::FIREWALL_RULE_DISABLED, [ 'ruleId' => 'rule-123', 'ruleScope' => FirewallRuleObject::SCOPE_SYSTEM, 'origin' => FirewallRuleManager::ORIGIN_MANUAL, ], identityId: 'operator', ); $this->service->logSecurityEvent($event); } #[TestDox('Settings changes map to recorded tenant audit entries')] public function testSettingsAudit(): void { $this->store->expects(self::once()) ->method('createLog') ->with(self::callback(static fn(FirewallLogObject $log): bool => $log->getEventType() === FirewallLogObject::EVENT_SETTINGS_UPDATED && $log->getResult() === FirewallLogObject::RESULT_RECORDED && $log->getTenantId() === 'tenant-a' && $log->getIdentityId() === 'operator' && $log->getMetadata()['changeReason'] === 'Tighten controls' )) ->willReturnArgument(0); $event = new \KTXC\Security\Event\SecurityEvent( \KTXC\Security\Event\SecurityEvent::FIREWALL_SETTINGS_UPDATED, ['changeReason' => 'Tighten controls'], tenantId: 'tenant-a', identityId: 'operator', ); $this->service->logSecurityEvent($event); } #[TestDox('Typed tenant firewall settings drive brute-force thresholds')] public function testFirewallConfiguration(): void { $this->store->method('createLogOnce')->willReturn(true); $this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([ 'firewall' => [ 'enabled' => true, 'maxAuthFailures' => 8, 'authFailureWindow' => 600, 'autoBlockDuration' => 7200, ], ]); $this->store->expects($this->once()) ->method('countRecentFailures') ->with('tenant-a', '203.0.113.10', 600) ->willReturn(4); $this->events->expects($this->never())->method('dispatch'); $event = \KTXC\Security\Event\SecurityEvent::authFailure( '203.0.113.10', tenantId: 'tenant-a', ); $this->service->handleAuthFailure($event); self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures()); self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration()); } #[TestDox('Unsafe numeric firewall settings fall back to safe defaults')] public function testConfigurationBounds(): void { $this->store->method('createLogOnce')->willReturn(true); $this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([ 'firewall' => [ 'maxAuthFailures' => 0, 'authFailureWindow' => -1, 'autoBlockDuration' => 0, ], ]); $this->store->expects($this->once()) ->method('countRecentFailures') ->with('tenant-a', '203.0.113.10', 300) ->willReturn(0); $event = \KTXC\Security\Event\SecurityEvent::authFailure( '203.0.113.10', tenantId: 'tenant-a', ); $this->service->handleAuthFailure($event); } #[TestDox('Automatic blocks retain the tenant carried by the authentication event')] public function testAutomaticBlockTenant(): void { $this->store->method('createLogOnce')->willReturn(true); $this->currentTenant = 'tenant-context'; $this->store->expects($this->once()) ->method('countRecentFailures') ->with('tenant-event', '203.0.113.10', 300) ->willReturn(5); $this->store->expects($this->once()) ->method('claimBruteForce') ->with('tenant-event', '203.0.113.10', 300) ->willReturn(true); $this->store->expects($this->once()) ->method('findExactIpRule') ->with( 'tenant-event', '203.0.113.10', FirewallRuleObject::ACTION_BLOCK ) ->willReturn(null); $this->store->expects($this->once()) ->method('depositRule') ->with(self::callback(static function (FirewallRuleObject $rule): bool { $metadata = $rule->getMetadata(); return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT && $rule->getTenantId() === 'tenant-event' && $rule->getExpiresAt() !== null && $metadata['failureThreshold'] === 5 && $metadata['failureWindowSeconds'] === 300 && $metadata['lastFailureCount'] === 5 && $metadata['blockDurationSeconds'] === 3600; })) ->willReturnArgument(0); $publishedTenants = []; $lifecycleOrigin = null; $this->events->expects($this->exactly(3)) ->method('dispatch') ->willReturnCallback(static function (\KTXF\Event\Event $event) use ( &$publishedTenants, &$lifecycleOrigin ): void { $publishedTenants[] = $event->getTenantId(); if ($event->getName() === \KTXC\Security\Event\SecurityEvent::FIREWALL_RULE_CREATED) { $lifecycleOrigin = $event->get('origin'); } }); $event = \KTXC\Security\Event\SecurityEvent::authFailure( '203.0.113.10', tenantId: 'tenant-event', ); $this->service->handleAuthFailure($event); self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants); self::assertSame(FirewallRuleManager::ORIGIN_AUTOMATIC, $lifecycleOrigin); } #[TestDox('Workers that lose the brute-force claim do not block or publish detection events')] public function testAutomaticBlockClaimLoss(): void { $this->store->method('createLogOnce')->willReturn(true); $this->store->expects($this->once()) ->method('countRecentFailures') ->with('tenant-a', '203.0.113.10', 300) ->willReturn(5); $this->store->expects($this->once()) ->method('claimBruteForce') ->with('tenant-a', '203.0.113.10', 300) ->willReturn(false); $this->store->expects($this->never())->method('depositRule'); $this->events->expects($this->never())->method('dispatch'); $this->service->handleAuthFailure( \KTXC\Security\Event\SecurityEvent::authFailure('203.0.113.10') ); } #[TestDox('Authentication events without a tenant use the current tenant')] public function testAutomaticBlockTenantFallback(): void { $this->store->method('createLogOnce')->willReturn(true); $this->store->expects($this->once()) ->method('countRecentFailures') ->with('tenant-a', '203.0.113.10', 300) ->willReturn(0); $this->service->handleAuthFailure( \KTXC\Security\Event\SecurityEvent::authFailure('203.0.113.10') ); } #[TestDox('Authentication failures are ignored when no tenant can be resolved')] public function testAutomaticBlockWithoutTenant(): void { $this->currentTenant = null; $this->store->expects($this->never())->method('countRecentFailures'); $this->store->expects($this->never())->method('depositRule'); $this->service->handleAuthFailure( \KTXC\Security\Event\SecurityEvent::authFailure('203.0.113.10') ); } #[TestDox('Repeated delivery of one authentication event is counted once')] public function testAuthenticationFailureIdempotency(): void { $this->store->expects($this->exactly(2)) ->method('createLogOnce') ->willReturnOnConsecutiveCalls(true, false); $this->store->expects($this->once()) ->method('countRecentFailures') ->with('tenant-a', '203.0.113.10', 300) ->willReturn(1); $event = \KTXC\Security\Event\SecurityEvent::authFailure('203.0.113.10'); $eventId = $event->getEventId(); $this->service->handleAuthFailure($event); $this->service->handleAuthFailure($event); self::assertSame($eventId, $event->getEventId()); } #[TestDox('Cleanup records successful maintenance counts')] public function testCleanupStatus(): void { $this->store->method('cleanupExpiredRules')->willReturn(2); $this->store->method('cleanupOldLogs')->with(30)->willReturn(3); $this->store->method('cleanupExpiredBruteForceClaims')->willReturn(4); $this->store->expects(self::once()) ->method('recordMaintenanceStatus') ->with( self::isInstanceOf(\DateTimeImmutable::class), self::isInstanceOf(\DateTimeImmutable::class), 'success', [ 'expiredRules' => 2, 'oldLogs' => 3, 'expiredBruteForceClaims' => 4, ] ); self::assertSame([ 'expiredRules' => 2, 'oldLogs' => 3, 'expiredBruteForceClaims' => 4, ], $this->service->cleanup()); } #[TestDox('Cleanup failures are recorded and rethrown')] public function testCleanupFailureStatus(): void { $this->store->method('cleanupExpiredRules')->willThrowException(new \RuntimeException('cleanup failed')); $this->store->expects(self::once()) ->method('recordMaintenanceStatus') ->with( self::isInstanceOf(\DateTimeImmutable::class), self::isInstanceOf(\DateTimeImmutable::class), 'failed', [], 'cleanup failed' ); $this->expectExceptionMessage('cleanup failed'); $this->service->cleanup(); } private function rule( string $id, string $scope, string $action, ?string $tenantId ): FirewallRuleObject { return (new FirewallRuleObject()) ->setId($id) ->setScope($scope) ->setTenantId($tenantId) ->setType(FirewallRuleObject::TYPE_IP) ->setAction($action) ->setValue('203.0.113.10') ->setReason($id); } private function disableTenantFirewall(): void { $this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([ 'firewall' => ['enabled' => false], ]); } }