feat(firewall): add scoped log administration reads

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-03 22:17:13 -04:00
parent 74696bbeb3
commit d919b70a2e
10 changed files with 468 additions and 1 deletions
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Service\FirewallLogService;
use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Stores\FirewallStore;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
#[AllowMockObjectsWithoutExpectations]
final class FirewallLogServicesTest extends TestCase
{
#[TestDox('Tenant log reads derive tenant ownership and validated filters')]
public function testTenantQuery(): void
{
$store = $this->createMock(FirewallStore::class);
$tenant = $this->createStub(TenantContextInterface::class);
$tenant->method('requireIdentifier')->willReturn('tenant-a');
$identity = $this->createStub(IdentityContextInterface::class);
$identity->method('hasPermission')->willReturn(true);
$store->expects(self::once())
->method('queryTenantLogs')
->with(
'tenant-a',
self::callback(static fn(array $filters): bool =>
$filters['eventType'] === FirewallLogObject::EVENT_AUTH_FAILURE
&& $filters['from'] instanceof \DateTimeImmutable
),
25,
10
)
->willReturn(['items' => [], 'total' => 0, 'limit' => 25, 'offset' => 10]);
$service = new TenantFirewallLogService(new FirewallLogService($store), $tenant, $identity);
self::assertSame(0, $service->query([
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
'from' => '2026-08-01T00:00:00+00:00',
], 25, 10)['total']);
}
#[TestDox('System log reads may filter one tenant without changing ownership')]
public function testSystemQuery(): void
{
$store = $this->createMock(FirewallStore::class);
$identity = $this->createStub(IdentityContextInterface::class);
$identity->method('hasPermission')->willReturn(true);
$store->expects(self::once())
->method('querySystemLogs')
->with('tenant-a', self::isArray(), 50, 0)
->willReturn(['items' => [], 'total' => 0, 'limit' => 50, 'offset' => 0]);
$service = new SystemFirewallLogService(new FirewallLogService($store), $identity);
self::assertSame(0, $service->query('tenant-a', [], 50, 0)['total']);
}
#[TestDox('Log queries reject invalid filters before database access')]
public function testValidation(): void
{
$store = $this->createMock(FirewallStore::class);
$store->expects(self::never())->method('queryTenantLogs');
$query = new FirewallLogService($store);
$this->expectException(\InvalidArgumentException::class);
$query->tenant('tenant-a', ['ipAddress' => 'not-an-ip'], 50, 0);
}
#[TestDox('Log boundaries enforce dedicated read permissions')]
public function testPermissions(): void
{
$store = $this->createMock(FirewallStore::class);
$identity = $this->createStub(IdentityContextInterface::class);
$identity->method('hasPermission')->willReturn(false);
$tenant = $this->createStub(TenantContextInterface::class);
$store->expects(self::never())->method('queryTenantLogs');
$store->expects(self::never())->method('querySystemLogs');
try {
(new TenantFirewallLogService(new FirewallLogService($store), $tenant, $identity))->query([]);
self::fail('Tenant log read should be rejected.');
} catch (\RuntimeException $error) {
self::assertStringContainsString(TenantFirewallLogService::PERMISSION_READ, $error->getMessage());
}
$this->expectExceptionMessage(SystemFirewallLogService::PERMISSION_READ);
(new SystemFirewallLogService(new FirewallLogService($store), $identity))->query(null, []);
}
}