- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
376 lines
13 KiB
PHP
376 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Http\Headers;
|
|
use App\Framework\Http\HttpRequest;
|
|
use App\Framework\Http\HttpResponse;
|
|
use App\Framework\Http\Method;
|
|
use App\Framework\Http\MiddlewareContext;
|
|
use App\Framework\Http\Middlewares\RateLimitMiddleware;
|
|
use App\Framework\Http\Next;
|
|
use App\Framework\Http\RequestStateManager;
|
|
use App\Framework\Http\ResponseManipulator;
|
|
use App\Framework\Http\ServerEnvironment;
|
|
use App\Framework\Http\Status;
|
|
use App\Framework\RateLimit\RateLimitConfig;
|
|
use App\Framework\RateLimit\RateLimiter;
|
|
use App\Framework\RateLimit\Storage\StorageInterface;
|
|
use App\Framework\RateLimit\TimeProvider\TimeProviderInterface;
|
|
|
|
beforeEach(function () {
|
|
// Create test storage
|
|
$this->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);
|
|
});
|