storage = new class () implements StorageInterface { public array $requests = []; public array $tokenBuckets = []; public function getRequestsInWindow(string $key, int $windowStart, int $windowEnd): array { if (! isset($this->requests[$key])) { return []; } return array_filter( $this->requests[$key], fn ($timestamp) => $timestamp >= $windowStart && $timestamp <= $windowEnd ); } public function addRequest(string $key, int $timestamp, int $ttl): void { if (! isset($this->requests[$key])) { $this->requests[$key] = []; } $this->requests[$key][] = $timestamp; } public function getTokenBucket(string $key): ?\App\Framework\RateLimit\TokenBucket { return $this->tokenBuckets[$key] ?? null; } public function saveTokenBucket(string $key, \App\Framework\RateLimit\TokenBucket $bucket): void { $this->tokenBuckets[$key] = $bucket; } public function clear(string $key): void { unset($this->requests[$key], $this->tokenBuckets[$key]); } public function getBaseline(string $key): ?array { // Simple test implementation - return null for no baseline return null; } public function updateBaseline(string $key, int $rate): void { // Simple test implementation - do nothing } public function reset(): void { $this->requests = []; $this->tokenBuckets = []; } }; // Create test time provider $this->timeProvider = new class () implements TimeProviderInterface { public int $currentTime = 1000; public function getCurrentTime(): int { return $this->currentTime; } public function setTime(int $time): void { $this->currentTime = $time; } }; $this->rateLimiter = new RateLimiter($this->storage, $this->timeProvider); $this->responseManipulator = new ResponseManipulator(); $this->config = new RateLimitConfig( enabled: true, requestsPerMinute: 10, windowSize: 60.0, trustedIps: ['192.168.1.100'], exemptPaths: ['/health'] ); $this->middleware = new RateLimitMiddleware( $this->rateLimiter, $this->responseManipulator, $this->config ); // Create test request $this->request = new HttpRequest( method: Method::GET, path: '/api/test' ); $this->stateManager = new RequestStateManager(new WeakMap(), $this->request); $this->context = new MiddlewareContext($this->request); }); afterEach(function () { $this->storage->reset(); $this->timeProvider->setTime(1000); }); it('allows requests within rate limit', function () { $next = new class () implements Next { public function __invoke(MiddlewareContext $context): MiddlewareContext { return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'success')); } }; $result = $this->middleware->__invoke($this->context, $next, $this->stateManager); expect($result->hasResponse())->toBeTrue(); expect($result->response->status)->toBe(Status::OK); expect($result->response->body)->toBe('success'); // Check rate limit headers expect($result->response->headers->has('X-RateLimit-Limit'))->toBeTrue(); expect($result->response->headers->has('X-RateLimit-Remaining'))->toBeTrue(); expect($result->response->headers->has('X-RateLimit-Reset'))->toBeTrue(); }); it('blocks requests exceeding rate limit', function () { $next = new class () implements Next { public function __invoke(MiddlewareContext $context): MiddlewareContext { return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'should not reach')); } }; // Fill up the rate limit by making actual middleware calls for ($i = 0; $i < 10; $i++) { $this->middleware->__invoke($this->context, $next, $this->stateManager); } // This should be blocked $result = $this->middleware->__invoke($this->context, $next, $this->stateManager); expect($result->hasResponse())->toBeTrue(); expect($result->response->status)->toBe(Status::TOO_MANY_REQUESTS); // Check rate limit headers expect($result->response->headers->getFirst('X-RateLimit-Limit'))->toBe('10'); expect($result->response->headers->getFirst('X-RateLimit-Remaining'))->toBe('0'); expect($result->response->headers->has('Retry-After'))->toBeTrue(); // Check JSON response body $body = json_decode($result->response->body, true); expect($body['error'])->toBe('Rate limit exceeded'); expect($body['limit'])->toBe(10); }); it('applies same limit to all endpoints', function () { // Test /login endpoint with regular limit (10 requests) $loginRequest = new HttpRequest(method: Method::POST, path: '/login'); $context = new MiddlewareContext($loginRequest); $stateManager = new RequestStateManager(new WeakMap(), $loginRequest); $next = new class () implements Next { public function __invoke(MiddlewareContext $context): MiddlewareContext { return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'success')); } }; // Fill up the rate limit by making actual middleware calls for ($i = 0; $i < 10; $i++) { $this->middleware->__invoke($context, $next, $stateManager); } // This should be blocked $result = $this->middleware->__invoke($context, $next, $stateManager); expect($result->response->status)->toBe(Status::TOO_MANY_REQUESTS); expect($result->response->headers->getFirst('X-RateLimit-Limit'))->toBe('10'); }); it('exempts whitelisted IPs', function () { // Use exempt IP $exemptRequest = new HttpRequest( method: Method::GET, path: '/api/test', server: new ServerEnvironment(['REMOTE_ADDR' => '192.168.1.100']) ); $context = new MiddlewareContext($exemptRequest); $stateManager = new RequestStateManager(new WeakMap(), $exemptRequest); // Fill up rate limit for this IP (should be ignored) for ($i = 0; $i < 15; $i++) { $this->rateLimiter->checkLimit('ip:192.168.1.100', 10, 60); } $next = new class () implements Next { public function __invoke(MiddlewareContext $context): MiddlewareContext { return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'exempt')); } }; $result = $this->middleware->__invoke($context, $next, $stateManager); expect($result->response->status)->toBe(Status::OK); expect($result->response->body)->toBe('exempt'); }); it('exempts whitelisted endpoints', function () { $healthRequest = new HttpRequest(method: Method::GET, path: '/health'); $context = new MiddlewareContext($healthRequest); $stateManager = new RequestStateManager(new WeakMap(), $healthRequest); // Fill up rate limit (should be ignored for /health) for ($i = 0; $i < 15; $i++) { $this->rateLimiter->checkLimit('ip:127.0.0.1', 10, 60); } $next = new class () implements Next { public function __invoke(MiddlewareContext $context): MiddlewareContext { return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'healthy')); } }; $result = $this->middleware->__invoke($context, $next, $stateManager); expect($result->response->status)->toBe(Status::OK); expect($result->response->body)->toBe('healthy'); }); it('respects disabled configuration', function () { $disabledConfig = new RateLimitConfig(enabled: false); $middleware = new RateLimitMiddleware( $this->rateLimiter, $this->responseManipulator, $disabledConfig ); // Fill up rate limit for ($i = 0; $i < 15; $i++) { $this->rateLimiter->checkLimit('ip:127.0.0.1', 10, 60); } $next = new class () implements Next { public function __invoke(MiddlewareContext $context): MiddlewareContext { return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'disabled')); } }; $result = $middleware->__invoke($this->context, $next, $this->stateManager); expect($result->response->status)->toBe(Status::OK); expect($result->response->body)->toBe('disabled'); }); it('passes through when no response is set', function () { $next = new class () implements Next { public function __invoke(MiddlewareContext $context): MiddlewareContext { return $context; // No response set } }; $result = $this->middleware->__invoke($this->context, $next, $this->stateManager); expect($result->hasResponse())->toBeFalse(); }); it('adds correct rate limit headers', function () { $next = new class () implements Next { public function __invoke(MiddlewareContext $context): MiddlewareContext { return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'test')); } }; $result = $this->middleware->__invoke($this->context, $next, $this->stateManager); $headers = $result->response->headers; expect($headers->has('X-RateLimit-Limit'))->toBeTrue(); expect($headers->has('X-RateLimit-Remaining'))->toBeTrue(); expect($headers->has('X-RateLimit-Reset'))->toBeTrue(); expect($headers->getFirst('X-RateLimit-Limit'))->toBe('10'); expect((int) $headers->getFirst('X-RateLimit-Remaining'))->toBeLessThan(10); expect((int) $headers->getFirst('X-RateLimit-Reset'))->toBeGreaterThan(time()); }); it('handles time window properly', function () { // Make 9 requests (within limit) for ($i = 0; $i < 9; $i++) { $result = $this->rateLimiter->checkLimit('ip:127.0.0.1', 10, 60); expect($result->isAllowed())->toBeTrue(); } // 10th request should still be allowed $next = new class () implements Next { public function __invoke(MiddlewareContext $context): MiddlewareContext { return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'allowed')); } }; $result = $this->middleware->__invoke($this->context, $next, $this->stateManager); expect($result->response->status)->toBe(Status::OK); // Move time forward beyond window $this->timeProvider->setTime(2000); // +1000 seconds // Should be allowed again after window reset $result2 = $this->middleware->__invoke($this->context, $next, $this->stateManager); expect($result2->response->status)->toBe(Status::OK); }); it('generates different keys for different endpoints', function () { $config = new RateLimitConfig( enabled: true, requestsPerMinute: 5, windowSize: 60.0 ); $middleware = new RateLimitMiddleware( $this->rateLimiter, $this->responseManipulator, $config ); $next = new class () implements Next { public function __invoke(MiddlewareContext $context): MiddlewareContext { return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'test')); } }; // Make requests to different endpoints - should each have their own limits $apiRequest = new HttpRequest(method: Method::GET, path: '/api/users'); $apiContext = new MiddlewareContext($apiRequest); $apiStateManager = new RequestStateManager(new WeakMap(), $apiRequest); $webRequest = new HttpRequest(method: Method::GET, path: '/web/dashboard'); $webContext = new MiddlewareContext($webRequest); $webStateManager = new RequestStateManager(new WeakMap(), $webRequest); // Both should be allowed since they're different endpoints $result1 = $middleware->__invoke($apiContext, $next, $apiStateManager); $result2 = $middleware->__invoke($webContext, $next, $webStateManager); expect($result1->response->status)->toBe(Status::OK); expect($result2->response->status)->toBe(Status::OK); });