forked from fa/breadcrumb-the-shire
Remove subset/duplicate architecture tests already covered by broader checks, and replace assertTrue(true) with self::addToAssertionCount(1) for explicit no-exception-thrown intent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
333 lines
11 KiB
PHP
333 lines
11 KiB
PHP
<?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');
|
|
self::addToAssertionCount(1);
|
|
}
|
|
|
|
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']);
|
|
}
|
|
}
|