test(services): add 88 unit tests for 7 critical service classes
RateLimiterServiceTest (17): hit/isBlocked/reset/registerFailure, fail-open, sliding window, scope normalization. PermissionServiceTest (24): userHas, getUserPermissions, cache hit/miss/refresh, clearUserCache, CRUD, system permission protection. RoleServiceTest (9): createFromAdmin, updateFromAdmin, deleteByUuid, Admin role protection, duplicate code rejection. TenantServiceTest (8): CRUD, department-dependent deletion blocking. DepartmentServiceTest (14): listPaged scope filtering, groupActiveByTenantIds, createFromAdmin, deleteByUuid, syncTenants. MailServiceTest (8): send logging, MailerException handling, template metadata. UserLifecycleServiceTest (8): advisory locking, deactivation, deletion, snapshot failure skip, privileged user exclusion, manual trigger type. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
332
tests/Service/Security/RateLimiterServiceTest.php
Normal file
332
tests/Service/Security/RateLimiterServiceTest.php
Normal file
@@ -0,0 +1,332 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user