a8e29d0305
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
650 lines
25 KiB
PHP
650 lines
25 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXT\Unit\Service;
|
|
|
|
use KTXC\Context\TenantContextInterface;
|
|
use KTXC\Http\Request\Request;
|
|
use KTXC\Http\Request\RequestContext;
|
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
use KTXC\Models\Firewall\FirewallLogObject;
|
|
use KTXC\Models\Tenant\TenantConfiguration;
|
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
|
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 RequestContext $requestContext;
|
|
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->requestContext = new RequestContext();
|
|
$this->requestContext->initialize(Request::create(
|
|
'/login',
|
|
'POST',
|
|
server: [
|
|
'REMOTE_ADDR' => '203.0.113.10',
|
|
'HTTP_USER_AGENT' => 'Test Agent',
|
|
'HTTP_X_DEVICE_FINGERPRINT' => 'device-a',
|
|
],
|
|
));
|
|
$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,
|
|
$this->requestContext,
|
|
);
|
|
}
|
|
|
|
#[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('Tenant rule-match logs persist dedicated rule ID and scope fields')]
|
|
public function testTenantRuleAuditContext(): void
|
|
{
|
|
$this->store->expects($this->once())
|
|
->method('createLog')
|
|
->with(self::callback(static function (FirewallLogObject $log): bool {
|
|
return $log->getTenantId() === 'tenant-a'
|
|
&& $log->getRuleId() === 'tenant-rule'
|
|
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_TENANT
|
|
&& $log->getEventType() === FirewallLogObject::EVENT_RULE_MATCH;
|
|
}))
|
|
->willReturnArgument(0);
|
|
$event = \KTXC\Security\Event\SecurityEvent::accessDenied(
|
|
'203.0.113.10',
|
|
null,
|
|
'tenant-rule',
|
|
FirewallRuleObject::SCOPE_TENANT,
|
|
'Tenant block',
|
|
'tenant-a',
|
|
);
|
|
|
|
$this->service->logSecurityEvent($event);
|
|
}
|
|
|
|
#[TestDox('System rule matches are logged even when no tenant is resolved')]
|
|
public function testSystemRuleAuditContext(): void
|
|
{
|
|
$this->currentTenant = null;
|
|
$this->store->expects($this->once())
|
|
->method('createLog')
|
|
->with(self::callback(static function (FirewallLogObject $log): bool {
|
|
return $log->getTenantId() === null
|
|
&& $log->getRuleId() === 'system-rule'
|
|
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM;
|
|
}))
|
|
->willReturnArgument(0);
|
|
$event = \KTXC\Security\Event\SecurityEvent::accessDenied(
|
|
'203.0.113.10',
|
|
null,
|
|
'system-rule',
|
|
FirewallRuleObject::SCOPE_SYSTEM,
|
|
'System block'
|
|
);
|
|
|
|
$this->service->logSecurityEvent($event);
|
|
}
|
|
|
|
#[TestDox('Tenantless security events without system rule context are ignored')]
|
|
public function testTenantlessAuditBoundary(): void
|
|
{
|
|
$this->currentTenant = null;
|
|
$this->store->expects($this->never())->method('createLog');
|
|
|
|
$this->service->logSecurityEvent(
|
|
new AuthenticationFailedEvent()
|
|
);
|
|
}
|
|
|
|
#[TestDox('Rate-limit events retain their request and threshold audit data')]
|
|
public function testRateLimitAudit(): void
|
|
{
|
|
$this->store->expects($this->once())
|
|
->method('createLog')
|
|
->with(self::callback(static function (FirewallLogObject $log): bool {
|
|
$metadata = $log->getMetadata();
|
|
return $log->getEventType() === FirewallLogObject::EVENT_RATE_LIMIT
|
|
&& $log->getResult() === FirewallLogObject::RESULT_BLOCKED
|
|
&& $log->getIpAddress() === '203.0.113.10'
|
|
&& $log->getRequestPath() === '/login'
|
|
&& $metadata['requestCount'] === 101
|
|
&& $metadata['windowSeconds'] === 60;
|
|
}))
|
|
->willReturnArgument(0);
|
|
$event = \KTXC\Security\Event\SecurityEvent::rateLimitExceeded(
|
|
'203.0.113.10',
|
|
101,
|
|
60,
|
|
'/login',
|
|
'tenant-a',
|
|
);
|
|
|
|
$this->service->logSecurityEvent($event);
|
|
}
|
|
|
|
#[TestDox('Suspicious-activity events retain request and detection metadata')]
|
|
public function testSuspiciousActivityAudit(): void
|
|
{
|
|
$this->store->expects($this->once())
|
|
->method('createLog')
|
|
->with(self::callback(static function (FirewallLogObject $log): bool {
|
|
return $log->getEventType() === FirewallLogObject::EVENT_SUSPICIOUS
|
|
&& $log->getResult() === FirewallLogObject::RESULT_BLOCKED
|
|
&& $log->getIpAddress() === '203.0.113.20'
|
|
&& $log->getRequestPath() === '/admin'
|
|
&& $log->getRequestMethod() === 'POST'
|
|
&& $log->getMetadata()['detector'] === 'payload-signature';
|
|
}))
|
|
->willReturnArgument(0);
|
|
$event = \KTXC\Security\Event\SecurityEvent::create(
|
|
\KTXC\Security\Event\SecurityEvent::SUSPICIOUS_ACTIVITY,
|
|
'203.0.113.20',
|
|
null,
|
|
['detector' => 'payload-signature'],
|
|
tenantId: 'tenant-a',
|
|
requestPath: '/admin',
|
|
requestMethod: 'POST',
|
|
);
|
|
|
|
$this->service->logSecurityEvent($event);
|
|
}
|
|
|
|
#[TestDox('Rule lifecycle events map to recorded audit entries')]
|
|
public function testRuleLifecycleAudit(): void
|
|
{
|
|
$this->currentTenant = null;
|
|
$this->store->expects($this->once())
|
|
->method('createLog')
|
|
->with(self::callback(static function (FirewallLogObject $log): bool {
|
|
return $log->getEventType() === FirewallLogObject::EVENT_RULE_DISABLED
|
|
&& $log->getResult() === FirewallLogObject::RESULT_RECORDED
|
|
&& $log->getRuleId() === 'rule-123'
|
|
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM
|
|
&& $log->getIdentityId() === 'operator';
|
|
}))
|
|
->willReturnArgument(0);
|
|
$event = new \KTXC\Security\Event\SecurityEvent(
|
|
\KTXC\Security\Event\SecurityEvent::FIREWALL_RULE_DISABLED,
|
|
[
|
|
'ruleId' => 'rule-123',
|
|
'ruleScope' => FirewallRuleObject::SCOPE_SYSTEM,
|
|
'origin' => FirewallRuleManager::ORIGIN_MANUAL,
|
|
],
|
|
identityId: 'operator',
|
|
);
|
|
|
|
$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 \KTXC\Security\Event\SecurityEvent(
|
|
\KTXC\Security\Event\SecurityEvent::FIREWALL_SETTINGS_UPDATED,
|
|
['changeReason' => 'Tighten controls'],
|
|
tenantId: 'tenant-a',
|
|
identityId: 'operator',
|
|
);
|
|
|
|
$this->service->logSecurityEvent($event);
|
|
}
|
|
|
|
#[TestDox('Typed tenant firewall settings drive brute-force thresholds')]
|
|
public function testFirewallConfiguration(): void
|
|
{
|
|
$this->store->method('createLogOnce')->willReturn(true);
|
|
$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 = new AuthenticationFailedEvent(tenantId: 'tenant-a');
|
|
$this->service->handleAuthFailure($event);
|
|
|
|
self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures());
|
|
self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration());
|
|
}
|
|
|
|
#[TestDox('Authentication failures combine event facts with the current request')]
|
|
public function testAuthenticationFailureRequestContext(): void
|
|
{
|
|
$this->store->expects($this->once())
|
|
->method('createLogOnce')
|
|
->with(self::callback(static function (FirewallLogObject $log): bool {
|
|
return $log->getIpAddress() === '203.0.113.10'
|
|
&& $log->getDeviceFingerprint() === 'device-a'
|
|
&& $log->getUserAgent() === 'Test Agent'
|
|
&& $log->getRequestPath() === '/login'
|
|
&& $log->getRequestMethod() === 'POST'
|
|
&& $log->getIdentityId() === 'user-a'
|
|
&& $log->getMetadata()['reason'] === 'invalid_credentials';
|
|
}))
|
|
->willReturn(true);
|
|
$this->store->expects($this->once())
|
|
->method('countRecentFailures')
|
|
->with('tenant-a', '203.0.113.10', 300)
|
|
->willReturn(0);
|
|
|
|
$this->service->handleAuthFailure(new AuthenticationFailedEvent(
|
|
userId: 'user-a',
|
|
reason: 'invalid_credentials',
|
|
tenantId: 'tenant-a',
|
|
));
|
|
}
|
|
|
|
#[TestDox('Authentication failures outside HTTP request context are ignored')]
|
|
public function testAuthenticationFailureWithoutRequestContext(): void
|
|
{
|
|
$this->requestContext->clear();
|
|
$this->store->expects($this->never())->method('createLogOnce');
|
|
$this->store->expects($this->never())->method('countRecentFailures');
|
|
|
|
$this->service->handleAuthFailure(new AuthenticationFailedEvent(
|
|
reason: 'invalid_credentials',
|
|
tenantId: 'tenant-a',
|
|
));
|
|
}
|
|
|
|
#[TestDox('Unsafe numeric firewall settings fall back to safe defaults')]
|
|
public function testConfigurationBounds(): void
|
|
{
|
|
$this->store->method('createLogOnce')->willReturn(true);
|
|
$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 = new AuthenticationFailedEvent(tenantId: 'tenant-a');
|
|
$this->service->handleAuthFailure($event);
|
|
}
|
|
|
|
#[TestDox('Automatic blocks retain the tenant carried by the authentication event')]
|
|
public function testAutomaticBlockTenant(): void
|
|
{
|
|
$this->store->method('createLogOnce')->willReturn(true);
|
|
$this->currentTenant = 'tenant-context';
|
|
$this->store->expects($this->once())
|
|
->method('countRecentFailures')
|
|
->with('tenant-event', '203.0.113.10', 300)
|
|
->willReturn(5);
|
|
$this->store->expects($this->once())
|
|
->method('claimBruteForce')
|
|
->with('tenant-event', '203.0.113.10', 300)
|
|
->willReturn(true);
|
|
$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 {
|
|
$metadata = $rule->getMetadata();
|
|
return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT
|
|
&& $rule->getTenantId() === 'tenant-event'
|
|
&& $rule->getExpiresAt() !== null
|
|
&& $metadata['failureThreshold'] === 5
|
|
&& $metadata['failureWindowSeconds'] === 300
|
|
&& $metadata['lastFailureCount'] === 5
|
|
&& $metadata['blockDurationSeconds'] === 3600;
|
|
}))
|
|
->willReturnArgument(0);
|
|
|
|
$publishedTenants = [];
|
|
$lifecycleOrigin = null;
|
|
$this->events->expects($this->exactly(3))
|
|
->method('dispatch')
|
|
->willReturnCallback(static function (\KTXF\Event\Event $event) use (
|
|
&$publishedTenants,
|
|
&$lifecycleOrigin
|
|
): void {
|
|
$publishedTenants[] = $event->getTenantId();
|
|
if ($event->getName() === \KTXC\Security\Event\SecurityEvent::FIREWALL_RULE_CREATED) {
|
|
$lifecycleOrigin = $event->get('origin');
|
|
}
|
|
});
|
|
|
|
$event = new AuthenticationFailedEvent(tenantId: 'tenant-event');
|
|
$this->service->handleAuthFailure($event);
|
|
|
|
self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants);
|
|
self::assertSame(FirewallRuleManager::ORIGIN_AUTOMATIC, $lifecycleOrigin);
|
|
}
|
|
|
|
#[TestDox('Workers that lose the brute-force claim do not block or publish detection events')]
|
|
public function testAutomaticBlockClaimLoss(): void
|
|
{
|
|
$this->store->method('createLogOnce')->willReturn(true);
|
|
$this->store->expects($this->once())
|
|
->method('countRecentFailures')
|
|
->with('tenant-a', '203.0.113.10', 300)
|
|
->willReturn(5);
|
|
$this->store->expects($this->once())
|
|
->method('claimBruteForce')
|
|
->with('tenant-a', '203.0.113.10', 300)
|
|
->willReturn(false);
|
|
$this->store->expects($this->never())->method('depositRule');
|
|
$this->events->expects($this->never())->method('dispatch');
|
|
|
|
$this->service->handleAuthFailure(
|
|
new AuthenticationFailedEvent()
|
|
);
|
|
}
|
|
|
|
#[TestDox('Authentication events without a tenant use the current tenant')]
|
|
public function testAutomaticBlockTenantFallback(): void
|
|
{
|
|
$this->store->method('createLogOnce')->willReturn(true);
|
|
$this->store->expects($this->once())
|
|
->method('countRecentFailures')
|
|
->with('tenant-a', '203.0.113.10', 300)
|
|
->willReturn(0);
|
|
|
|
$this->service->handleAuthFailure(
|
|
new AuthenticationFailedEvent()
|
|
);
|
|
}
|
|
|
|
#[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(
|
|
new AuthenticationFailedEvent()
|
|
);
|
|
}
|
|
|
|
#[TestDox('Repeated delivery of one authentication event is counted once')]
|
|
public function testAuthenticationFailureIdempotency(): void
|
|
{
|
|
$this->store->expects($this->exactly(2))
|
|
->method('createLogOnce')
|
|
->willReturnOnConsecutiveCalls(true, false);
|
|
$this->store->expects($this->once())
|
|
->method('countRecentFailures')
|
|
->with('tenant-a', '203.0.113.10', 300)
|
|
->willReturn(1);
|
|
$event = new AuthenticationFailedEvent();
|
|
$eventId = $event->getEventId();
|
|
|
|
$this->service->handleAuthFailure($event);
|
|
$this->service->handleAuthFailure($event);
|
|
|
|
self::assertSame($eventId, $event->getEventId());
|
|
}
|
|
|
|
#[TestDox('Cleanup records successful maintenance counts')]
|
|
public function testCleanupStatus(): void
|
|
{
|
|
$this->store->method('cleanupExpiredRules')->willReturn(2);
|
|
$this->store->method('cleanupOldLogs')->with(30)->willReturn(3);
|
|
$this->store->method('cleanupExpiredBruteForceClaims')->willReturn(4);
|
|
$this->store->expects(self::once())
|
|
->method('recordMaintenanceStatus')
|
|
->with(
|
|
self::isInstanceOf(\DateTimeImmutable::class),
|
|
self::isInstanceOf(\DateTimeImmutable::class),
|
|
'success',
|
|
[
|
|
'expiredRules' => 2,
|
|
'oldLogs' => 3,
|
|
'expiredBruteForceClaims' => 4,
|
|
]
|
|
);
|
|
|
|
self::assertSame([
|
|
'expiredRules' => 2,
|
|
'oldLogs' => 3,
|
|
'expiredBruteForceClaims' => 4,
|
|
], $this->service->cleanup());
|
|
}
|
|
|
|
#[TestDox('Cleanup failures are recorded and rethrown')]
|
|
public function testCleanupFailureStatus(): void
|
|
{
|
|
$this->store->method('cleanupExpiredRules')->willThrowException(new \RuntimeException('cleanup failed'));
|
|
$this->store->expects(self::once())
|
|
->method('recordMaintenanceStatus')
|
|
->with(
|
|
self::isInstanceOf(\DateTimeImmutable::class),
|
|
self::isInstanceOf(\DateTimeImmutable::class),
|
|
'failed',
|
|
[],
|
|
'cleanup failed'
|
|
);
|
|
$this->expectExceptionMessage('cleanup failed');
|
|
|
|
$this->service->cleanup();
|
|
}
|
|
|
|
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],
|
|
]);
|
|
}
|
|
}
|