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()); } #[TestDox('Only one worker can claim a tenant and IP brute-force response')] public function testBruteForceClaim(): void { self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600)); self::assertFalse($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600)); self::assertTrue($this->store->claimBruteForce('tenant-b', '203.0.113.10', 3600)); self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.11', 3600)); } #[TestDox('Expired brute-force claims can be acquired again and cleaned up')] public function testExpiredBruteForceClaim(): void { self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600)); $claimId = hash('sha256', "tenant-a\0"."203.0.113.10"); $this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne( ['_id' => $claimId], ['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]] ); self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600)); $this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne( ['_id' => $claimId], ['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]] ); self::assertSame(1, $this->store->cleanupExpiredBruteForceClaims()); } #[TestDox('Firewall dates use BSON date storage and indexes are installed idempotently')] public function testBsonDatesAndIndexes(): void { $rule = $this->rule('bson-date', FirewallRuleObject::SCOPE_TENANT, 'tenant-a') ->setExpiresAt(new \DateTimeImmutable('+1 hour')); $this->store->depositRule($rule); $log = (new FirewallLogObject()) ->setTenantId('tenant-a') ->setEventType(FirewallLogObject::EVENT_ACCESS_CHECK) ->setResult(FirewallLogObject::RESULT_ALLOWED) ->setTimestamp(new \DateTimeImmutable()); $this->store->createLog($log); $storedRule = $this->dataStore->selectCollection('firewall_rules') ->getMongoCollection() ->findOne(['_id' => new \MongoDB\BSON\ObjectId($rule->getId())]); $storedLog = $this->dataStore->selectCollection('firewall_logs') ->getMongoCollection() ->findOne(['_id' => new \MongoDB\BSON\ObjectId($log->getId())]); self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedRule['createdAt']); self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedRule['expiresAt']); self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedLog['timestamp']); $expected = $this->store->ensureIndexes(); self::assertSame($expected, $this->store->ensureIndexes()); $claimIndex = null; foreach ($this->dataStore->selectCollection('firewall_brute_force_claims')->getMongoCollection()->listIndexes() as $index) { if ($index->getName() === 'claims_expiry') { $claimIndex = $index; } } self::assertNotNull($claimIndex); self::assertSame(0, $claimIndex['expireAfterSeconds']); } #[TestDox('Automatic block extensions persist policy and expiration history')] public function testAutomaticBlockExtensionPersistence(): void { $events = $this->createMock(EventDispatcherInterface::class); $manager = new FirewallRuleManager($this->store, new FirewallRuleCache($this->store), $events); $scope = FirewallRuleScope::tenant('tenant-a'); $rule = $manager->blockIp( $scope, '203.0.113.10', 'Initial attack', null, 60, FirewallRuleManager::ORIGIN_AUTOMATIC, [ 'failureThreshold' => 5, 'failureWindowSeconds' => 300, 'lastFailureCount' => 5, 'blockDurationSeconds' => 60, ] ); $originalExpiry = $rule->getExpiresAt(); $extended = $manager->blockIp( $scope, '203.0.113.10', 'Continued attack', null, 3600, FirewallRuleManager::ORIGIN_AUTOMATIC, [ 'failureThreshold' => 5, 'failureWindowSeconds' => 300, 'lastFailureCount' => 9, 'blockDurationSeconds' => 3600, ] ); $persisted = $this->store->fetchRule($rule->getId()); self::assertSame($rule->getId(), $extended->getId()); self::assertNotNull($persisted); self::assertGreaterThan($originalExpiry, $persisted->getExpiresAt()); self::assertSame(9, $persisted->getMetadata()['lastFailureCount']); self::assertCount(1, $persisted->getMetadata()['extensions']); } #[TestDox('Cleanup removes only expired rules, old logs, and expired claims')] public function testCleanupBoundaries(): void { $this->store->depositRule( $this->rule('expired', FirewallRuleObject::SCOPE_TENANT, 'tenant-a') ->setExpiresAt(new \DateTimeImmutable('-1 minute')) ); $this->store->depositRule( $this->rule('active', FirewallRuleObject::SCOPE_TENANT, 'tenant-a') ->setExpiresAt(new \DateTimeImmutable('+1 hour')) ); foreach (['-31 days', '-29 days'] as $age) { $this->store->createLog( (new FirewallLogObject()) ->setTenantId('tenant-a') ->setEventType(FirewallLogObject::EVENT_ACCESS_CHECK) ->setResult(FirewallLogObject::RESULT_ALLOWED) ->setTimestamp(new \DateTimeImmutable($age)) ); } self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600)); $claimId = hash('sha256', "tenant-a\0"."203.0.113.10"); $this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne( ['_id' => $claimId], ['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]] ); self::assertSame(1, $this->store->cleanupExpiredRules()); self::assertSame(1, $this->store->cleanupOldLogs()); self::assertSame(1, $this->store->cleanupExpiredBruteForceClaims()); self::assertCount(1, $this->store->listRules('tenant-a')); self::assertCount(1, $this->store->listLogs('tenant-a')); } #[TestDox('Maintenance outcomes are persisted with BSON timestamps')] public function testMaintenanceStatusPersistence(): void { $this->store->recordMaintenanceStatus( new \DateTimeImmutable('-1 second'), new \DateTimeImmutable(), 'success', ['expiredRules' => 2, 'oldLogs' => 3, 'expiredBruteForceClaims' => 4] ); $status = $this->store->maintenanceStatus(); $raw = $this->dataStore->selectCollection('firewall_maintenance') ->getMongoCollection() ->findOne(['_id' => 'cleanup']); self::assertSame('success', $status['status']); self::assertSame(3, $status['result']['oldLogs']); self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $raw['startedAt']); self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $raw['completedAt']); } #[TestDox('Firewall query shapes select their intended indexes')] public function testIndexedQueryShapes(): void { $this->store->ensureIndexes(); $this->store->depositRule($this->rule('indexed', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')); $this->store->createLog( (new FirewallLogObject()) ->setTenantId('tenant-a') ->setIpAddress('203.0.113.10') ->setEventType(FirewallLogObject::EVENT_AUTH_FAILURE) ->setResult(FirewallLogObject::RESULT_BLOCKED) ->setTimestamp(new \DateTimeImmutable()) ); $database = $this->dataStore->getDatabase()->getMongoDatabase(); $now = new \MongoDB\BSON\UTCDateTime(); $rulePlan = $database->command([ 'explain' => [ 'find' => 'firewall_rules', 'filter' => [ 'scope' => FirewallRuleObject::SCOPE_TENANT, 'tenantId' => 'tenant-a', 'type' => FirewallRuleObject::TYPE_IP, 'value' => '203.0.113.10', 'action' => FirewallRuleObject::ACTION_BLOCK, 'enabled' => true, '$or' => [['expiresAt' => null], ['expiresAt' => ['$gt' => $now]]], ], ], 'verbosity' => 'queryPlanner', ])->toArray()[0]; $failurePlan = $database->command([ 'explain' => [ 'find' => 'firewall_logs', 'filter' => [ 'tenantId' => 'tenant-a', 'ipAddress' => '203.0.113.10', 'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE, 'timestamp' => ['$gte' => new \MongoDB\BSON\UTCDateTime(0)], ], ], 'verbosity' => 'queryPlanner', ])->toArray()[0]; self::assertContains('rules_exact_lookup', self::indexNames($rulePlan)); self::assertContains('logs_auth_failures', self::indexNames($failurePlan)); } #[TestDox('Administrative rule queries filter, paginate, and preserve scope')] public function testAdministrativeRuleQuery(): void { $this->store->depositRule($this->rule('tenant-active', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')); $this->store->depositRule( $this->rule('tenant-disabled', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')->setEnabled(false) ); $this->store->depositRule($this->rule('other-tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-b')); $this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM)); $active = $this->store->queryRules( FirewallRuleObject::SCOPE_TENANT, 'tenant-a', 'active', FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK, 1, 0 ); $disabled = $this->store->queryRules( FirewallRuleObject::SCOPE_TENANT, 'tenant-a', 'disabled', null, null, 50, 0 ); self::assertSame(1, $active['total']); self::assertCount(1, $active['items']); self::assertSame('tenant-active', $active['items'][0]->getReason()); self::assertSame(1, $disabled['total']); self::assertSame('tenant-disabled', $disabled['items'][0]->getReason()); } #[TestDox('Administrative log queries enforce tenant scope and supported filters')] public function testAdministrativeLogQuery(): void { $matching = (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('-1 hour')); $otherTenant = (new FirewallLogObject()) ->setTenantId('tenant-b') ->setIpAddress('203.0.113.10') ->setEventType(FirewallLogObject::EVENT_RULE_MATCH) ->setResult(FirewallLogObject::RESULT_BLOCKED) ->setRuleId('rule-123') ->setRuleScope(FirewallRuleObject::SCOPE_TENANT) ->setTimestamp(new \DateTimeImmutable('-1 hour')); $tooOld = (new FirewallLogObject()) ->setTenantId('tenant-a') ->setEventType(FirewallLogObject::EVENT_RULE_MATCH) ->setResult(FirewallLogObject::RESULT_BLOCKED) ->setRuleId('rule-123') ->setTimestamp(new \DateTimeImmutable('-3 days')); foreach ([$matching, $otherTenant, $tooOld] as $log) { $this->store->createLog($log); } $tenant = $this->store->queryTenantLogs('tenant-a', [ 'ipAddress' => '203.0.113.10', 'eventType' => FirewallLogObject::EVENT_RULE_MATCH, 'result' => FirewallLogObject::RESULT_BLOCKED, 'ruleId' => 'rule-123', 'ruleScope' => FirewallRuleObject::SCOPE_TENANT, 'from' => new \DateTimeImmutable('-1 day'), 'to' => new \DateTimeImmutable(), ], 50, 0); $system = $this->store->querySystemLogs(null, [], 2, 0); self::assertSame(1, $tenant['total']); self::assertSame('tenant-a', $tenant['items'][0]->getTenantId()); self::assertSame(3, $system['total']); self::assertCount(2, $system['items']); } 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); } private static function indexNames(mixed $value): array { if (is_object($value)) { $value = (array)$value; } if (!is_array($value)) { return []; } $names = isset($value['indexName']) ? [(string)$value['indexName']] : []; foreach ($value as $child) { $names = [...$names, ...self::indexNames($child)]; } return array_values(array_unique($names)); } }