Files
server/tests/php/Integration/Stores/FirewallStoreTest.php
T
2026-07-30 22:46:27 -04:00

215 lines
7.8 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXT\Integration\Stores;
use KTXC\Db\DataStore;
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());
}
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);
}
}