feat(firewall): validate rules and add typed configuration

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-30 21:54:23 -04:00
parent 63d91ca7fa
commit 3b9a5cd5ab
4 changed files with 231 additions and 7 deletions
+112 -1
View File
@@ -6,6 +6,7 @@ namespace KTXT\Unit\Service;
use KTXC\Context\TenantContextInterface;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Service\FirewallService;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
@@ -22,6 +23,7 @@ class FirewallServiceTest extends TestCase
private EventDispatcherInterface&MockObject $events;
private FirewallService $service;
private string $currentTenant;
private ?TenantConfiguration $currentConfiguration;
protected function setUp(): void
{
@@ -29,10 +31,13 @@ class FirewallServiceTest extends TestCase
$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')->willReturn(null);
$this->tenantContext->method('configuration')->willReturnCallback(
fn(): ?TenantConfiguration => $this->currentConfiguration
);
$this->service = new FirewallService($this->store, $this->tenantContext, $this->events);
}
@@ -127,6 +132,112 @@ class FirewallServiceTest extends TestCase
self::assertSame('tenant-a', $rule->getTenantId());
}
#[TestDox('Malformed IP addresses are rejected before persistence')]
public function testIpValidation(): void
{
$this->store->expects($this->never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid IP address');
$this->service->blockIp('999.2.3.4');
}
#[TestDox('Valid IPv6 addresses can be blocked')]
public function testIpv6Validation(): void
{
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->expects($this->once())
->method('depositRule')
->with(self::callback(static fn(FirewallRuleObject $rule): bool => $rule->getValue() === '2001:db8::1'))
->willReturnArgument(0);
self::assertSame('2001:db8::1', $this->service->blockIp(' 2001:db8::1 ')->getValue());
}
#[TestDox('Malformed CIDR ranges are rejected before persistence')]
public function testCidrValidation(): void
{
$this->store->expects($this->never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid CIDR range');
$this->service->blockIpRange('2001:db8::/129');
}
#[TestDox('Valid IPv4 and IPv6 CIDR ranges are accepted')]
public function testValidCidrs(): void
{
$this->store->expects($this->exactly(2))->method('depositRule')->willReturnArgument(0);
self::assertSame('192.0.2.0/24', $this->service->blockIpRange('192.0.2.0/24')->getValue());
self::assertSame('2001:db8::/32', $this->service->blockIpRange('2001:db8::/32')->getValue());
}
#[TestDox('Temporary rules require a positive duration')]
public function testDurationValidation(): void
{
$this->store->expects($this->never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('greater than zero');
$this->service->blockIp('203.0.113.10', durationSeconds: 0);
}
#[TestDox('Device fingerprints must be non-empty and bounded')]
public function testFingerprintValidation(): void
{
$this->store->expects($this->never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Device fingerprint');
$this->service->blockDevice(' ');
}
#[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);
}
private function rule(
string $id,
string $scope,