12037da367
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
215 lines
7.8 KiB
PHP
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);
|
|
}
|
|
}
|