Files
server/tests/php/Unit/Service/FirewallRuleManagerTest.php
T
2026-08-03 22:27:20 -04:00

391 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Service\FirewallRuleCache;
use KTXC\Service\FirewallRuleConflictException;
use KTXC\Service\FirewallRuleManager;
use KTXC\Service\FirewallRuleScope;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
#[AllowMockObjectsWithoutExpectations]
class FirewallRuleManagerTest extends TestCase
{
private FirewallStore&MockObject $store;
private EventDispatcherInterface&MockObject $events;
private FirewallRuleManager $manager;
protected function setUp(): void
{
$this->store = $this->createMock(FirewallStore::class);
$this->events = $this->createMock(EventDispatcherInterface::class);
$this->manager = new FirewallRuleManager(
$this->store,
new FirewallRuleCache($this->store),
$this->events
);
}
#[TestDox('The shared manager creates rules with the supplied scope and owner')]
public function testScopeCreation(): void
{
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->expects($this->exactly(2))
->method('depositRule')
->willReturnArgument(0);
$tenant = $this->manager->blockIp(
FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin-a'
);
$system = $this->manager->blockIp(
FirewallRuleScope::system(), '203.0.113.11', null, 'system-admin'
);
self::assertSame('tenant-a', $tenant->getTenantId());
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $tenant->getScope());
self::assertNull($system->getTenantId());
self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $system->getScope());
}
#[TestDox('Malformed rule values are rejected before persistence')]
public function testValidation(): void
{
$this->store->expects($this->never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->blockIpRange(
FirewallRuleScope::tenant('tenant-a'), '2001:db8::/129', null, 'admin-a'
);
}
#[TestDox('Temporary rules require a positive duration')]
public function testDuration(): void
{
$this->store->expects($this->never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->blockIp(
FirewallRuleScope::system(), '203.0.113.10', null, 'admin', 0
);
}
#[TestDox('Manual rule creation requires a reason before persistence')]
public function testManualReason(): void
{
$this->store->expects(self::never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
'203.0.113.10',
' ',
'admin-a'
);
}
#[TestDox('Blocking the current exact IP requires explicit confirmation')]
public function testCurrentIpSafeguard(): void
{
$this->store->expects(self::never())->method('depositRule');
try {
$this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
'2001:db8::1',
'Confirmed abuse',
'admin-a',
currentIp: '2001:0db8:0:0:0:0:0:1'
);
self::fail('Current IP block should require confirmation.');
} catch (FirewallRuleConflictException $error) {
self::assertSame('current_ip_confirmation_required', $error->conflictCode);
}
}
#[TestDox('CIDR rules covering the current IP require explicit confirmation')]
public function testCurrentIpCidrSafeguard(): void
{
$this->store->expects(self::never())->method('depositRule');
$this->expectException(FirewallRuleConflictException::class);
$this->manager->createManualRule(
FirewallRuleScope::system(),
FirewallRuleObject::TYPE_IP_RANGE,
FirewallRuleObject::ACTION_BLOCK,
'203.0.113.0/24',
'Network abuse',
'admin-a',
currentIp: '203.0.113.10'
);
}
#[TestDox('Confirmed current-IP blocks retain manual audit context')]
public function testConfirmedCurrentIpBlock(): void
{
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->expects(self::once())->method('depositRule')->willReturnArgument(0);
$rule = $this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
'203.0.113.10',
'Emergency lockout',
'admin-a',
currentIp: '203.0.113.10',
confirmCurrentIp: true
);
self::assertSame('Emergency lockout', $rule->getReason());
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $rule->getMetadata()['origin']);
}
#[TestDox('Manual creation rejects unsupported type and action combinations')]
public function testUnsupportedManualRule(): void
{
$this->store->expects(self::never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_DEVICE,
FirewallRuleObject::ACTION_ALLOW,
'device-123',
'Trusted device',
'admin-a'
);
}
#[TestDox('Rule lifecycle operations cannot cross scope ownership')]
public function testOwnership(): void
{
$tenantRule = (new FirewallRuleObject())
->setId('tenant-rule')
->setScope(FirewallRuleObject::SCOPE_TENANT)
->setTenantId('tenant-a');
$this->store->method('fetchRule')->willReturn($tenantRule);
$this->store->expects($this->never())->method('destroyRule');
self::assertFalse($this->manager->remove(FirewallRuleScope::system(), 'tenant-rule'));
self::assertFalse($this->manager->remove(FirewallRuleScope::tenant('tenant-b'), 'tenant-rule'));
}
#[TestDox('Rule queries retain scope, filters, and bounded pagination')]
public function testRuleQuery(): void
{
$result = ['items' => [], 'total' => 0, 'limit' => 25, 'offset' => 50];
$this->store->expects(self::once())
->method('queryRules')
->with(
FirewallRuleObject::SCOPE_TENANT,
'tenant-a',
'disabled',
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
25,
50
)
->willReturn($result);
self::assertSame($result, $this->manager->query(
FirewallRuleScope::tenant('tenant-a'),
'disabled',
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
25,
50
));
}
#[TestDox('Rule queries reject invalid filters and excessive pages')]
public function testRuleQueryValidation(): void
{
$this->store->expects(self::never())->method('queryRules');
$this->expectException(\InvalidArgumentException::class);
$this->manager->query(FirewallRuleScope::system(), 'active', null, null, 101);
}
#[TestDox('Effective policy keeps system and tenant rule sets distinct')]
public function testEffectivePolicy(): void
{
$system = (new FirewallRuleObject())->setScope(FirewallRuleObject::SCOPE_SYSTEM);
$tenant = (new FirewallRuleObject())
->setScope(FirewallRuleObject::SCOPE_TENANT)
->setTenantId('tenant-a');
$this->store->method('listSystemRules')->willReturn([$system]);
$this->store->method('listRules')->with('tenant-a')->willReturn([$tenant]);
$policy = $this->manager->effectivePolicy('tenant-a');
self::assertSame([$system], $policy['system']);
self::assertSame([$tenant], $policy['tenant']);
self::assertSame('system_block', $policy['precedence'][0]);
}
#[TestDox('Rule mutations invalidate the shared enforcement cache')]
public function testCacheInvalidation(): void
{
$this->store->expects($this->exactly(2))
->method('listRules')
->with('tenant-a')
->willReturnOnConsecutiveCalls([], []);
$cache = new FirewallRuleCache($this->store);
$manager = new FirewallRuleManager($this->store, $cache, $this->events);
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->method('depositRule')->willReturnArgument(0);
self::assertSame([], $cache->tenant('tenant-a'));
$manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin');
self::assertSame([], $cache->tenant('tenant-a'));
}
#[TestDox('Rule creation emits complete lifecycle audit context')]
public function testCreationAudit(): void
{
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->method('depositRule')->willReturnCallback(
static function (FirewallRuleObject $rule): FirewallRuleObject {
return $rule->setId('rule-123');
}
);
$events = [];
$this->events->expects($this->exactly(2))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
$events[$event->getName()] = $event;
});
$this->manager->blockIp(
FirewallRuleScope::tenant('tenant-a'),
'203.0.113.10',
'Repeated abuse',
'admin-a',
300
);
$audit = $events[SecurityEvent::FIREWALL_RULE_CREATED];
self::assertSame('rule-123', $audit->get('ruleId'));
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $audit->get('ruleScope'));
self::assertSame(FirewallRuleObject::TYPE_IP, $audit->get('ruleType'));
self::assertSame(FirewallRuleObject::ACTION_BLOCK, $audit->get('ruleAction'));
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $audit->get('origin'));
self::assertSame('admin-a', $audit->getIdentityId());
self::assertNotNull($audit->get('expiresAt'));
}
#[TestDox('Disable and remove audits identify the acting administrator')]
public function testLifecycleActors(): void
{
$rule = (new FirewallRuleObject())
->setId('rule-123')
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
->setTenantId(null)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setCreatedBy('creator');
$this->store->method('fetchRule')->willReturn($rule);
$events = [];
$this->events->expects($this->exactly(2))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
$events[$event->getName()] = $event;
});
self::assertTrue($this->manager->disable(FirewallRuleScope::system(), 'rule-123', 'operator'));
self::assertTrue($this->manager->remove(FirewallRuleScope::system(), 'rule-123', 'operator'));
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_DISABLED]->getIdentityId());
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_REMOVED]->getIdentityId());
}
#[TestDox('Continued attacks extend automatic blocks and retain their audit history')]
public function testAutomaticBlockExtension(): void
{
$originalExpiry = new \DateTimeImmutable('+5 minutes');
$rule = (new FirewallRuleObject())
->setId('rule-123')
->setScope(FirewallRuleObject::SCOPE_TENANT)
->setTenantId('tenant-a')
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setExpiresAt($originalExpiry)
->setMetadata([
'origin' => FirewallRuleManager::ORIGIN_AUTOMATIC,
'originalExpiresAt' => $originalExpiry->format(\DateTimeInterface::ATOM),
'extensions' => [],
]);
$this->store->method('findExactIpRule')->willReturn($rule);
$this->store->expects(self::once())
->method('depositRule')
->with(self::callback(static function (FirewallRuleObject $extended) use ($originalExpiry): bool {
$metadata = $extended->getMetadata();
return $extended->getExpiresAt() > $originalExpiry
&& $metadata['failureThreshold'] === 5
&& $metadata['failureWindowSeconds'] === 300
&& $metadata['lastFailureCount'] === 8
&& $metadata['originalExpiresAt'] === $originalExpiry->format(\DateTimeInterface::ATOM)
&& count($metadata['extensions']) === 1;
}))
->willReturnArgument(0);
$this->events->expects(self::once())
->method('dispatch')
->with(self::callback(static fn(\KTXF\Event\Event $event): bool =>
$event->getName() === SecurityEvent::FIREWALL_RULE_EXTENDED
&& $event->get('lastFailureCount') === 8
));
$extended = $this->manager->blockIp(
FirewallRuleScope::tenant('tenant-a'),
'203.0.113.10',
'Continued attack',
null,
3600,
FirewallRuleManager::ORIGIN_AUTOMATIC,
[
'failureThreshold' => 5,
'failureWindowSeconds' => 300,
'lastFailureCount' => 8,
'blockDurationSeconds' => 3600,
]
);
self::assertSame('rule-123', $extended->getId());
}
#[TestDox('Automatic detection never extends a manual block')]
public function testManualBlockIsNotExtended(): void
{
$rule = (new FirewallRuleObject())
->setScope(FirewallRuleObject::SCOPE_TENANT)
->setTenantId('tenant-a')
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
$this->store->method('findExactIpRule')->willReturn($rule);
$this->store->expects(self::never())->method('depositRule');
$this->events->expects(self::never())->method('dispatch');
self::assertSame($rule, $this->manager->blockIp(
FirewallRuleScope::tenant('tenant-a'),
'203.0.113.10',
null,
null,
3600,
FirewallRuleManager::ORIGIN_AUTOMATIC
));
}
}