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 = \KTXF\Event\SecurityEvent::accessDenied( '203.0.113.10', null, 'tenant-rule', FirewallRuleObject::SCOPE_TENANT, 'Tenant block' ); $event->setTenantId('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 = \KTXF\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( \KTXF\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 = \KTXF\Event\SecurityEvent::rateLimitExceeded( '203.0.113.10', 101, 60, '/login' ); $event->setTenantId('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 = \KTXF\Event\SecurityEvent::create( \KTXF\Event\SecurityEvent::SUSPICIOUS_ACTIVITY, '203.0.113.20', null, ['detector' => 'payload-signature'] ); $event->setTenantId('tenant-a') ->setRequestPath('/admin') ->setRequestMethod('POST'); $this->service->logSecurityEvent($event); } #[TestDox('Typed tenant firewall settings drive brute-force thresholds')] public function testFirewallConfiguration(): void { $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 = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10'); $event->setTenantId('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->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 = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10'); $event->setTenantId('tenant-a'); $this->service->handleAuthFailure($event); } #[TestDox('Automatic blocks retain the tenant carried by the authentication event')] public function testAutomaticBlockTenant(): void { $this->currentTenant = 'tenant-context'; $this->store->expects($this->once()) ->method('countRecentFailures') ->with('tenant-event', '203.0.113.10', 300) ->willReturn(4); $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 { return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT && $rule->getTenantId() === 'tenant-event' && $rule->getExpiresAt() !== null; })) ->willReturnArgument(0); $publishedTenants = []; $this->events->expects($this->exactly(2)) ->method('dispatch') ->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$publishedTenants): void { $publishedTenants[] = $event->getTenantId(); }); $event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10'); $event->setTenantId('tenant-event'); $this->service->handleAuthFailure($event); self::assertSame(['tenant-event', 'tenant-event'], $publishedTenants); } #[TestDox('Authentication events without a tenant use the current tenant')] public function testAutomaticBlockTenantFallback(): void { $this->store->expects($this->once()) ->method('countRecentFailures') ->with('tenant-a', '203.0.113.10', 300) ->willReturn(0); $this->service->handleAuthFailure( \KTXF\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( \KTXF\Event\SecurityEvent::authFailure('203.0.113.10') ); } 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], ]); } }