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()); } }