Files
server/tests/php/Integration/Stores/FirewallStoreTest.php
T
2026-08-03 22:27:20 -04:00

608 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXT\Integration\Stores;
use KTXC\Db\DataStore;
use KTXC\Db\UTCDateTime;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Service\FirewallRuleCache;
use KTXC\Service\FirewallRuleManager;
use KTXC\Service\FirewallRuleScope;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
#[AllowMockObjectsWithoutExpectations]
class FirewallStoreTest extends TestCase
{
private DataStore $dataStore;
private FirewallStore $store;
private bool $databaseAvailable = false;
protected function setUp(): void
{
$system = require dirname(__DIR__, 4).'/config/system.php';
$database = $system['database'];
$database['database'] = sprintf('ktrix_firewall_test_%d', getmypid());
$this->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 creation persists tenant and system ownership')]
public function testAdministrativeRuleCreation(): void
{
$manager = new FirewallRuleManager(
$this->store,
new FirewallRuleCache($this->store),
$this->createStub(EventDispatcherInterface::class)
);
$tenantRule = $manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_DEVICE,
FirewallRuleObject::ACTION_BLOCK,
'device-123',
'Compromised device',
'admin-a',
3600
);
$systemRule = $manager->createManualRule(
FirewallRuleScope::system(),
FirewallRuleObject::TYPE_IP_RANGE,
FirewallRuleObject::ACTION_BLOCK,
'198.51.100.0/24',
'Malicious network',
'system-admin',
currentIp: '203.0.113.10'
);
$persistedTenant = $this->store->fetchRule($tenantRule->getId());
$persistedSystem = $this->store->fetchRule($systemRule->getId());
self::assertSame('tenant-a', $persistedTenant->getTenantId());
self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $persistedSystem->getScope());
self::assertNull($persistedSystem->getTenantId());
self::assertSame('system-admin', $persistedSystem->getCreatedBy());
}
#[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']);
self::assertSame(2, $this->store->countSystemBlockedRequests('tenant-a'));
self::assertSame(3, $this->store->countSystemBlockedRequests());
self::assertSame(2, $this->store->countSystemBlockedRequests(null, new \DateTimeImmutable('-1 day')));
}
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));
}
}