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']); } public function testIsBlockedFailsSecureOnExceptionForLoginAuthScope(): void { $this->repository->method('findByScopeAndHash') ->willThrowException(new \RuntimeException('DB down')); $result = $this->service->isBlocked('api.auth.login.ip', '127.0.0.1'); $this->assertFalse($result['allowed']); $this->assertSame(30, $result['retry_after']); } public function testRegisterFailureFailsSecureOnExceptionForLoginAuthScope(): void { $this->repository->method('findByScopeAndHash') ->willThrowException(new \RuntimeException('DB down')); $result = $this->service->registerFailure('login.password.email_ip', 'user@example.com|127.0.0.1', 5, 900, 900); $this->assertFalse($result['allowed']); $this->assertSame(30, $result['retry_after']); } public function testIsBlockedUsesFallbackGatewayWhenStorageFails(): void { $subject = '127.0.0.1'; $subjectHash = hash('sha256', $subject); $fallback = $this->createMock(RateLimiterFallbackGateway::class); $fallback->expects($this->once()) ->method('load') ->with('api.auth.login.ip', $subjectHash) ->willReturn([ 'hits' => 7, 'window_started_at' => time(), 'blocked_until' => time() + 120, ]); $this->repository->method('findByScopeAndHash') ->willThrowException(new \RuntimeException('DB down')); $service = new RateLimiterService($this->repository, $fallback); $result = $service->isBlocked('api.auth.login.ip', $subject); $this->assertFalse($result['allowed']); $this->assertGreaterThan(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']); } }