dataStore = new DataStore($database); try { $this->dataStore->getDatabase()->drop(); $this->databaseAvailable = true; } catch (\MongoDB\Driver\Exception\Exception $error) { self::markTestSkipped('MongoDB is unavailable: '.$error->getMessage()); } $this->store = new FirewallStore($this->dataStore); } protected function tearDown(): void { if ($this->databaseAvailable) { $this->dataStore->getDatabase()->drop(); } } #[TestDox('System and tenant rule sets remain independently scoped')] public function testApplicableScopes(): void { $tenantA = $this->rule('tenant-a', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'); $tenantB = $this->rule('tenant-b', FirewallRuleObject::SCOPE_TENANT, 'tenant-b'); $system = $this->rule('system', FirewallRuleObject::SCOPE_SYSTEM); $expiredSystem = $this->rule('expired-system', FirewallRuleObject::SCOPE_SYSTEM) ->setExpiresAt(new \DateTimeImmutable('-1 minute')); $disabledSystem = $this->rule('disabled-system', FirewallRuleObject::SCOPE_SYSTEM) ->setEnabled(false); foreach ([$tenantA, $tenantB, $system, $expiredSystem, $disabledSystem] as $rule) { $this->store->depositRule($rule); } $rules = array_merge( $this->store->listSystemRules(), $this->store->listRules('tenant-a') ); $reasons = array_map(static fn(FirewallRuleObject $rule): ?string => $rule->getReason(), $rules); sort($reasons); self::assertSame(['system', 'tenant-a'], $reasons); } #[TestDox('Tenant rule listings cannot expose system or other tenant rules')] public function testTenantListingIsolation(): void { $this->store->depositRule($this->rule('tenant-a', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')); $this->store->depositRule($this->rule('tenant-b', FirewallRuleObject::SCOPE_TENANT, 'tenant-b')); $this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM)); $rules = $this->store->listRules('tenant-a'); self::assertCount(1, $rules); self::assertSame('tenant-a', $rules[0]->getReason()); self::assertSame(FirewallRuleObject::SCOPE_TENANT, $rules[0]->getScope()); } #[TestDox('Expired exact IP rules do not suppress a replacement block')] public function testExpiredBlockReplacement(): void { $expired = $this->rule('expired', FirewallRuleObject::SCOPE_TENANT, 'tenant-a') ->setExpiresAt(new \DateTimeImmutable('-1 minute')); $this->store->depositRule($expired); self::assertNull($this->store->findExactIpRule( 'tenant-a', '203.0.113.10', FirewallRuleObject::ACTION_BLOCK )); $events = $this->createMock(EventDispatcherInterface::class); $cache = new FirewallRuleCache($this->store); $manager = new FirewallRuleManager($this->store, $cache, $events); $replacement = $manager->blockIp( FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, null, 300 ); self::assertFalse($replacement->isExpired()); self::assertNotSame($expired->getId(), $replacement->getId()); self::assertSame( $replacement->getId(), $this->store->findExactIpRule( 'tenant-a', '203.0.113.10', FirewallRuleObject::ACTION_BLOCK )?->getId() ); self::assertCount(2, $this->store->listRules('tenant-a', false)); } #[TestDox('Exact IP lookups respect tenant and system scope')] public function testExactLookupScope(): void { $tenant = $this->rule('tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'); $system = $this->rule('system', FirewallRuleObject::SCOPE_SYSTEM); $this->store->depositRule($tenant); $this->store->depositRule($system); self::assertSame( $tenant->getId(), $this->store->findExactIpRule( 'tenant-a', '203.0.113.10', FirewallRuleObject::ACTION_BLOCK )?->getId() ); self::assertSame( $system->getId(), $this->store->findExactIpRule( null, '203.0.113.10', FirewallRuleObject::ACTION_BLOCK, FirewallRuleObject::SCOPE_SYSTEM )?->getId() ); self::assertNull($this->store->findExactIpRule( 'tenant-b', '203.0.113.10', FirewallRuleObject::ACTION_BLOCK )); } #[TestDox('System rule listings exclude tenant, disabled, and expired rules')] public function testSystemListing(): void { $this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM)); $this->store->depositRule( $this->rule('disabled', FirewallRuleObject::SCOPE_SYSTEM)->setEnabled(false) ); $this->store->depositRule( $this->rule('expired', FirewallRuleObject::SCOPE_SYSTEM) ->setExpiresAt(new \DateTimeImmutable('-1 minute')) ); $this->store->depositRule( $this->rule('tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-a') ); $rules = $this->store->listSystemRules(); self::assertCount(1, $rules); self::assertSame('system', $rules[0]->getReason()); self::assertTrue($rules[0]->isSystemScoped()); } #[TestDox('Firewall log persistence retains matched rule context')] public function testRuleAuditPersistence(): void { $log = (new FirewallLogObject()) ->setTenantId('tenant-a') ->setIpAddress('203.0.113.10') ->setEventType(FirewallLogObject::EVENT_RULE_MATCH) ->setResult(FirewallLogObject::RESULT_BLOCKED) ->setRuleId('rule-123') ->setRuleScope(FirewallRuleObject::SCOPE_TENANT) ->setTimestamp(new \DateTimeImmutable()); $this->store->createLog($log); $logs = $this->store->listLogs('tenant-a'); self::assertCount(1, $logs); self::assertSame('rule-123', $logs[0]->getRuleId()); self::assertSame(FirewallRuleObject::SCOPE_TENANT, $logs[0]->getRuleScope()); } #[TestDox('Rule lifecycle audit persistence retains actor and origin')] public function testLifecycleAuditPersistence(): void { $log = (new FirewallLogObject()) ->setTenantId('tenant-a') ->setEventType(FirewallLogObject::EVENT_RULE_CREATED) ->setResult(FirewallLogObject::RESULT_RECORDED) ->setRuleId('rule-456') ->setRuleScope(FirewallRuleObject::SCOPE_TENANT) ->setIdentityId('admin-a') ->setTimestamp(new \DateTimeImmutable()) ->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]); $this->store->createLog($log); $logs = $this->store->listLogs('tenant-a'); self::assertCount(1, $logs); self::assertSame(FirewallLogObject::EVENT_RULE_CREATED, $logs[0]->getEventType()); self::assertSame(FirewallLogObject::RESULT_RECORDED, $logs[0]->getResult()); self::assertSame('admin-a', $logs[0]->getIdentityId()); self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $logs[0]->getMetadata()['origin']); } #[TestDox('Event-backed firewall logs are inserted exactly once')] public function testIdempotentLogPersistence(): void { $log = (new FirewallLogObject()) ->setEventId('event-123') ->setTenantId('tenant-a') ->setIpAddress('203.0.113.10') ->setEventType(FirewallLogObject::EVENT_AUTH_FAILURE) ->setResult(FirewallLogObject::RESULT_BLOCKED) ->setTimestamp(new \DateTimeImmutable()); self::assertTrue($this->store->createLogOnce($log)); self::assertFalse($this->store->createLogOnce($log)); $logs = $this->store->listLogs('tenant-a'); self::assertCount(1, $logs); self::assertSame('event-123', $logs[0]->getEventId()); } private function rule( string $reason, string $scope, ?string $tenantId = null ): FirewallRuleObject { return (new FirewallRuleObject()) ->setScope($scope) ->setTenantId($tenantId) ->setType(FirewallRuleObject::TYPE_IP) ->setAction(FirewallRuleObject::ACTION_BLOCK) ->setValue('203.0.113.10') ->setReason($reason) ->setCreatedAt(new \DateTimeImmutable()) ->setEnabled(true); } }