From fb39aa57fd927787241f0eb72811dfb9205124eb Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Mon, 3 Aug 2026 22:44:34 -0400 Subject: [PATCH] feat(firewall): add audited configuration management Signed-off-by: Sebastian Krupinski --- core/lib/Controllers/FirewallController.php | 57 ++++++++++++ .../lib/Models/Firewall/FirewallLogObject.php | 1 + core/lib/Module/Module.php | 11 +++ core/lib/Service/FirewallLogService.php | 1 + core/lib/Service/FirewallService.php | 4 +- core/lib/Service/FirewallSettingsService.php | 75 ++++++++++++++++ .../Service/SystemFirewallStatusService.php | 22 +++++ .../Service/TenantFirewallStatusService.php | 21 +++++ shared/lib/Event/SecurityEvent.php | 1 + .../Service/FirewallSettingsServiceTest.php | 73 ++++++++++++++++ .../Controllers/FirewallControllerTest.php | 12 ++- tests/php/Unit/Module/CoreModuleTest.php | 5 +- .../php/Unit/Service/FirewallServiceTest.php | 22 +++++ .../Service/FirewallSettingsServiceTest.php | 86 +++++++++++++++++++ .../Service/FirewallStatusServicesTest.php | 27 +++++- 15 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 core/lib/Service/FirewallSettingsService.php create mode 100644 tests/php/Integration/Service/FirewallSettingsServiceTest.php create mode 100644 tests/php/Unit/Service/FirewallSettingsServiceTest.php diff --git a/core/lib/Controllers/FirewallController.php b/core/lib/Controllers/FirewallController.php index 97eee27..738a886 100644 --- a/core/lib/Controllers/FirewallController.php +++ b/core/lib/Controllers/FirewallController.php @@ -314,6 +314,43 @@ final class FirewallController extends ControllerAbstract return new JsonResponse($this->tenantStatus->configuration()); } + #[AuthenticatedRoute( + '/firewall/configuration', + name: 'firewall.tenant.configuration.update', + methods: ['PUT'], + permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE], + )] + public function updateTenantConfiguration( + bool $enabled, + int $maxAuthFailures, + int $authFailureWindow, + int $autoBlockDuration, + string $reason + ): JsonResponse { + return $this->settingsResponse(fn() => $this->tenantStatus->updateConfiguration( + $enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason + )); + } + + #[AuthenticatedRoute( + '/firewall/system/tenants/{tenantId}/configuration', + name: 'firewall.system.tenant.configuration.update', + methods: ['PUT'], + permissions: [SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE], + )] + public function updateSystemTenantConfiguration( + string $tenantId, + bool $enabled, + int $maxAuthFailures, + int $authFailureWindow, + int $autoBlockDuration, + string $reason + ): JsonResponse { + return $this->settingsResponse(fn() => $this->systemStatus->updateTenantConfiguration( + $tenantId, $enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason + )); + } + #[AuthenticatedRoute( '/firewall/system/metrics', name: 'firewall.system.metrics.read', @@ -405,4 +442,24 @@ final class FirewallController extends ControllerAbstract ]], JsonResponse::HTTP_BAD_REQUEST); } } + + private function settingsResponse(callable $mutation): JsonResponse + { + try { + $configuration = $mutation(); + if ($configuration === null) { + return new JsonResponse(['error' => [ + 'code' => 'tenant_not_found', + 'message' => 'Tenant not found.', + ]], JsonResponse::HTTP_NOT_FOUND); + } + + return new JsonResponse(['configuration' => $configuration]); + } catch (\InvalidArgumentException $error) { + return new JsonResponse(['error' => [ + 'code' => 'invalid_firewall_configuration', + 'message' => $error->getMessage(), + ]], JsonResponse::HTTP_BAD_REQUEST); + } + } } diff --git a/core/lib/Models/Firewall/FirewallLogObject.php b/core/lib/Models/Firewall/FirewallLogObject.php index b2a3154..beb4c25 100644 --- a/core/lib/Models/Firewall/FirewallLogObject.php +++ b/core/lib/Models/Firewall/FirewallLogObject.php @@ -26,6 +26,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable public const EVENT_RULE_ENABLED = 'rule_enabled'; public const EVENT_RULE_DISABLED = 'rule_disabled'; public const EVENT_RULE_REMOVED = 'rule_removed'; + public const EVENT_SETTINGS_UPDATED = 'settings_updated'; private ?string $id = null; private ?string $eventId = null; diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index a9af8cc..b962036 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -51,6 +51,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M SecurityEvent::FIREWALL_RULE_ENABLED, SecurityEvent::FIREWALL_RULE_DISABLED, SecurityEvent::FIREWALL_RULE_REMOVED, + SecurityEvent::FIREWALL_SETTINGS_UPDATED, ] as $event) { $this->events->listen( 'core', @@ -150,6 +151,11 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M 'description' => 'View effective firewall settings for the current tenant', 'group' => 'Firewall Management' ], + TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE => [ + 'label' => 'Manage Tenant Firewall Settings', + 'description' => 'Update firewall settings for the current tenant', + 'group' => 'Firewall Management' + ], SystemFirewallRuleService::PERMISSION_READ => [ 'label' => 'View System Firewall Rules', 'description' => 'View firewall rules that apply to every tenant', @@ -170,6 +176,11 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M 'description' => 'View the last firewall cleanup result and operational status', 'group' => 'System Administration' ], + SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE => [ + 'label' => 'Manage Tenant Firewall Settings System-Wide', + 'description' => 'Update firewall settings for any tenant', + 'group' => 'System Administration' + ], 'system.admin' => [ 'label' => 'System Administrator', 'description' => 'Full system access (superuser)', diff --git a/core/lib/Service/FirewallLogService.php b/core/lib/Service/FirewallLogService.php index 19addc5..4076ff2 100644 --- a/core/lib/Service/FirewallLogService.php +++ b/core/lib/Service/FirewallLogService.php @@ -24,6 +24,7 @@ final class FirewallLogService FirewallLogObject::EVENT_RULE_ENABLED, FirewallLogObject::EVENT_RULE_DISABLED, FirewallLogObject::EVENT_RULE_REMOVED, + FirewallLogObject::EVENT_SETTINGS_UPDATED, ]; public function __construct(private readonly FirewallStore $store) diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index 1415054..cc68518 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -272,6 +272,7 @@ class FirewallService SecurityEvent::FIREWALL_RULE_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED, SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED, SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED, + SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::EVENT_SETTINGS_UPDATED, default => FirewallLogObject::EVENT_ACCESS_CHECK, }; } @@ -288,7 +289,8 @@ class FirewallService SecurityEvent::FIREWALL_RULE_EXTENDED, SecurityEvent::FIREWALL_RULE_ENABLED, SecurityEvent::FIREWALL_RULE_DISABLED, - SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED, + SecurityEvent::FIREWALL_RULE_REMOVED, + SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::RESULT_RECORDED, default => FirewallLogObject::RESULT_BLOCKED, }; } diff --git a/core/lib/Service/FirewallSettingsService.php b/core/lib/Service/FirewallSettingsService.php new file mode 100644 index 0000000..3c41d76 --- /dev/null +++ b/core/lib/Service/FirewallSettingsService.php @@ -0,0 +1,75 @@ + 1000) { + throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.'); + } + self::bounded($maxAuthFailures, 1, 1000, 'Maximum authentication failures'); + self::bounded($authFailureWindow, 1, 86400, 'Authentication failure window'); + self::bounded($autoBlockDuration, 1, 31536000, 'Automatic block duration'); + + $tenant = $this->tenants->fetchById($tenantId); + if ($tenant === null) { + return null; + } + + $previous = $tenant->getConfiguration()->firewall()->jsonSerialize(); + $current = [ + 'enabled' => $enabled, + 'maxAuthFailures' => $maxAuthFailures, + 'authFailureWindow' => $authFailureWindow, + 'autoBlockDuration' => $autoBlockDuration, + ]; + $configuration = (new TenantConfiguration())->jsonDeserialize([ + ...$tenant->getConfiguration()->jsonSerialize(), + 'firewall' => $current, + ]); + $tenant->setConfiguration($configuration); + $this->tenants->deposit($tenant); + + $event = new SecurityEvent(SecurityEvent::FIREWALL_SETTINGS_UPDATED, [ + 'changeReason' => $reason, + 'changeOrigin' => FirewallRuleManager::ORIGIN_MANUAL, + 'previous' => $previous, + 'current' => $current, + ]); + $event->setTenantId($tenantId)->setIdentityId($actorId); + $this->events->dispatch($event); + + return $current; + } + + private static function bounded(int $value, int $minimum, int $maximum, string $label): void + { + if ($value < $minimum || $value > $maximum) { + throw new \InvalidArgumentException( + "{$label} must be between {$minimum} and {$maximum}." + ); + } + } +} diff --git a/core/lib/Service/SystemFirewallStatusService.php b/core/lib/Service/SystemFirewallStatusService.php index 0eb101d..d8136a4 100644 --- a/core/lib/Service/SystemFirewallStatusService.php +++ b/core/lib/Service/SystemFirewallStatusService.php @@ -9,10 +9,12 @@ use KTXC\Context\IdentityContextInterface; final class SystemFirewallStatusService { public const PERMISSION_MAINTENANCE_READ = 'firewall.system.maintenance.read'; + public const PERMISSION_SETTINGS_MANAGE = 'firewall.system.settings.manage'; public function __construct( private readonly FirewallStatusService $status, private readonly IdentityContextInterface $identity, + private readonly FirewallSettingsService $settings, ) { } @@ -28,6 +30,26 @@ final class SystemFirewallStatusService return $this->status->maintenanceStatus(); } + public function updateTenantConfiguration( + string $tenantId, + bool $enabled, + int $maxAuthFailures, + int $authFailureWindow, + int $autoBlockDuration, + string $reason + ): ?array { + $this->requirePermission(self::PERMISSION_SETTINGS_MANAGE); + return $this->settings->update( + $tenantId, + $enabled, + $maxAuthFailures, + $authFailureWindow, + $autoBlockDuration, + $reason, + $this->identity->identifier() + ); + } + private function requirePermission(string $permission): void { if (!$this->identity->hasPermission($permission)) { diff --git a/core/lib/Service/TenantFirewallStatusService.php b/core/lib/Service/TenantFirewallStatusService.php index bb80993..6b96c4b 100644 --- a/core/lib/Service/TenantFirewallStatusService.php +++ b/core/lib/Service/TenantFirewallStatusService.php @@ -10,11 +10,13 @@ use KTXC\Context\TenantContextInterface; final class TenantFirewallStatusService { public const PERMISSION_SETTINGS_READ = 'firewall.tenant.settings.read'; + public const PERMISSION_SETTINGS_MANAGE = 'firewall.tenant.settings.manage'; public function __construct( private readonly FirewallStatusService $status, private readonly TenantContextInterface $tenant, private readonly IdentityContextInterface $identity, + private readonly FirewallSettingsService $settings, ) { } @@ -36,6 +38,25 @@ final class TenantFirewallStatusService ]; } + public function updateConfiguration( + bool $enabled, + int $maxAuthFailures, + int $authFailureWindow, + int $autoBlockDuration, + string $reason + ): ?array { + $this->requirePermission(self::PERMISSION_SETTINGS_MANAGE); + return $this->settings->update( + $this->tenant->requireIdentifier(), + $enabled, + $maxAuthFailures, + $authFailureWindow, + $autoBlockDuration, + $reason, + $this->identity->identifier() + ); + } + private function requirePermission(string $permission): void { if (!$this->identity->hasPermission($permission)) { diff --git a/shared/lib/Event/SecurityEvent.php b/shared/lib/Event/SecurityEvent.php index 0215ce0..147eb0c 100644 --- a/shared/lib/Event/SecurityEvent.php +++ b/shared/lib/Event/SecurityEvent.php @@ -31,6 +31,7 @@ class SecurityEvent extends Event public const FIREWALL_RULE_ENABLED = 'security.firewall.rule.enabled'; public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled'; public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed'; + public const FIREWALL_SETTINGS_UPDATED = 'security.firewall.settings.updated'; private ?string $ipAddress = null; private ?string $deviceFingerprint = null; diff --git a/tests/php/Integration/Service/FirewallSettingsServiceTest.php b/tests/php/Integration/Service/FirewallSettingsServiceTest.php new file mode 100644 index 0000000..b519a09 --- /dev/null +++ b/tests/php/Integration/Service/FirewallSettingsServiceTest.php @@ -0,0 +1,73 @@ +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()); + } + } + + protected function tearDown(): void + { + if ($this->databaseAvailable) { + $this->dataStore->getDatabase()->drop(); + } + } + + #[TestDox('Firewall settings updates persist without replacing unrelated tenant configuration')] + public function testPersistence(): void + { + $tenants = new TenantService(new TenantStore($this->dataStore)); + $tenants->deposit((new TenantObject()) + ->setIdentifier('tenant-a') + ->setEnabled(true) + ->setLabel('Tenant A') + ->setDomains(new DomainCollection(['tenant-a.example.test'])) + ->setConfiguration((new TenantConfiguration())->jsonDeserialize([ + 'security' => ['code' => 'keep-me'], + ]))); + $settings = new FirewallSettingsService( + $tenants, + $this->createStub(EventDispatcherInterface::class) + ); + + $settings->update( + 'tenant-a', false, 8, 600, 7200, 'Tighten authentication controls', 'admin-a' + ); + $stored = $tenants->fetchById('tenant-a')->getConfiguration(); + + self::assertFalse($stored->firewall()->enabled()); + self::assertSame(8, $stored->firewall()->maxAuthFailures()); + self::assertSame(600, $stored->firewall()->authFailureWindow()); + self::assertSame(7200, $stored->firewall()->autoBlockDuration()); + self::assertSame('keep-me', $stored->security()->code()); + } +} diff --git a/tests/php/Unit/Controllers/FirewallControllerTest.php b/tests/php/Unit/Controllers/FirewallControllerTest.php index 2429db3..71c0e7a 100644 --- a/tests/php/Unit/Controllers/FirewallControllerTest.php +++ b/tests/php/Unit/Controllers/FirewallControllerTest.php @@ -10,6 +10,7 @@ use KTXC\Controllers\FirewallController; use KTXC\Http\Request\Request; use KTXC\Service\FirewallRuleCache; use KTXC\Service\FirewallRuleManager; +use KTXC\Service\FirewallSettingsService; use KTXC\Service\FirewallStatusService; use KTXC\Service\FirewallLogService; use KTXC\Service\SystemFirewallLogService; @@ -18,6 +19,7 @@ use KTXC\Service\SystemFirewallStatusService; use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallRuleService; use KTXC\Service\TenantFirewallStatusService; +use KTXC\Service\TenantService; use KTXC\Stores\FirewallStore; use KTXF\Event\EventDispatcherInterface; use KTXF\Routing\Attributes\AuthenticatedRoute; @@ -43,13 +45,17 @@ final class FirewallControllerTest extends TestCase new FirewallRuleCache($this->store), $this->createStub(EventDispatcherInterface::class) ); + $settings = new FirewallSettingsService( + $this->createStub(TenantService::class), + $this->createStub(EventDispatcherInterface::class) + ); $this->controller = new FirewallController( new TenantFirewallRuleService($manager, $tenant, $identity), new SystemFirewallRuleService($manager, $identity), new TenantFirewallLogService(new FirewallLogService($this->store), $tenant, $identity), new SystemFirewallLogService(new FirewallLogService($this->store), $identity), - new TenantFirewallStatusService(new FirewallStatusService($this->store), $tenant, $identity), - new SystemFirewallStatusService(new FirewallStatusService($this->store), $identity) + new TenantFirewallStatusService(new FirewallStatusService($this->store), $tenant, $identity, $settings), + new SystemFirewallStatusService(new FirewallStatusService($this->store), $identity, $settings) ); } @@ -176,6 +182,8 @@ final class FirewallControllerTest extends TestCase 'tenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_READ, 'systemMetrics' => SystemFirewallLogService::PERMISSION_READ, 'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, + 'updateTenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE, + 'updateSystemTenantConfiguration' => SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE, 'createTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE, 'createSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE, 'updateTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE, diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index 6807e26..6fdb502 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -34,7 +34,7 @@ final class CoreModuleTest extends TestCase $module->boot(); $definitions = $registry->definitions(); - self::assertCount(11, $definitions); + self::assertCount(12, $definitions); self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module')))); self::assertSame( FirewallService::class, @@ -49,6 +49,7 @@ final class CoreModuleTest extends TestCase SecurityEvent::FIREWALL_RULE_DISABLED, SecurityEvent::FIREWALL_RULE_ENABLED, SecurityEvent::FIREWALL_RULE_REMOVED, + SecurityEvent::FIREWALL_SETTINGS_UPDATED, ] as $event) { $listeners = $registry->listeners($event, DeliveryMode::Deferred); self::assertCount(1, $listeners); @@ -82,6 +83,8 @@ final class CoreModuleTest extends TestCase self::assertArrayHasKey(TenantFirewallLogService::PERMISSION_READ, $permissions); self::assertArrayHasKey(SystemFirewallLogService::PERMISSION_READ, $permissions); self::assertArrayHasKey(TenantFirewallStatusService::PERMISSION_SETTINGS_READ, $permissions); + self::assertArrayHasKey(TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE, $permissions); self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, $permissions); + self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE, $permissions); } } diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index 5716bfe..bd233d8 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -336,6 +336,28 @@ class FirewallServiceTest extends TestCase $this->service->logSecurityEvent($event); } + #[TestDox('Settings changes map to recorded tenant audit entries')] + public function testSettingsAudit(): void + { + $this->store->expects(self::once()) + ->method('createLog') + ->with(self::callback(static fn(FirewallLogObject $log): bool => + $log->getEventType() === FirewallLogObject::EVENT_SETTINGS_UPDATED + && $log->getResult() === FirewallLogObject::RESULT_RECORDED + && $log->getTenantId() === 'tenant-a' + && $log->getIdentityId() === 'operator' + && $log->getMetadata()['changeReason'] === 'Tighten controls' + )) + ->willReturnArgument(0); + $event = new \KTXF\Event\SecurityEvent( + \KTXF\Event\SecurityEvent::FIREWALL_SETTINGS_UPDATED, + ['changeReason' => 'Tighten controls'] + ); + $event->setTenantId('tenant-a')->setIdentityId('operator'); + + $this->service->logSecurityEvent($event); + } + #[TestDox('Typed tenant firewall settings drive brute-force thresholds')] public function testFirewallConfiguration(): void { diff --git a/tests/php/Unit/Service/FirewallSettingsServiceTest.php b/tests/php/Unit/Service/FirewallSettingsServiceTest.php new file mode 100644 index 0000000..3b6b972 --- /dev/null +++ b/tests/php/Unit/Service/FirewallSettingsServiceTest.php @@ -0,0 +1,86 @@ +setId('507f1f77bcf86cd799439011') + ->setIdentifier('tenant-a') + ->setConfiguration((new TenantConfiguration())->jsonDeserialize([ + 'firewall' => ['enabled' => true, 'maxAuthFailures' => 5], + 'security' => ['code' => 'preserved'], + ])); + $tenants = $this->createMock(TenantService::class); + $tenants->method('fetchById')->with('tenant-a')->willReturn($tenant); + $tenants->expects(self::once()) + ->method('deposit') + ->with(self::callback(static fn(TenantObject $updated): bool => + $updated->getConfiguration()->firewall()->maxAuthFailures() === 8 + && $updated->getConfiguration()->security()->jsonSerialize()['code'] === 'preserved' + )) + ->willReturnArgument(0); + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::once()) + ->method('dispatch') + ->with(self::callback(static fn(SecurityEvent $event): bool => + $event->getName() === SecurityEvent::FIREWALL_SETTINGS_UPDATED + && $event->getTenantId() === 'tenant-a' + && $event->getIdentityId() === 'admin-a' + && $event->get('changeReason') === 'Tighten authentication controls' + && $event->get('previous')['maxAuthFailures'] === 5 + && $event->get('current')['maxAuthFailures'] === 8 + )); + + $result = (new FirewallSettingsService($tenants, $events))->update( + 'tenant-a', false, 8, 600, 7200, 'Tighten authentication controls', 'admin-a' + ); + + self::assertSame([ + 'enabled' => false, + 'maxAuthFailures' => 8, + 'authFailureWindow' => 600, + 'autoBlockDuration' => 7200, + ], $result); + } + + #[TestDox('Invalid settings are rejected before tenant reads or persistence')] + public function testValidation(): void + { + $tenants = $this->createMock(TenantService::class); + $tenants->expects(self::never())->method('fetchById'); + $tenants->expects(self::never())->method('deposit'); + $this->expectException(\InvalidArgumentException::class); + + (new FirewallSettingsService( + $tenants, + $this->createStub(EventDispatcherInterface::class) + ))->update('tenant-a', true, 0, 300, 3600, 'Invalid threshold', 'admin-a'); + } + + #[TestDox('Unknown tenants return no configuration without emitting audit events')] + public function testMissingTenant(): void + { + $tenants = $this->createStub(TenantService::class); + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::never())->method('dispatch'); + + self::assertNull((new FirewallSettingsService($tenants, $events))->update( + 'missing', true, 5, 300, 3600, 'Apply defaults', 'admin-a' + )); + } +} diff --git a/tests/php/Unit/Service/FirewallStatusServicesTest.php b/tests/php/Unit/Service/FirewallStatusServicesTest.php index 62b6832..5dfe086 100644 --- a/tests/php/Unit/Service/FirewallStatusServicesTest.php +++ b/tests/php/Unit/Service/FirewallStatusServicesTest.php @@ -8,11 +8,14 @@ use KTXC\Context\IdentityContextInterface; use KTXC\Context\TenantContextInterface; use KTXC\Models\Tenant\TenantConfiguration; use KTXC\Service\FirewallStatusService; +use KTXC\Service\FirewallSettingsService; use KTXC\Service\SystemFirewallLogService; use KTXC\Service\SystemFirewallStatusService; use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallStatusService; +use KTXC\Service\TenantService; use KTXC\Stores\FirewallStore; +use KTXF\Event\EventDispatcherInterface; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -37,7 +40,9 @@ final class FirewallStatusServicesTest extends TestCase ->method('countBlockedRequests') ->with('tenant-a', self::isInstanceOf(\DateTimeImmutable::class)) ->willReturn(7); - $service = new TenantFirewallStatusService(new FirewallStatusService($store), $tenant, $identity); + $service = new TenantFirewallStatusService( + new FirewallStatusService($store), $tenant, $identity, $this->settings() + ); self::assertSame(7, $service->metrics('2026-08-01T00:00:00+00:00')['blockedRequests']); self::assertFalse($service->configuration()['enabled']); @@ -55,7 +60,9 @@ final class FirewallStatusServicesTest extends TestCase ->with('tenant-a', null) ->willReturn(12); $store->method('maintenanceStatus')->willReturn(['status' => 'success']); - $service = new SystemFirewallStatusService(new FirewallStatusService($store), $identity); + $service = new SystemFirewallStatusService( + new FirewallStatusService($store), $identity, $this->settings() + ); self::assertSame(12, $service->metrics('tenant-a')['blockedRequests']); self::assertSame('success', $service->maintenanceStatus()['status']); @@ -80,14 +87,18 @@ final class FirewallStatusServicesTest extends TestCase $store->expects(self::never())->method('maintenanceStatus'); try { - (new TenantFirewallStatusService(new FirewallStatusService($store), $tenant, $identity))->metrics(); + (new TenantFirewallStatusService( + new FirewallStatusService($store), $tenant, $identity, $this->settings() + ))->metrics(); self::fail('Tenant metrics should be rejected.'); } catch (\RuntimeException $error) { self::assertStringContainsString(TenantFirewallLogService::PERMISSION_READ, $error->getMessage()); } $this->expectExceptionMessage(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ); - (new SystemFirewallStatusService(new FirewallStatusService($store), $identity))->maintenanceStatus(); + (new SystemFirewallStatusService( + new FirewallStatusService($store), $identity, $this->settings() + ))->maintenanceStatus(); } #[TestDox('Metrics reject invalid dates before querying storage')] @@ -99,4 +110,12 @@ final class FirewallStatusServicesTest extends TestCase (new FirewallStatusService($store))->systemMetrics(null, 'not-a-date'); } + + private function settings(): FirewallSettingsService + { + return new FirewallSettingsService( + $this->createStub(TenantService::class), + $this->createStub(EventDispatcherInterface::class) + ); + } }