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 ); $this->service = new FirewallService($this->store, $this->tenantContext, $this->events); } #[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('listApplicableRules') ->with('tenant-a') ->willReturn([$tenantAllow, $systemBlock]); $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('listApplicableRules')->willReturn([$systemAllow, $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->exactly(2)) ->method('listApplicableRules') ->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('New IP blocks are explicitly tenant-scoped')] public function testIpBlockScope(): void { $this->store->method('findExactIpRule')->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-a'; })) ->willReturnArgument(0); $rule = $this->service->blockIp('203.0.113.10'); self::assertSame(FirewallRuleObject::SCOPE_TENANT, $rule->getScope()); self::assertSame('tenant-a', $rule->getTenantId()); } #[TestDox('Malformed IP addresses are rejected before persistence')] public function testIpValidation(): void { $this->store->expects($this->never())->method('depositRule'); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid IP address'); $this->service->blockIp('999.2.3.4'); } #[TestDox('Valid IPv6 addresses can be blocked')] public function testIpv6Validation(): void { $this->store->method('findExactIpRule')->willReturn(null); $this->store->expects($this->once()) ->method('depositRule') ->with(self::callback(static fn(FirewallRuleObject $rule): bool => $rule->getValue() === '2001:db8::1')) ->willReturnArgument(0); self::assertSame('2001:db8::1', $this->service->blockIp(' 2001:db8::1 ')->getValue()); } #[TestDox('Malformed CIDR ranges are rejected before persistence')] public function testCidrValidation(): void { $this->store->expects($this->never())->method('depositRule'); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid CIDR range'); $this->service->blockIpRange('2001:db8::/129'); } #[TestDox('Valid IPv4 and IPv6 CIDR ranges are accepted')] public function testValidCidrs(): void { $this->store->expects($this->exactly(2))->method('depositRule')->willReturnArgument(0); self::assertSame('192.0.2.0/24', $this->service->blockIpRange('192.0.2.0/24')->getValue()); self::assertSame('2001:db8::/32', $this->service->blockIpRange('2001:db8::/32')->getValue()); } #[TestDox('Temporary rules require a positive duration')] public function testDurationValidation(): void { $this->store->expects($this->never())->method('depositRule'); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('greater than zero'); $this->service->blockIp('203.0.113.10', durationSeconds: 0); } #[TestDox('Device fingerprints must be non-empty and bounded')] public function testFingerprintValidation(): void { $this->store->expects($this->never())->method('depositRule'); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Device fingerprint'); $this->service->blockDevice(' '); } #[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); } }