store = $this->createMock(FirewallStore::class); $this->events = $this->createMock(EventDispatcherInterface::class); $this->manager = new FirewallRuleManager( $this->store, new FirewallRuleCache($this->store), $this->events ); } #[TestDox('The shared manager creates rules with the supplied scope and owner')] public function testScopeCreation(): void { $this->store->method('findExactIpRule')->willReturn(null); $this->store->expects($this->exactly(2)) ->method('depositRule') ->willReturnArgument(0); $tenant = $this->manager->blockIp( FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin-a' ); $system = $this->manager->blockIp( FirewallRuleScope::system(), '203.0.113.11', null, 'system-admin' ); self::assertSame('tenant-a', $tenant->getTenantId()); self::assertSame(FirewallRuleObject::SCOPE_TENANT, $tenant->getScope()); self::assertNull($system->getTenantId()); self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $system->getScope()); } #[TestDox('Malformed rule values are rejected before persistence')] public function testValidation(): void { $this->store->expects($this->never())->method('depositRule'); $this->expectException(\InvalidArgumentException::class); $this->manager->blockIpRange( FirewallRuleScope::tenant('tenant-a'), '2001:db8::/129', null, 'admin-a' ); } #[TestDox('Temporary rules require a positive duration')] public function testDuration(): void { $this->store->expects($this->never())->method('depositRule'); $this->expectException(\InvalidArgumentException::class); $this->manager->blockIp( FirewallRuleScope::system(), '203.0.113.10', null, 'admin', 0 ); } #[TestDox('Rule lifecycle operations cannot cross scope ownership')] public function testOwnership(): void { $tenantRule = (new FirewallRuleObject()) ->setId('tenant-rule') ->setScope(FirewallRuleObject::SCOPE_TENANT) ->setTenantId('tenant-a'); $this->store->method('fetchRule')->willReturn($tenantRule); $this->store->expects($this->never())->method('destroyRule'); self::assertFalse($this->manager->remove(FirewallRuleScope::system(), 'tenant-rule')); self::assertFalse($this->manager->remove(FirewallRuleScope::tenant('tenant-b'), 'tenant-rule')); } #[TestDox('Rule mutations invalidate the shared enforcement cache')] public function testCacheInvalidation(): void { $this->store->expects($this->exactly(2)) ->method('listRules') ->with('tenant-a') ->willReturnOnConsecutiveCalls([], []); $cache = new FirewallRuleCache($this->store); $manager = new FirewallRuleManager($this->store, $cache, $this->events); $this->store->method('findExactIpRule')->willReturn(null); $this->store->method('depositRule')->willReturnArgument(0); self::assertSame([], $cache->tenant('tenant-a')); $manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin'); self::assertSame([], $cache->tenant('tenant-a')); } #[TestDox('Rule creation emits complete lifecycle audit context')] public function testCreationAudit(): void { $this->store->method('findExactIpRule')->willReturn(null); $this->store->method('depositRule')->willReturnCallback( static function (FirewallRuleObject $rule): FirewallRuleObject { return $rule->setId('rule-123'); } ); $events = []; $this->events->expects($this->exactly(2)) ->method('dispatch') ->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void { $events[$event->getName()] = $event; }); $this->manager->blockIp( FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', 'Repeated abuse', 'admin-a', 300 ); $audit = $events[SecurityEvent::FIREWALL_RULE_CREATED]; self::assertSame('rule-123', $audit->get('ruleId')); self::assertSame(FirewallRuleObject::SCOPE_TENANT, $audit->get('ruleScope')); self::assertSame(FirewallRuleObject::TYPE_IP, $audit->get('ruleType')); self::assertSame(FirewallRuleObject::ACTION_BLOCK, $audit->get('ruleAction')); self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $audit->get('origin')); self::assertSame('admin-a', $audit->getIdentityId()); self::assertNotNull($audit->get('expiresAt')); } #[TestDox('Disable and remove audits identify the acting administrator')] public function testLifecycleActors(): void { $rule = (new FirewallRuleObject()) ->setId('rule-123') ->setScope(FirewallRuleObject::SCOPE_SYSTEM) ->setTenantId(null) ->setType(FirewallRuleObject::TYPE_IP) ->setAction(FirewallRuleObject::ACTION_BLOCK) ->setValue('203.0.113.10') ->setCreatedBy('creator'); $this->store->method('fetchRule')->willReturn($rule); $events = []; $this->events->expects($this->exactly(2)) ->method('dispatch') ->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void { $events[$event->getName()] = $event; }); self::assertTrue($this->manager->disable(FirewallRuleScope::system(), 'rule-123', 'operator')); self::assertTrue($this->manager->remove(FirewallRuleScope::system(), 'rule-123', 'operator')); self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_DISABLED]->getIdentityId()); self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_REMOVED]->getIdentityId()); } #[TestDox('Continued attacks extend automatic blocks and retain their audit history')] public function testAutomaticBlockExtension(): void { $originalExpiry = new \DateTimeImmutable('+5 minutes'); $rule = (new FirewallRuleObject()) ->setId('rule-123') ->setScope(FirewallRuleObject::SCOPE_TENANT) ->setTenantId('tenant-a') ->setType(FirewallRuleObject::TYPE_IP) ->setAction(FirewallRuleObject::ACTION_BLOCK) ->setValue('203.0.113.10') ->setExpiresAt($originalExpiry) ->setMetadata([ 'origin' => FirewallRuleManager::ORIGIN_AUTOMATIC, 'originalExpiresAt' => $originalExpiry->format(\DateTimeInterface::ATOM), 'extensions' => [], ]); $this->store->method('findExactIpRule')->willReturn($rule); $this->store->expects(self::once()) ->method('depositRule') ->with(self::callback(static function (FirewallRuleObject $extended) use ($originalExpiry): bool { $metadata = $extended->getMetadata(); return $extended->getExpiresAt() > $originalExpiry && $metadata['failureThreshold'] === 5 && $metadata['failureWindowSeconds'] === 300 && $metadata['lastFailureCount'] === 8 && $metadata['originalExpiresAt'] === $originalExpiry->format(\DateTimeInterface::ATOM) && count($metadata['extensions']) === 1; })) ->willReturnArgument(0); $this->events->expects(self::once()) ->method('dispatch') ->with(self::callback(static fn(\KTXF\Event\Event $event): bool => $event->getName() === SecurityEvent::FIREWALL_RULE_EXTENDED && $event->get('lastFailureCount') === 8 )); $extended = $this->manager->blockIp( FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', 'Continued attack', null, 3600, FirewallRuleManager::ORIGIN_AUTOMATIC, [ 'failureThreshold' => 5, 'failureWindowSeconds' => 300, 'lastFailureCount' => 8, 'blockDurationSeconds' => 3600, ] ); self::assertSame('rule-123', $extended->getId()); } #[TestDox('Automatic detection never extends a manual block')] public function testManualBlockIsNotExtended(): void { $rule = (new FirewallRuleObject()) ->setScope(FirewallRuleObject::SCOPE_TENANT) ->setTenantId('tenant-a') ->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]); $this->store->method('findExactIpRule')->willReturn($rule); $this->store->expects(self::never())->method('depositRule'); $this->events->expects(self::never())->method('dispatch'); self::assertSame($rule, $this->manager->blockIp( FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, null, 3600, FirewallRuleManager::ORIGIN_AUTOMATIC )); } }