Files
server/tests/php/Unit/Service/FirewallServiceTest.php
T
2026-07-30 22:41:27 -04:00

332 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Context\TenantContextInterface;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Service\FirewallService;
use KTXC\Service\FirewallRuleCache;
use KTXC\Service\FirewallRuleManager;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
#[AllowMockObjectsWithoutExpectations]
class FirewallServiceTest extends TestCase
{
private FirewallStore&MockObject $store;
private TenantContextInterface&MockObject $tenantContext;
private EventDispatcherInterface&MockObject $events;
private FirewallService $service;
private ?string $currentTenant;
private ?TenantConfiguration $currentConfiguration;
protected function setUp(): void
{
$this->store = $this->createMock(FirewallStore::class);
$this->tenantContext = $this->createMock(TenantContextInterface::class);
$this->events = $this->createMock(EventDispatcherInterface::class);
$this->currentTenant = 'tenant-a';
$this->currentConfiguration = null;
$this->tenantContext->method('identifier')->willReturnCallback(
fn(): ?string => $this->currentTenant
);
$this->tenantContext->method('configuration')->willReturnCallback(
fn(): ?TenantConfiguration => $this->currentConfiguration
);
$cache = new FirewallRuleCache($this->store);
$manager = new FirewallRuleManager($this->store, $cache, $this->events);
$this->service = new FirewallService(
$this->store,
$this->tenantContext,
$this->events,
$manager,
$cache
);
}
#[TestDox('System blocks cannot be overridden by tenant allows')]
public function testSystemBlockPrecedence(): void
{
$systemBlock = $this->rule(
'system-block',
FirewallRuleObject::SCOPE_SYSTEM,
FirewallRuleObject::ACTION_BLOCK,
null
);
$tenantAllow = $this->rule(
'tenant-allow',
FirewallRuleObject::SCOPE_TENANT,
FirewallRuleObject::ACTION_ALLOW,
'tenant-a'
);
$this->store->expects($this->once())->method('listSystemRules')->willReturn([$systemBlock]);
$this->store->expects($this->once())->method('listRules')->with('tenant-a')->willReturn([$tenantAllow]);
$this->events->expects($this->once())->method('dispatch');
$result = $this->service->analyze('203.0.113.10');
self::assertTrue($result->isBlocked());
self::assertSame('system-block', $result->ruleId);
}
#[TestDox('Tenant blocks override system allows')]
public function testTenantBlockPrecedence(): void
{
$systemAllow = $this->rule(
'system-allow',
FirewallRuleObject::SCOPE_SYSTEM,
FirewallRuleObject::ACTION_ALLOW,
null
);
$tenantBlock = $this->rule(
'tenant-block',
FirewallRuleObject::SCOPE_TENANT,
FirewallRuleObject::ACTION_BLOCK,
'tenant-a'
);
$this->store->method('listSystemRules')->willReturn([$systemAllow]);
$this->store->method('listRules')->with('tenant-a')->willReturn([$tenantBlock]);
$this->events->expects($this->once())->method('dispatch');
$result = $this->service->analyze('203.0.113.10');
self::assertTrue($result->isBlocked());
self::assertSame('tenant-block', $result->ruleId);
}
#[TestDox('Rule caches are isolated by tenant')]
public function testTenantCacheIsolation(): void
{
$this->store->expects($this->once())->method('listSystemRules')->willReturn([]);
$this->store->expects($this->exactly(2))
->method('listRules')
->willReturnCallback(static fn(string $tenantId): array => [
(new FirewallRuleObject())
->setId($tenantId)
->setScope(FirewallRuleObject::SCOPE_TENANT)
->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10'),
]);
self::assertSame('tenant-a', $this->service->analyze('203.0.113.10')->ruleId);
$this->currentTenant = 'tenant-b';
self::assertSame('tenant-b', $this->service->analyze('203.0.113.10')->ruleId);
}
#[TestDox('System blocks apply when no tenant is resolved')]
public function testSystemBlockWithoutTenant(): void
{
$this->currentTenant = null;
$this->store->method('listSystemRules')->willReturn([
$this->rule(
'system-block',
FirewallRuleObject::SCOPE_SYSTEM,
FirewallRuleObject::ACTION_BLOCK,
null
),
]);
$this->store->expects($this->never())->method('listRules');
self::assertSame('system-block', $this->service->analyze('203.0.113.10')->ruleId);
}
#[TestDox('System allows can match when no tenant is resolved')]
public function testSystemAllowWithoutTenant(): void
{
$this->currentTenant = null;
$this->store->method('listSystemRules')->willReturn([
$this->rule(
'system-allow',
FirewallRuleObject::SCOPE_SYSTEM,
FirewallRuleObject::ACTION_ALLOW,
null
),
]);
$result = $this->service->analyze('203.0.113.10');
self::assertTrue($result->isAllowed());
self::assertSame('system-allow', $result->ruleId);
}
#[TestDox('System blocks remain active when the tenant firewall is disabled')]
public function testSystemBlockWithDisabledTenant(): void
{
$this->disableTenantFirewall();
$this->store->method('listSystemRules')->willReturn([
$this->rule(
'system-block',
FirewallRuleObject::SCOPE_SYSTEM,
FirewallRuleObject::ACTION_BLOCK,
null
),
]);
$this->store->expects($this->never())->method('listRules');
self::assertSame('system-block', $this->service->analyze('203.0.113.10')->ruleId);
}
#[TestDox('Tenant rules are ignored when the tenant firewall is disabled')]
public function testTenantRulesDisabled(): void
{
$this->disableTenantFirewall();
$this->store->method('listSystemRules')->willReturn([]);
$this->store->expects($this->never())->method('listRules');
self::assertTrue($this->service->analyze('203.0.113.10')->isAllowed());
}
#[TestDox('Requests without a tenant and without a system match are allowed')]
public function testNoTenantDefault(): void
{
$this->currentTenant = null;
$this->store->method('listSystemRules')->willReturn([]);
$this->store->expects($this->never())->method('listRules');
self::assertTrue($this->service->analyze('203.0.113.10')->isAllowed());
}
#[TestDox('Typed tenant firewall settings drive brute-force thresholds')]
public function testFirewallConfiguration(): void
{
$this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([
'firewall' => [
'enabled' => true,
'maxAuthFailures' => 8,
'authFailureWindow' => 600,
'autoBlockDuration' => 7200,
],
]);
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-a', '203.0.113.10', 600)
->willReturn(4);
$this->events->expects($this->never())->method('dispatch');
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
$event->setTenantId('tenant-a');
$this->service->handleAuthFailure($event);
self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures());
self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration());
}
#[TestDox('Unsafe numeric firewall settings fall back to safe defaults')]
public function testConfigurationBounds(): void
{
$this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([
'firewall' => [
'maxAuthFailures' => 0,
'authFailureWindow' => -1,
'autoBlockDuration' => 0,
],
]);
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-a', '203.0.113.10', 300)
->willReturn(0);
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
$event->setTenantId('tenant-a');
$this->service->handleAuthFailure($event);
}
#[TestDox('Automatic blocks retain the tenant carried by the authentication event')]
public function testAutomaticBlockTenant(): void
{
$this->currentTenant = 'tenant-context';
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-event', '203.0.113.10', 300)
->willReturn(4);
$this->store->expects($this->once())
->method('findExactIpRule')
->with(
'tenant-event',
'203.0.113.10',
FirewallRuleObject::ACTION_BLOCK
)
->willReturn(null);
$this->store->expects($this->once())
->method('depositRule')
->with(self::callback(static function (FirewallRuleObject $rule): bool {
return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT
&& $rule->getTenantId() === 'tenant-event'
&& $rule->getExpiresAt() !== null;
}))
->willReturnArgument(0);
$publishedTenants = [];
$this->events->expects($this->exactly(2))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$publishedTenants): void {
$publishedTenants[] = $event->getTenantId();
});
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
$event->setTenantId('tenant-event');
$this->service->handleAuthFailure($event);
self::assertSame(['tenant-event', 'tenant-event'], $publishedTenants);
}
#[TestDox('Authentication events without a tenant use the current tenant')]
public function testAutomaticBlockTenantFallback(): void
{
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-a', '203.0.113.10', 300)
->willReturn(0);
$this->service->handleAuthFailure(
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
);
}
#[TestDox('Authentication failures are ignored when no tenant can be resolved')]
public function testAutomaticBlockWithoutTenant(): void
{
$this->currentTenant = null;
$this->store->expects($this->never())->method('countRecentFailures');
$this->store->expects($this->never())->method('depositRule');
$this->service->handleAuthFailure(
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
);
}
private function rule(
string $id,
string $scope,
string $action,
?string $tenantId
): FirewallRuleObject {
return (new FirewallRuleObject())
->setId($id)
->setScope($scope)
->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction($action)
->setValue('203.0.113.10')
->setReason($id);
}
private function disableTenantFirewall(): void
{
$this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([
'firewall' => ['enabled' => false],
]);
}
}