Files
breadcrumb-the-shire/tests/Service/Security/RateLimiterServiceTest.php

333 lines
11 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Tests\Service\Security;
use MintyPHP\Repository\Security\RateLimitRepositoryInterface;
use MintyPHP\Service\Security\RateLimiterService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class RateLimiterServiceTest extends TestCase
{
private RateLimitRepositoryInterface&MockObject $repository;
private RateLimiterService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(RateLimitRepositoryInterface::class);
$this->service = new RateLimiterService($this->repository);
}
// ---------------------------------------------------------------
// hit — fail-open on empty input
// ---------------------------------------------------------------
public function testHitWithEmptyScopeFailsOpen(): void
{
$this->repository->expects($this->never())->method('findByScopeAndHash');
$result = $this->service->hit('', 'user@example.com', 5, 60, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
public function testHitWithEmptySubjectFailsOpen(): void
{
$this->repository->expects($this->never())->method('findByScopeAndHash');
$result = $this->service->hit('login', '', 5, 60, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — new entry creation
// ---------------------------------------------------------------
public function testHitCreatesNewEntryOnFirstAttempt(): void
{
$this->repository->method('findByScopeAndHash')->willReturn(null);
$this->repository->expects($this->once())
->method('create')
->with(
'login',
hash('sha256', 'user@example.com'),
1,
$this->matchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'),
null
)
->willReturn(true);
$result = $this->service->hit('login', 'user@example.com', 5, 60, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — increment existing entry
// ---------------------------------------------------------------
public function testHitIncrementsExistingEntry(): void
{
$nowSql = gmdate('Y-m-d H:i:s');
$this->repository->method('findByScopeAndHash')->willReturn([
'id' => 42,
'hits' => 2,
'window_started_at' => $nowSql,
'blocked_until' => null,
]);
$this->repository->expects($this->once())
->method('updateStateById')
->with(
42,
3, // 2 + 1
$this->matchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'),
null
)
->willReturn(true);
$result = $this->service->hit('login', 'user@example.com', 5, 3600, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — blocks when over limit
// ---------------------------------------------------------------
public function testHitBlocksWhenOverLimit(): void
{
$nowSql = gmdate('Y-m-d H:i:s');
$this->repository->method('findByScopeAndHash')->willReturn([
'id' => 42,
'hits' => 5, // at maxHits; next increment pushes to 6 > 5
'window_started_at' => $nowSql,
'blocked_until' => null,
]);
$this->repository->expects($this->once())
->method('updateStateById')
->with(
42,
6,
$this->anything(),
$this->matchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/')
)
->willReturn(true);
$result = $this->service->hit('login', 'user@example.com', 5, 3600, 300);
$this->assertFalse($result['allowed']);
$this->assertGreaterThan(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — returns blocked when already blocked
// ---------------------------------------------------------------
public function testHitReturnsBlockedWhenAlreadyBlocked(): void
{
$blockedUntil = gmdate('Y-m-d H:i:s', time() + 600);
$this->repository->method('findByScopeAndHash')->willReturn([
'id' => 42,
'hits' => 10,
'window_started_at' => gmdate('Y-m-d H:i:s'),
'blocked_until' => $blockedUntil,
]);
// Should not update — early return when already blocked.
$this->repository->expects($this->never())->method('updateStateById');
$result = $this->service->hit('login', 'user@example.com', 5, 3600, 300);
$this->assertFalse($result['allowed']);
$this->assertGreaterThan(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — sliding window resets expired window
// ---------------------------------------------------------------
public function testHitResetsHitsWhenWindowExpired(): void
{
$expiredWindowStart = gmdate('Y-m-d H:i:s', time() - 7200);
$this->repository->method('findByScopeAndHash')->willReturn([
'id' => 42,
'hits' => 4,
'window_started_at' => $expiredWindowStart,
'blocked_until' => null,
]);
$this->repository->expects($this->once())
->method('updateStateById')
->with(
42,
1, // reset to 0 then incremented to 1
$this->anything(),
null
)
->willReturn(true);
$result = $this->service->hit('login', 'user@example.com', 5, 3600, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — fail-open on storage exception
// ---------------------------------------------------------------
public function testHitFailsOpenOnStorageException(): void
{
$this->repository->method('findByScopeAndHash')
->willThrowException(new \RuntimeException('DB down'));
$result = $this->service->hit('login', 'user@example.com', 5, 60, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — scope normalization
// ---------------------------------------------------------------
public function testHitNormalizesScopeToLowercaseTrimmed(): void
{
$this->repository->expects($this->once())
->method('findByScopeAndHash')
->with('login', $this->anything())
->willReturn(null);
$this->repository->method('create')->willReturn(true);
$this->service->hit(' LOGIN ', 'user@example.com', 5, 60, 300);
}
// ---------------------------------------------------------------
// isBlocked — not blocked
// ---------------------------------------------------------------
public function testIsBlockedReturnsTrueWhenBlocked(): void
{
$blockedUntil = gmdate('Y-m-d H:i:s', time() + 600);
$this->repository->method('findByScopeAndHash')->willReturn([
'id' => 42,
'hits' => 10,
'window_started_at' => gmdate('Y-m-d H:i:s'),
'blocked_until' => $blockedUntil,
]);
$result = $this->service->isBlocked('login', 'user@example.com');
$this->assertFalse($result['allowed']);
$this->assertGreaterThan(0, $result['retry_after']);
}
public function testIsBlockedClearsExpiredBlock(): void
{
$expiredBlock = gmdate('Y-m-d H:i:s', time() - 60);
$this->repository->method('findByScopeAndHash')->willReturn([
'id' => 42,
'hits' => 10,
'window_started_at' => gmdate('Y-m-d H:i:s'),
'blocked_until' => $expiredBlock,
]);
$this->repository->expects($this->once())
->method('updateStateById')
->with(
42,
0,
$this->matchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'),
null
)
->willReturn(true);
$result = $this->service->isBlocked('login', 'user@example.com');
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
public function testIsBlockedReturnsAllowedWhenNoRowExists(): void
{
$this->repository->method('findByScopeAndHash')->willReturn(null);
$result = $this->service->isBlocked('login', 'user@example.com');
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
public function testIsBlockedFailsOpenOnException(): void
{
$this->repository->method('findByScopeAndHash')
->willThrowException(new \RuntimeException('DB down'));
$result = $this->service->isBlocked('login', 'user@example.com');
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// reset
// ---------------------------------------------------------------
public function testResetDeletesEntry(): void
{
$this->repository->expects($this->once())
->method('deleteByScopeAndHash')
->with('login', hash('sha256', 'user@example.com'))
->willReturn(true);
$this->service->reset('login', 'user@example.com');
}
public function testResetIgnoresException(): void
{
$this->repository->method('deleteByScopeAndHash')
->willThrowException(new \RuntimeException('DB down'));
// Should not throw — exceptions are silently ignored.
$this->service->reset('login', 'user@example.com');
$this->assertTrue(true);
}
public function testResetWithEmptyScopeDoesNothing(): void
{
$this->repository->expects($this->never())->method('deleteByScopeAndHash');
$this->service->reset('', 'user@example.com');
}
// ---------------------------------------------------------------
// registerFailure — delegates to apply() same as hit()
// ---------------------------------------------------------------
public function testRegisterFailureBehavesLikeHit(): void
{
$this->repository->method('findByScopeAndHash')->willReturn(null);
$this->repository->method('create')->willReturn(true);
$result = $this->service->registerFailure('login', 'user@example.com', 5, 60, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
}