diff --git a/packages/cache/src/RateLimiting/CacheRateLimiter.php b/packages/cache/src/RateLimiting/CacheRateLimiter.php new file mode 100644 index 0000000000..116b8b5787 --- /dev/null +++ b/packages/cache/src/RateLimiting/CacheRateLimiter.php @@ -0,0 +1,98 @@ +getCacheKey($key); + $timerKey = $this->getTimerKey($key); + + // Get or set the timer (when the window ends) + $resetAt = (int) $this->cache->get($timerKey); + + if ($resetAt === 0) { + $resetAt = time() + $decaySeconds; + $this->cache->put($timerKey, $resetAt, Duration::seconds($decaySeconds)); + } + + // Increment the counter + $attempts = $this->cache->increment($cacheKey); + + // Set expiration on first hit + if ($attempts === 1) { + $this->cache->put($cacheKey, 1, Duration::seconds($decaySeconds)); + } + + $remaining = max(0, $maxAttempts - $attempts); + + if ($attempts > $maxAttempts) { + return RateLimitResult::deny($maxAttempts, (int) $resetAt); + } + + return RateLimitResult::allow($maxAttempts, $remaining, $resetAt); + } + + public function attempts(string $key): int + { + return (int) ($this->cache->get($this->getCacheKey($key)) ?? 0); + } + + public function remaining(string $key, int $maxAttempts): int + { + return max(0, $maxAttempts - $this->attempts($key)); + } + + public function tooManyAttempts(string $key, int $maxAttempts): bool + { + return $this->attempts($key) >= $maxAttempts; + } + + public function clear(string $key): void + { + $this->cache->remove($this->getCacheKey($key)); + $this->cache->remove($this->getTimerKey($key)); + } + + public function availableAt(string $key): DateTime + { + $timestamp = (int) ($this->cache->get($this->getTimerKey($key)) ?? time()); + + return DateTime::fromTimestamp($timestamp); + } + + private function getCacheKey(string $key): string + { + return self::PREFIX . $this->sanitizeKey($key); + } + + private function getTimerKey(string $key): string + { + return self::PREFIX . $this->sanitizeKey($key) . '_timer'; + } + + /** + * Sanitize a cache key to be compatible with all cache adapters. + * Replaces reserved characters with underscores. + */ + private function sanitizeKey(string $key): string + { + return preg_replace('/[{}()\/@:\\\\]/', '_', $key); + } +} diff --git a/packages/cache/src/RateLimiting/RateLimitResult.php b/packages/cache/src/RateLimiting/RateLimitResult.php new file mode 100644 index 0000000000..42398f3683 --- /dev/null +++ b/packages/cache/src/RateLimiting/RateLimitResult.php @@ -0,0 +1,52 @@ +cache = new TestingCache(tag: 'rate-limit-test', clock: $clock->toPsrClock()); + $this->rateLimiter = new CacheRateLimiter($this->cache); + } + + public function test_attempt_allows_within_limit(): void + { + $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->assertTrue($result->allowed); + $this->assertSame(5, $result->limit); + $this->assertSame(4, $result->remaining); + } + + public function test_attempt_denies_after_exceeding_limit(): void + { + // Make 5 attempts (the limit) + for ($i = 0; $i < 5; $i++) { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + } + + // 6th attempt should be denied + $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->assertFalse($result->allowed); + $this->assertSame(0, $result->remaining); + } + + public function test_attempts_returns_count(): void + { + $this->assertSame(0, $this->rateLimiter->attempts('test-key')); + + $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); + $this->assertSame(1, $this->rateLimiter->attempts('test-key')); + + $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); + $this->assertSame(2, $this->rateLimiter->attempts('test-key')); + } + + public function test_remaining_returns_available_attempts(): void + { + $this->assertSame(10, $this->rateLimiter->remaining('test-key', maxAttempts: 10)); + + $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); + $this->assertSame(9, $this->rateLimiter->remaining('test-key', maxAttempts: 10)); + } + + public function test_too_many_attempts_returns_true_when_exceeded(): void + { + $this->assertFalse($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); + + $this->rateLimiter->attempt('test-key', maxAttempts: 2, decaySeconds: 60); + $this->assertFalse($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); + + $this->rateLimiter->attempt('test-key', maxAttempts: 2, decaySeconds: 60); + $this->assertTrue($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); + } + + public function test_clear_resets_the_counter(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->assertSame(2, $this->rateLimiter->attempts('test-key')); + + $this->rateLimiter->clear('test-key'); + + $this->assertSame(0, $this->rateLimiter->attempts('test-key')); + } + + public function test_different_keys_are_independent(): void + { + $this->rateLimiter->attempt('key-a', maxAttempts: 2, decaySeconds: 60); + $this->rateLimiter->attempt('key-a', maxAttempts: 2, decaySeconds: 60); + + // key-a is at limit + $this->assertTrue($this->rateLimiter->tooManyAttempts('key-a', maxAttempts: 2)); + + // key-b should still be available + $this->assertFalse($this->rateLimiter->tooManyAttempts('key-b', maxAttempts: 2)); + } + + public function test_reset_time_is_set_correctly(): void + { + $beforeTime = time(); + $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + $afterTime = time(); + + // Reset time should be approximately 60 seconds from now + $this->assertGreaterThanOrEqual($beforeTime + 60, $result->resetAt); + $this->assertLessThanOrEqual($afterTime + 60, $result->resetAt); + } +} diff --git a/packages/cache/tests/RateLimiting/RateLimitResultTest.php b/packages/cache/tests/RateLimiting/RateLimitResultTest.php new file mode 100644 index 0000000000..56b1f95b29 --- /dev/null +++ b/packages/cache/tests/RateLimiting/RateLimitResultTest.php @@ -0,0 +1,46 @@ +assertTrue($result->allowed); + $this->assertSame(100, $result->limit); + $this->assertSame(99, $result->remaining); + $this->assertSame($resetAt, $result->resetAt); + $this->assertLessThanOrEqual(60, $result->retryAfter); + } + + public function test_deny_creates_failed_result(): void + { + $resetAt = time() + 30; + $result = RateLimitResult::deny(limit: 100, resetAt: $resetAt); + + $this->assertFalse($result->allowed); + $this->assertSame(100, $result->limit); + $this->assertSame(0, $result->remaining); + $this->assertSame($resetAt, $result->resetAt); + $this->assertLessThanOrEqual(30, $result->retryAfter); + } + + public function test_retry_after_is_never_negative(): void + { + $pastTime = time() - 100; + $result = RateLimitResult::deny(limit: 100, resetAt: $pastTime); + + $this->assertSame(0, $result->retryAfter); + } +} diff --git a/packages/http/src/IsRequest.php b/packages/http/src/IsRequest.php index 1ee0020fb6..117d9fe4d0 100644 --- a/packages/http/src/IsRequest.php +++ b/packages/http/src/IsRequest.php @@ -94,6 +94,30 @@ public function getCookie(string $name): ?Cookie return $this->cookies[$name] ?? null; } + public function getClientIp(): string + { + $forwardedFor = $this->headers->get('X-Forwarded-For'); + + if ($forwardedFor !== null) { + $ips = explode(',', $forwardedFor); + + return trim($ips[0]); + } + + $realIp = $this->headers->get('X-Real-IP'); + + if ($realIp !== null) { + return $realIp; + } + + if (isset($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR'] !== '') { + return (string) $_SERVER['REMOTE_ADDR']; + } + + // Avoid a shared global fallback key when the client IP cannot be resolved. + return 'unknown:' . spl_object_id($this); + } + private function resolvePath(): string { $decodedUri = rawurldecode($this->uri); diff --git a/packages/http/src/Request.php b/packages/http/src/Request.php index 794d42c860..0daadb7142 100644 --- a/packages/http/src/Request.php +++ b/packages/http/src/Request.php @@ -58,6 +58,8 @@ public function getSessionValue(string $name): mixed; public function getCookie(string $name): ?Cookie; + public function getClientIp(): string; + /** * Determines if the request's "Content-Type" header matches the given content type. * If multiple content types are provided, the method returns true if any of them matches. diff --git a/packages/http/src/Responses/TooManyRequests.php b/packages/http/src/Responses/TooManyRequests.php new file mode 100644 index 0000000000..7110862419 --- /dev/null +++ b/packages/http/src/Responses/TooManyRequests.php @@ -0,0 +1,49 @@ +status = Status::TOO_MANY_REQUESTS; + $this->body = 'Too Many Requests'; + + if ($retryAfter !== null) { + $this->addHeader('Retry-After', (string) $retryAfter); + } + + if ($limit !== null) { + $this->addHeader('X-RateLimit-Limit', (string) $limit); + } + + if ($remaining !== null) { + $this->addHeader('X-RateLimit-Remaining', (string) $remaining); + } + + if ($resetAt !== null) { + $this->addHeader('X-RateLimit-Reset', (string) $resetAt); + } + } +} diff --git a/packages/http/tests/GenericRequestTest.php b/packages/http/tests/GenericRequestTest.php index f4a2e9f61a..91f260c45f 100644 --- a/packages/http/tests/GenericRequestTest.php +++ b/packages/http/tests/GenericRequestTest.php @@ -180,4 +180,74 @@ public function test_accepts_returns_true_on_first_match(): void $this->assertTrue($request->accepts(ContentType::AVIF, ContentType::PNG)); $this->assertFalse($request->accepts(ContentType::HTML, ContentType::PNG)); } + + public function test_get_client_ip_uses_x_forwarded_for_first_value(): void + { + $request = new GenericRequest( + method: Method::GET, + uri: '/', + headers: [ + 'X-Forwarded-For' => '203.0.113.10, 198.51.100.5', + 'X-Real-IP' => '192.0.2.20', + ], + ); + + $this->assertSame('203.0.113.10', $request->getClientIp()); + } + + public function test_get_client_ip_uses_x_real_ip_when_x_forwarded_for_is_missing(): void + { + $request = new GenericRequest( + method: Method::GET, + uri: '/', + headers: [ + 'X-Real-IP' => '192.0.2.20', + ], + ); + + $this->assertSame('192.0.2.20', $request->getClientIp()); + } + + public function test_get_client_ip_falls_back_to_remote_addr(): void + { + $previousRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; + $_SERVER['REMOTE_ADDR'] = '198.51.100.42'; + + try { + $request = new GenericRequest( + method: Method::GET, + uri: '/', + ); + + $this->assertSame('198.51.100.42', $request->getClientIp()); + } finally { + if ($previousRemoteAddr === null) { + unset($_SERVER['REMOTE_ADDR']); + } else { + $_SERVER['REMOTE_ADDR'] = $previousRemoteAddr; + } + } + } + + public function test_get_client_ip_returns_request_scoped_unknown_when_unavailable(): void + { + $previousRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; + unset($_SERVER['REMOTE_ADDR']); + + try { + $request = new GenericRequest( + method: Method::GET, + uri: '/', + ); + + $resolvedIp = $request->getClientIp(); + + $this->assertStringStartsWith('unknown:', $resolvedIp); + $this->assertSame($resolvedIp, $request->getClientIp()); + } finally { + if ($previousRemoteAddr !== null) { + $_SERVER['REMOTE_ADDR'] = $previousRemoteAddr; + } + } + } } diff --git a/packages/router/composer.json b/packages/router/composer.json index 91f5aa1708..d77e6b8c1b 100644 --- a/packages/router/composer.json +++ b/packages/router/composer.json @@ -5,6 +5,7 @@ "minimum-stability": "dev", "require": { "php": "^8.5", + "tempest/cache": "dev-main", "tempest/http": "dev-main", "tempest/view": "dev-main", "tempest/highlight": "^2.11.4", diff --git a/packages/router/src/Exceptions/HtmlExceptionRenderer.php b/packages/router/src/Exceptions/HtmlExceptionRenderer.php index e4bf13fbcb..95ed39ca3c 100644 --- a/packages/router/src/Exceptions/HtmlExceptionRenderer.php +++ b/packages/router/src/Exceptions/HtmlExceptionRenderer.php @@ -70,6 +70,11 @@ private function renderHttpRequestFailed(HttpRequestFailed $exception): Response return $this->renderErrorResponse($exception->status, message: $exception->getMessage()); } + // Preserve explicit error responses that carry meaningful headers (e.g. Retry-After). + if ($exception->cause && count($exception->cause->headers) > 0) { + return $exception->cause; + } + if ($exception->cause && is_string($exception->cause->body)) { return $this->renderErrorResponse($exception->status, message: $exception->cause->body); } diff --git a/packages/router/src/Exceptions/JsonExceptionRenderer.php b/packages/router/src/Exceptions/JsonExceptionRenderer.php index 9a3c247be2..a4dd65a26a 100644 --- a/packages/router/src/Exceptions/JsonExceptionRenderer.php +++ b/packages/router/src/Exceptions/JsonExceptionRenderer.php @@ -50,6 +50,11 @@ private function renderHttpRequestFailed(HttpRequestFailed $exception): Response return $this->renderErrorResponse($exception->status, message: $exception->getMessage()); } + // Preserve explicit error responses that carry meaningful headers (e.g. Retry-After). + if ($exception->cause && count($exception->cause->headers) > 0) { + return $exception->cause; + } + if ($exception->cause && is_string($exception->cause->body)) { return $this->renderErrorResponse($exception->status, message: $exception->cause->body); } diff --git a/packages/router/src/RateLimit.php b/packages/router/src/RateLimit.php new file mode 100644 index 0000000000..28e79911ac --- /dev/null +++ b/packages/router/src/RateLimit.php @@ -0,0 +1,65 @@ + + */ + public RateLimitBy|string $by = RateLimitBy::IP, + ) {} + + public function decorate(Route $route): Route + { + // RateLimitMiddleware uses #[SkipDiscovery] to prevent auto-discovery + // as a global middleware. It is only applied to routes with this attribute. + $route->middleware = [ + ...$route->middleware, + RateLimitMiddleware::class, + ]; + + return $route; + } +} diff --git a/packages/router/src/RateLimitBy.php b/packages/router/src/RateLimitBy.php new file mode 100644 index 0000000000..9a53377756 --- /dev/null +++ b/packages/router/src/RateLimitBy.php @@ -0,0 +1,20 @@ +headers->get('X-API-Key') ?? 'anonymous'; + * } + * } + * + * // Usage + * #[RateLimit(by: ApiKeyResolver::class)] + * ``` + */ +interface RateLimitIdentifierResolver +{ + public function resolve(Request $request): string; +} diff --git a/packages/router/src/RateLimitMiddleware.php b/packages/router/src/RateLimitMiddleware.php new file mode 100644 index 0000000000..54a8827100 --- /dev/null +++ b/packages/router/src/RateLimitMiddleware.php @@ -0,0 +1,168 @@ +getRateLimitAttribute(); + + if ($rateLimit === null) { + return $next($request); + } + + // Get the rate limiter from container at invocation time to support testing + $rateLimiter = $this->container->get(RateLimiter::class); + + $key = $this->resolveKey($rateLimit, $request); + $result = $rateLimiter->attempt($key, $rateLimit->maxAttempts, $rateLimit->decaySeconds); + + if (! $result->allowed) { + return $this->buildTooManyRequestsResponse($result); + } + + $response = $next($request); + + return $this->addRateLimitHeaders($response, $result); + } + + /** + * Get the RateLimit attribute from the current route handler. + */ + private function getRateLimitAttribute(): ?RateLimit + { + $handler = $this->matchedRoute->route->handler; + + // Check method first, then class + $rateLimit = $handler->getAttribute(RateLimit::class) ?? $handler->getDeclaringClass()->getAttribute(RateLimit::class); + + return $rateLimit; + } + + /** + * Resolve the rate limit key based on the configuration. + */ + private function resolveKey(RateLimit $rateLimit, Request $request): string + { + $prefix = $rateLimit->key ?? $this->matchedRoute->route->uri; + $identifier = $this->resolveIdentifier($rateLimit->by, $request); + + return sprintf('%s:%s', $prefix, $identifier); + } + + /** + * Resolve the client identifier based on the rate limit strategy. + */ + private function resolveIdentifier(RateLimitBy|string $by, Request $request): string + { + // Handle custom resolver class + if (is_string($by)) { + /** @var RateLimitIdentifierResolver $resolver */ + $resolver = $this->container->get($by); + + return $resolver->resolve($request); + } + + return match ($by) { + RateLimitBy::USER => $this->resolveUserIdentifier($request), + RateLimitBy::SESSION => $this->resolveSessionIdentifier($request), + RateLimitBy::IP => $request->getClientIp(), + }; + } + + /** + * Resolve the authenticated user ID, falling back to IP if not authenticated. + */ + private function resolveUserIdentifier(Request $request): string + { + if (! $this->container->has(Authenticator::class)) { + return $request->getClientIp(); + } + + /** @var Authenticator $authenticator */ + $authenticator = $this->container->get(Authenticator::class); + $user = $authenticator->current(); + + if ($user === null) { + return $request->getClientIp(); + } + + // Try to get an identifier from the authenticatable + // Fall back to IP if the user doesn't have an id property + if (property_exists($user, 'id')) { + return 'user:' . (string) $user->id; + } + + return $request->getClientIp(); + } + + /** + * Resolve the session ID, falling back to IP if no session is available. + */ + private function resolveSessionIdentifier(Request $request): string + { + if (! $this->container->has(Session::class)) { + return $request->getClientIp(); + } + + $session = $this->container->get(Session::class); + + return 'session:' . $session->id; + } + + /** + * Build a 429 Too Many Requests response. + */ + private function buildTooManyRequestsResponse(RateLimitResult $result): TooManyRequests + { + return new TooManyRequests( + retryAfter: $result->retryAfter, + limit: $result->limit, + remaining: $result->remaining, + resetAt: $result->resetAt, + ); + } + + /** + * Add rate limit headers to the response. + */ + private function addRateLimitHeaders(Response $response, RateLimitResult $result): Response + { + return $response + ->addHeader('X-RateLimit-Limit', (string) $result->limit) + ->addHeader('X-RateLimit-Remaining', (string) $result->remaining) + ->addHeader('X-RateLimit-Reset', (string) $result->resetAt); + } +} diff --git a/packages/router/src/RateLimiting/RateLimiterInitializer.php b/packages/router/src/RateLimiting/RateLimiterInitializer.php new file mode 100644 index 0000000000..b7f701372d --- /dev/null +++ b/packages/router/src/RateLimiting/RateLimiterInitializer.php @@ -0,0 +1,23 @@ +get(Cache::class), + ); + } +} diff --git a/packages/router/src/RateLimiting/Testing/TestingRateLimiter.php b/packages/router/src/RateLimiting/Testing/TestingRateLimiter.php new file mode 100644 index 0000000000..1ccd60b040 --- /dev/null +++ b/packages/router/src/RateLimiting/Testing/TestingRateLimiter.php @@ -0,0 +1,203 @@ + */ + private array $attempts = []; + + /** @var array */ + private array $timers = []; + + public function attempt(string $key, int $maxAttempts, int $decaySeconds): RateLimitResult + { + // Initialize timer if not set + if (! isset($this->timers[$key])) { + $this->timers[$key] = time() + $decaySeconds; + } + + // Increment attempts + if (! isset($this->attempts[$key])) { + $this->attempts[$key] = 0; + } + $this->attempts[$key]++; + + $attempts = $this->attempts[$key]; + $resetAt = $this->timers[$key]; + $remaining = max(0, $maxAttempts - $attempts); + + if ($attempts > $maxAttempts) { + return RateLimitResult::deny($maxAttempts, $resetAt); + } + + return RateLimitResult::allow($maxAttempts, $remaining, $resetAt); + } + + public function attempts(string $key): int + { + return $this->attempts[$key] ?? 0; + } + + public function remaining(string $key, int $maxAttempts): int + { + return max(0, $maxAttempts - $this->attempts($key)); + } + + public function tooManyAttempts(string $key, int $maxAttempts): bool + { + return $this->attempts($key) >= $maxAttempts; + } + + public function clear(string $key): void + { + unset($this->attempts[$key], $this->timers[$key]); + } + + public function availableAt(string $key): DateTime + { + $timestamp = $this->timers[$key] ?? time(); + + return DateTime::fromTimestamp($timestamp); + } + + /** + * Clear all rate limiting state. + */ + public function clearAll(): void + { + $this->attempts = []; + $this->timers = []; + } + + /** + * Assert that a key has been hit a specific number of times. + */ + public function assertAttempts(string $key, int $expected): self + { + Assert::assertSame( + $expected, + $this->attempts($key), + sprintf('Expected %d attempts for key `%s`, got %d.', $expected, $key, $this->attempts($key)), + ); + + return $this; + } + + /** + * Assert that a key has remaining attempts. + */ + public function assertRemainingAttempts(string $key, int $maxAttempts, int $expected): self + { + Assert::assertSame( + $expected, + $this->remaining($key, $maxAttempts), + sprintf('Expected %d remaining attempts for key `%s`, got %d.', $expected, $key, $this->remaining($key, $maxAttempts)), + ); + + return $this; + } + + /** + * Assert that a key is rate limited (has exceeded max attempts). + */ + public function assertLimited(string $key, int $maxAttempts): self + { + Assert::assertTrue( + $this->tooManyAttempts($key, $maxAttempts), + sprintf('Expected key `%s` to be rate limited with max %d attempts, but it has %d attempts.', $key, $maxAttempts, $this->attempts($key)), + ); + + return $this; + } + + /** + * Assert that a key is not rate limited. + */ + public function assertNotLimited(string $key, int $maxAttempts): self + { + Assert::assertFalse( + $this->tooManyAttempts($key, $maxAttempts), + sprintf('Expected key `%s` to not be rate limited, but it has %d attempts (max: %d).', $key, $this->attempts($key), $maxAttempts), + ); + + return $this; + } + + /** + * Assert that a key has no recorded attempts (or has been cleared). + */ + public function assertCleared(string $key): self + { + Assert::assertFalse( + isset($this->attempts[$key]), + sprintf('Expected key `%s` to be cleared, but it has %d attempts.', $key, $this->attempts($key)), + ); + + return $this; + } + + /** + * Assert that no rate limiting state exists. + */ + public function assertEmpty(): self + { + Assert::assertEmpty( + $this->attempts, + sprintf('Expected rate limiter to be empty, but it has %d keys.', count($this->attempts)), + ); + + return $this; + } + + /** + * Assert that some rate limiting state exists. + */ + public function assertNotEmpty(): self + { + Assert::assertNotEmpty( + $this->attempts, + 'Expected rate limiter to have some state, but it is empty.', + ); + + return $this; + } + + /** + * Assert that a specific key exists in the rate limiter. + */ + public function assertHasKey(string $key): self + { + Assert::assertArrayHasKey( + $key, + $this->attempts, + sprintf('Expected rate limiter to have key `%s`, but it does not.', $key), + ); + + return $this; + } + + /** + * Assert that a specific key does not exist in the rate limiter. + */ + public function assertMissingKey(string $key): self + { + Assert::assertArrayNotHasKey( + $key, + $this->attempts, + sprintf('Expected rate limiter to not have key `%s`, but it does.', $key), + ); + + return $this; + } +} diff --git a/packages/router/tests/RateLimiting/Testing/TestingRateLimiterTest.php b/packages/router/tests/RateLimiting/Testing/TestingRateLimiterTest.php new file mode 100644 index 0000000000..4ea6343a34 --- /dev/null +++ b/packages/router/tests/RateLimiting/Testing/TestingRateLimiterTest.php @@ -0,0 +1,288 @@ +rateLimiter = new TestingRateLimiter(); + } + + #[Test] + public function attempt_allows_within_limit(): void + { + $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->assertTrue($result->allowed); + $this->assertSame(5, $result->limit); + $this->assertSame(4, $result->remaining); + } + + #[Test] + public function attempt_denies_after_exceeding_limit(): void + { + // Make 5 attempts (the limit) + for ($i = 0; $i < 5; $i++) { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + } + + // 6th attempt should be denied + $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->assertFalse($result->allowed); + $this->assertSame(0, $result->remaining); + } + + #[Test] + public function attempts_returns_count(): void + { + $this->assertSame(0, $this->rateLimiter->attempts('test-key')); + + $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); + $this->assertSame(1, $this->rateLimiter->attempts('test-key')); + + $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); + $this->assertSame(2, $this->rateLimiter->attempts('test-key')); + } + + #[Test] + public function remaining_returns_available_attempts(): void + { + $this->assertSame(10, $this->rateLimiter->remaining('test-key', maxAttempts: 10)); + + $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); + $this->assertSame(9, $this->rateLimiter->remaining('test-key', maxAttempts: 10)); + } + + #[Test] + public function too_many_attempts_returns_true_when_exceeded(): void + { + $this->assertFalse($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); + + $this->rateLimiter->attempt('test-key', maxAttempts: 2, decaySeconds: 60); + $this->assertFalse($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); + + $this->rateLimiter->attempt('test-key', maxAttempts: 2, decaySeconds: 60); + $this->assertTrue($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); + } + + #[Test] + public function clear_removes_attempts_for_key(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + $this->assertSame(1, $this->rateLimiter->attempts('test-key')); + + $this->rateLimiter->clear('test-key'); + $this->assertSame(0, $this->rateLimiter->attempts('test-key')); + } + + #[Test] + public function clear_all_removes_all_state(): void + { + $this->rateLimiter->attempt('key-1', maxAttempts: 5, decaySeconds: 60); + $this->rateLimiter->attempt('key-2', maxAttempts: 5, decaySeconds: 60); + + $this->rateLimiter->clearAll(); + + $this->assertSame(0, $this->rateLimiter->attempts('key-1')); + $this->assertSame(0, $this->rateLimiter->attempts('key-2')); + } + + #[Test] + public function assert_attempts_passes_when_correct(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $result = $this->rateLimiter->assertAttempts('test-key', 2); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_attempts_fails_when_incorrect(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertAttempts('test-key', 5); + } + + #[Test] + public function assert_remaining_attempts_passes_when_correct(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $result = $this->rateLimiter->assertRemainingAttempts('test-key', maxAttempts: 5, expected: 4); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_remaining_attempts_fails_when_incorrect(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertRemainingAttempts('test-key', maxAttempts: 5, expected: 5); + } + + #[Test] + public function assert_limited_passes_when_limited(): void + { + for ($i = 0; $i < 3; $i++) { + $this->rateLimiter->attempt('test-key', maxAttempts: 3, decaySeconds: 60); + } + + $result = $this->rateLimiter->assertLimited('test-key', maxAttempts: 3); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_limited_fails_when_not_limited(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertLimited('test-key', maxAttempts: 5); + } + + #[Test] + public function assert_not_limited_passes_when_not_limited(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $result = $this->rateLimiter->assertNotLimited('test-key', maxAttempts: 5); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_not_limited_fails_when_limited(): void + { + for ($i = 0; $i < 3; $i++) { + $this->rateLimiter->attempt('test-key', maxAttempts: 3, decaySeconds: 60); + } + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertNotLimited('test-key', maxAttempts: 3); + } + + #[Test] + public function assert_cleared_passes_when_cleared(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + $this->rateLimiter->clear('test-key'); + + $result = $this->rateLimiter->assertCleared('test-key'); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_cleared_fails_when_not_cleared(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertCleared('test-key'); + } + + #[Test] + public function assert_empty_passes_when_empty(): void + { + $result = $this->rateLimiter->assertEmpty(); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_empty_fails_when_not_empty(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertEmpty(); + } + + #[Test] + public function assert_not_empty_passes_when_not_empty(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $result = $this->rateLimiter->assertNotEmpty(); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_not_empty_fails_when_empty(): void + { + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertNotEmpty(); + } + + #[Test] + public function assert_has_key_passes_when_key_exists(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $result = $this->rateLimiter->assertHasKey('test-key'); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_has_key_fails_when_key_missing(): void + { + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertHasKey('missing-key'); + } + + #[Test] + public function assert_missing_key_passes_when_key_missing(): void + { + $result = $this->rateLimiter->assertMissingKey('missing-key'); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_missing_key_fails_when_key_exists(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertMissingKey('test-key'); + } + + #[Test] + public function assertions_can_be_chained(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $result = $this->rateLimiter + ->assertNotEmpty() + ->assertHasKey('test-key') + ->assertAttempts('test-key', 2) + ->assertRemainingAttempts('test-key', maxAttempts: 5, expected: 3) + ->assertNotLimited('test-key', maxAttempts: 5); + + $this->assertSame($this->rateLimiter, $result); + } +} diff --git a/tests/Integration/Route/Fixtures/RateLimitedController.php b/tests/Integration/Route/Fixtures/RateLimitedController.php new file mode 100644 index 0000000000..c061e1281d --- /dev/null +++ b/tests/Integration/Route/Fixtures/RateLimitedController.php @@ -0,0 +1,41 @@ +previousRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + + // Use a testing rate limiter that is isolated per test + $this->rateLimiter = new TestingRateLimiter(); + + // Unregister any existing singleton and initializer, then add our singleton + if ($this->container instanceof GenericContainer) { + $this->container->unregister(RateLimiter::class); + $this->container->removeInitializer(RateLimiterInitializer::class); + } + $this->container->singleton(RateLimiter::class, fn () => $this->rateLimiter); + } + + protected function tearDown(): void + { + if ($this->previousRemoteAddr === null) { + unset($_SERVER['REMOTE_ADDR']); + } else { + $_SERVER['REMOTE_ADDR'] = $this->previousRemoteAddr; + } + + parent::tearDown(); + } + + #[Test] + public function allows_requests_within_limit(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limited']); + + // First 3 requests should succeed + for ($i = 0; $i < 3; $i++) { + $response = $this->http->get('/rate-limited'); + $this->assertSame(Status::OK, $response->status); + $this->assertSame('success', $response->body); + } + } + + #[Test] + public function blocks_requests_exceeding_limit(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limited']); + + // Make 3 requests (the limit) + for ($i = 0; $i < 3; $i++) { + $this->http->get('/rate-limited'); + } + + // 4th request should be blocked + $response = $this->http->get('/rate-limited'); + $this->assertSame(Status::TOO_MANY_REQUESTS, $response->status); + } + + #[Test] + public function includes_rate_limit_headers(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limited']); + + $response = $this->http->get('/rate-limited'); + + $this->assertSame(Status::OK, $response->status); + $response->assertHasHeader('X-RateLimit-Limit'); + $response->assertHasHeader('X-RateLimit-Remaining'); + $response->assertHasHeader('X-RateLimit-Reset'); + $response->assertHeaderContains('X-RateLimit-Limit', '3'); + $response->assertHeaderContains('X-RateLimit-Remaining', '2'); + } + + #[Test] + public function includes_retry_after_header_when_limited(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limited']); + + // Exhaust the limit + for ($i = 0; $i < 3; $i++) { + $this->http->get('/rate-limited'); + } + + $response = $this->http->get('/rate-limited'); + + $this->assertSame(Status::TOO_MANY_REQUESTS, $response->status); + $response->assertHasHeader('Retry-After'); + $response->assertHasHeader('X-RateLimit-Limit'); + $response->assertHeaderContains('X-RateLimit-Remaining', '0'); + } + + #[Test] + public function routes_without_rate_limit_are_not_affected(): void + { + $this->http->registerRoute([RateLimitedController::class, 'noLimit']); + + // Should be able to make unlimited requests + for ($i = 0; $i < 100; $i++) { + $response = $this->http->get('/no-rate-limit'); + $this->assertSame(Status::OK, $response->status); + } + } + + #[Test] + public function different_routes_have_separate_limits(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limited']); + $this->http->registerRoute([RateLimitedController::class, 'limitedCustomKey']); + + // Exhaust limit on first route + for ($i = 0; $i < 3; $i++) { + $this->http->get('/rate-limited'); + } + + // Second route should still work (different key) + $response = $this->http->get('/rate-limited-custom-key'); + $this->assertSame(Status::OK, $response->status); + } + + #[Test] + public function custom_key_is_used(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limitedCustomKey']); + + // Make 2 requests (the limit for this route) + $response1 = $this->http->get('/rate-limited-custom-key'); + $response2 = $this->http->get('/rate-limited-custom-key'); + + $this->assertSame(Status::OK, $response1->status); + $this->assertSame(Status::OK, $response2->status); + + // 3rd request should be blocked + $response3 = $this->http->get('/rate-limited-custom-key'); + $this->assertSame(Status::TOO_MANY_REQUESTS, $response3->status); + } + + #[Test] + public function remaining_count_decrements(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limited']); + + $response1 = $this->http->get('/rate-limited'); + $response1->assertHeaderContains('X-RateLimit-Remaining', '2'); + + $response2 = $this->http->get('/rate-limited'); + $response2->assertHeaderContains('X-RateLimit-Remaining', '1'); + + $response3 = $this->http->get('/rate-limited'); + $response3->assertHeaderContains('X-RateLimit-Remaining', '0'); + } + + #[Test] + public function unauthenticated_user_limits_are_scoped_per_client_ip(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limitedByUser']); + + $firstClientHeaders = ['X-Forwarded-For' => '203.0.113.10']; + $secondClientHeaders = ['X-Forwarded-For' => '198.51.100.15']; + + for ($i = 0; $i < 5; $i++) { + $response = $this->http->get('/rate-limited-by-user', headers: $firstClientHeaders); + $this->assertSame(Status::OK, $response->status); + } + + $limitedResponse = $this->http->get('/rate-limited-by-user', headers: $firstClientHeaders); + $this->assertSame(Status::TOO_MANY_REQUESTS, $limitedResponse->status); + + $differentIpResponse = $this->http->get('/rate-limited-by-user', headers: $secondClientHeaders); + $this->assertSame(Status::OK, $differentIpResponse->status); + } +}