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') ->willReturnCallback(static function (FirewallRuleObject $rule): FirewallRuleObject { static $sequence = 0; return $rule->setId('rule-' . ++$sequence); }); $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('Manual rule creation requires a reason before persistence')] public function testManualReason(): void { $this->store->expects(self::never())->method('depositRule'); $this->expectException(\InvalidArgumentException::class); $this->manager->createManualRule( FirewallRuleScope::tenant('tenant-a'), FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK, '203.0.113.10', ' ', 'admin-a' ); } #[TestDox('Blocking the current exact IP requires explicit confirmation')] public function testCurrentIpSafeguard(): void { $this->store->expects(self::never())->method('depositRule'); try { $this->manager->createManualRule( FirewallRuleScope::tenant('tenant-a'), FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK, '2001:db8::1', 'Confirmed abuse', 'admin-a', currentIp: '2001:0db8:0:0:0:0:0:1' ); self::fail('Current IP block should require confirmation.'); } catch (FirewallRuleConflictException $error) { self::assertSame('current_ip_confirmation_required', $error->conflictCode); } } #[TestDox('CIDR rules covering the current IP require explicit confirmation')] public function testCurrentIpCidrSafeguard(): void { $this->store->expects(self::never())->method('depositRule'); $this->expectException(FirewallRuleConflictException::class); $this->manager->createManualRule( FirewallRuleScope::system(), FirewallRuleObject::TYPE_IP_RANGE, FirewallRuleObject::ACTION_BLOCK, '203.0.113.0/24', 'Network abuse', 'admin-a', currentIp: '203.0.113.10' ); } #[TestDox('Confirmed current-IP blocks retain manual audit context')] public function testConfirmedCurrentIpBlock(): void { $this->store->method('findExactIpRule')->willReturn(null); $this->store->expects(self::once()) ->method('depositRule') ->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject => $rule->setId('rule-confirmed') ); $rule = $this->manager->createManualRule( FirewallRuleScope::tenant('tenant-a'), FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK, '203.0.113.10', 'Emergency lockout', 'admin-a', currentIp: '203.0.113.10', confirmCurrentIp: true ); self::assertSame('Emergency lockout', $rule->getReason()); self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $rule->getMetadata()['origin']); } #[TestDox('Manual creation rejects unsupported type and action combinations')] public function testUnsupportedManualRule(): void { $this->store->expects(self::never())->method('depositRule'); $this->expectException(\InvalidArgumentException::class); $this->manager->createManualRule( FirewallRuleScope::tenant('tenant-a'), FirewallRuleObject::TYPE_DEVICE, FirewallRuleObject::ACTION_ALLOW, 'device-123', 'Trusted device', 'admin-a' ); } #[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::assertNull($this->manager->removeManual( FirewallRuleScope::system(), 'tenant-rule', 'No longer required', 'operator' )); self::assertNull($this->manager->removeManual( FirewallRuleScope::tenant('tenant-b'), 'tenant-rule', 'No longer required', 'operator' )); } #[TestDox('Rule queries retain scope, filters, and bounded pagination')] public function testRuleQuery(): void { $result = ['items' => [], 'total' => 0, 'limit' => 25, 'offset' => 50]; $this->store->expects(self::once()) ->method('queryRules') ->with( FirewallRuleObject::SCOPE_TENANT, 'tenant-a', 'disabled', FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK, 25, 50 ) ->willReturn($result); self::assertSame($result, $this->manager->query( FirewallRuleScope::tenant('tenant-a'), 'disabled', FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK, 25, 50 )); } #[TestDox('Rule queries reject invalid filters and excessive pages')] public function testRuleQueryValidation(): void { $this->store->expects(self::never())->method('queryRules'); $this->expectException(\InvalidArgumentException::class); $this->manager->query(FirewallRuleScope::system(), 'active', null, null, 101); } #[TestDox('Effective policy keeps system and tenant rule sets distinct')] public function testEffectivePolicy(): void { $system = (new FirewallRuleObject())->setScope(FirewallRuleObject::SCOPE_SYSTEM); $tenant = (new FirewallRuleObject()) ->setScope(FirewallRuleObject::SCOPE_TENANT) ->setTenantId('tenant-a'); $this->store->method('listSystemRules')->willReturn([$system]); $this->store->method('listRules')->with('tenant-a')->willReturn([$tenant]); $policy = $this->manager->effectivePolicy('tenant-a'); self::assertSame([$system], $policy['system']); self::assertSame([$tenant], $policy['tenant']); self::assertSame('system_block', $policy['precedence'][0]); } #[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')->willReturnCallback( static fn(FirewallRuleObject $rule): FirewallRuleObject => $rule->setId('rule-cache') ); 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->label()] = $event; }); $this->manager->blockIp( FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', 'Repeated abuse', 'admin-a', 300 ); $audit = $events[FirewallRuleCreatedEvent::class]; 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->actorIdentity()); self::assertNotNull($audit->get('expiresAt')); self::assertInstanceOf(IpBlockedEvent::class, $events[IpBlockedEvent::class]); self::assertSame('203.0.113.10', $events[IpBlockedEvent::class]->getIpAddress()); } #[TestDox('IP allowance and device blocking publish dedicated policy events')] public function testSubjectPolicyEvents(): void { $this->store->method('findExactIpRule')->willReturn(null); $this->store->method('depositRule')->willReturnCallback( static function (FirewallRuleObject $rule): FirewallRuleObject { static $sequence = 0; return $rule->setId('policy-rule-' . ++$sequence); } ); $events = []; $this->events->expects($this->exactly(4)) ->method('dispatch') ->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void { $events[] = $event; }); $this->manager->allowIp( FirewallRuleScope::tenant('tenant-a'), '203.0.113.11', 'Trusted service', 'admin-a', ); $this->manager->blockDevice( FirewallRuleScope::tenant('tenant-a'), 'device-a', 'Compromised device', 'admin-a', ); self::assertCount(1, array_filter($events, static fn($event): bool => $event instanceof IpAllowedEvent)); self::assertCount(1, array_filter($events, static fn($event): bool => $event instanceof DeviceBlockedEvent)); } #[TestDox('Manual lifecycle changes persist state, reasons, actors, and extension history')] public function testManualLifecycle(): void { $originalExpiry = new \DateTimeImmutable('+5 minutes'); $rule = (new FirewallRuleObject()) ->setId('rule-123') ->setScope(FirewallRuleObject::SCOPE_SYSTEM) ->setType(FirewallRuleObject::TYPE_IP) ->setAction(FirewallRuleObject::ACTION_BLOCK) ->setValue('203.0.113.10') ->setEnabled(true) ->setExpiresAt($originalExpiry) ->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]); $this->store->method('fetchRule')->willReturn($rule); $this->store->expects(self::exactly(3))->method('depositRule')->willReturnArgument(0); $this->store->expects(self::once())->method('destroyRule')->with($rule); $audits = []; $this->events->expects(self::exactly(4)) ->method('dispatch') ->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$audits): void { $audits[] = $event; }); self::assertFalse($this->manager->disableManual( FirewallRuleScope::system(), 'rule-123', 'Investigation', 'operator' )->isEnabled()); self::assertTrue($this->manager->enableManual( FirewallRuleScope::system(), 'rule-123', 'Threat confirmed', 'operator', '203.0.113.10', true )->isEnabled()); $extended = $this->manager->extendManual( FirewallRuleScope::system(), 'rule-123', 300, 'Continue monitoring', 'operator' ); self::assertSame($rule, $this->manager->removeManual( FirewallRuleScope::system(), 'rule-123', 'Case closed', 'operator' )); self::assertGreaterThan($originalExpiry, $extended->getExpiresAt()); self::assertCount(1, $extended->getMetadata()['extensions']); self::assertSame([ FirewallRuleDisabledEvent::class, FirewallRuleEnabledEvent::class, FirewallRuleExtendedEvent::class, FirewallRuleRemovedEvent::class, ], array_map(static fn($event): string => $event->label(), $audits)); self::assertSame( ['Investigation', 'Threat confirmed', 'Continue monitoring', 'Case closed'], array_map(static fn($event): string => $event->get('changeReason'), $audits) ); self::assertSame(['operator'], array_values(array_unique(array_map( static fn($event): ?string => $event->actorIdentity(), $audits )))); } #[TestDox('Manual lifecycle changes require reasons before reading or writing rules')] public function testLifecycleReason(): void { $this->store->expects(self::never())->method('fetchRule'); $this->expectException(\InvalidArgumentException::class); $this->manager->disableManual(FirewallRuleScope::system(), 'rule-123', ' ', 'operator'); } #[TestDox('Permanent rules cannot be extended')] public function testPermanentExtension(): void { $rule = (new FirewallRuleObject()) ->setId('rule-123') ->setScope(FirewallRuleObject::SCOPE_SYSTEM); $this->store->method('fetchRule')->willReturn($rule); $this->store->expects(self::never())->method('depositRule'); $this->expectException(\InvalidArgumentException::class); $this->manager->extendManual( FirewallRuleScope::system(), 'rule-123', 300, 'Continue monitoring', 'operator' ); } #[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 instanceof FirewallRuleExtendedEvent && $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 )); } }