Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
476
tests/Framework/Http/Middlewares/CsrfMiddlewareTest.php
Normal file
476
tests/Framework/Http/Middlewares/CsrfMiddlewareTest.php
Normal file
@@ -0,0 +1,476 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\DateTime\Clock;
|
||||
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\CsrfMiddleware;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestBody;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Session\SessionId;
|
||||
use App\Framework\Http\Session\SessionInterface;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Security\CsrfToken;
|
||||
use App\Framework\Security\CsrfTokenGenerator;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create test CSRF protection mock
|
||||
$this->csrfProtection = new class () {
|
||||
public array $validatedTokens = [];
|
||||
|
||||
public bool $shouldValidate = true;
|
||||
|
||||
public function validateToken(string $formId, CsrfToken $token): bool
|
||||
{
|
||||
$this->validatedTokens[] = ['formId' => $formId, 'token' => $token->toString()];
|
||||
|
||||
return $this->shouldValidate;
|
||||
}
|
||||
|
||||
public function rotateToken(string $formId): CsrfToken
|
||||
{
|
||||
return CsrfToken::fromString(str_repeat('a', 64));
|
||||
}
|
||||
};
|
||||
|
||||
// Create test session
|
||||
$this->session = new class ($this->csrfProtection) implements SessionInterface {
|
||||
public function __construct(public $csrf)
|
||||
{
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $default;
|
||||
}
|
||||
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function remove(string $key): void
|
||||
{
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function fromArray(SessionId $sessionId, Clock $clock, CsrfTokenGenerator $csrfTokenGenerator, array $data): self
|
||||
{
|
||||
return new self(new class () {
|
||||
public function validateToken(string $formId, CsrfToken $token): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rotateToken(string $formId): CsrfToken
|
||||
{
|
||||
return CsrfToken::fromString(str_repeat('a', 64));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Create test container mock
|
||||
$this->container = new class ($this->session) implements \App\Framework\DI\Container {
|
||||
public \App\Framework\DI\MethodInvoker $invoker {
|
||||
get => new \App\Framework\DI\MethodInvoker(new \App\Framework\DI\DependencyResolver(new \App\Framework\DI\DefaultContainer()));
|
||||
}
|
||||
|
||||
public function __construct(private $session)
|
||||
{
|
||||
}
|
||||
|
||||
public function get(string $class): object
|
||||
{
|
||||
if ($class === \App\Framework\Http\Session\SessionInterface::class) {
|
||||
return $this->session;
|
||||
}
|
||||
|
||||
throw new \RuntimeException("Service not found: $class");
|
||||
}
|
||||
|
||||
public function has(string $class): bool
|
||||
{
|
||||
return $class === \App\Framework\Http\Session\SessionInterface::class;
|
||||
}
|
||||
|
||||
public function bind(string $abstract, callable|string|object $concrete): void
|
||||
{
|
||||
}
|
||||
|
||||
public function singleton(string $abstract, callable|string|object $concrete): void
|
||||
{
|
||||
}
|
||||
|
||||
public function instance(string $abstract, object $instance): void
|
||||
{
|
||||
}
|
||||
|
||||
public function forget(string $class): void
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
$this->middleware = new CsrfMiddleware($this->container);
|
||||
|
||||
// Create test request
|
||||
$this->getRequest = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/test'
|
||||
);
|
||||
|
||||
$this->stateManager = new RequestStateManager(new WeakMap(), $this->getRequest);
|
||||
});
|
||||
|
||||
it('allows GET requests without CSRF validation', function () {
|
||||
$context = new MiddlewareContext($this->getRequest);
|
||||
|
||||
$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($context, $next, $this->stateManager);
|
||||
|
||||
expect($result->hasResponse())->toBeTrue();
|
||||
expect($result->response->status)->toBe(Status::OK);
|
||||
expect($this->csrfProtection->validatedTokens)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('validates CSRF token for POST requests', function () {
|
||||
// Create POST request with CSRF data
|
||||
// Use multipart/form-data content type so the $post array is used
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'multipart/form-data',
|
||||
]);
|
||||
|
||||
$requestBody = new RequestBody(Method::POST, $headers, '', [
|
||||
'_form_id' => 'contact-form',
|
||||
'_token' => str_repeat('b', 64),
|
||||
]);
|
||||
|
||||
$postRequest = new HttpRequest(
|
||||
method: Method::POST,
|
||||
headers: $headers,
|
||||
path: '/contact',
|
||||
parsedBody: $requestBody
|
||||
);
|
||||
|
||||
$context = new MiddlewareContext($postRequest);
|
||||
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
||||
|
||||
$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($context, $next, $stateManager);
|
||||
|
||||
expect($result->hasResponse())->toBeTrue();
|
||||
expect($result->response->status)->toBe(Status::OK);
|
||||
expect($this->csrfProtection->validatedTokens)->toHaveCount(1);
|
||||
expect($this->csrfProtection->validatedTokens[0]['formId'])->toBe('contact-form');
|
||||
expect($this->csrfProtection->validatedTokens[0]['token'])->toBe(str_repeat('b', 64));
|
||||
});
|
||||
|
||||
it('validates CSRF token from headers for POST requests', function () {
|
||||
// Create POST request with CSRF data in headers
|
||||
$headers = new Headers([
|
||||
'X-CSRF-Form-ID' => 'api-form',
|
||||
'X-CSRF-Token' => str_repeat('c', 64),
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
|
||||
$postRequest = new HttpRequest(
|
||||
method: Method::POST,
|
||||
headers: $headers,
|
||||
path: '/api/submit',
|
||||
parsedBody: new RequestBody(Method::POST, $headers, '{"data": "test"}', [])
|
||||
);
|
||||
|
||||
$context = new MiddlewareContext($postRequest);
|
||||
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
||||
|
||||
$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($context, $next, $stateManager);
|
||||
|
||||
expect($result->hasResponse())->toBeTrue();
|
||||
expect($this->csrfProtection->validatedTokens)->toHaveCount(1);
|
||||
expect($this->csrfProtection->validatedTokens[0]['formId'])->toBe('api-form');
|
||||
expect($this->csrfProtection->validatedTokens[0]['token'])->toBe(str_repeat('c', 64));
|
||||
});
|
||||
|
||||
it('validates CSRF token for PUT requests', function () {
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'multipart/form-data',
|
||||
]);
|
||||
|
||||
$requestBody = new RequestBody(Method::PUT, $headers, '', [
|
||||
'_form_id' => 'update-form',
|
||||
'_token' => str_repeat('d', 64),
|
||||
]);
|
||||
|
||||
$putRequest = new HttpRequest(
|
||||
method: Method::PUT,
|
||||
headers: $headers,
|
||||
path: '/update',
|
||||
parsedBody: $requestBody
|
||||
);
|
||||
|
||||
$context = new MiddlewareContext($putRequest);
|
||||
$stateManager = new RequestStateManager(new WeakMap(), $putRequest);
|
||||
|
||||
$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($context, $next, $stateManager);
|
||||
|
||||
expect($this->csrfProtection->validatedTokens)->toHaveCount(1);
|
||||
expect($this->csrfProtection->validatedTokens[0]['formId'])->toBe('update-form');
|
||||
});
|
||||
|
||||
it('validates CSRF token for DELETE requests', function () {
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'multipart/form-data',
|
||||
]);
|
||||
|
||||
$requestBody = new RequestBody(Method::DELETE, $headers, '', [
|
||||
'_form_id' => 'delete-form',
|
||||
'_token' => str_repeat('e', 64),
|
||||
]);
|
||||
|
||||
$deleteRequest = new HttpRequest(
|
||||
method: Method::DELETE,
|
||||
headers: $headers,
|
||||
path: '/delete',
|
||||
parsedBody: $requestBody
|
||||
);
|
||||
|
||||
$context = new MiddlewareContext($deleteRequest);
|
||||
$stateManager = new RequestStateManager(new WeakMap(), $deleteRequest);
|
||||
|
||||
$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($context, $next, $stateManager);
|
||||
|
||||
expect($this->csrfProtection->validatedTokens)->toHaveCount(1);
|
||||
expect($this->csrfProtection->validatedTokens[0]['formId'])->toBe('delete-form');
|
||||
});
|
||||
|
||||
it('skips CSRF validation when session is not available', function () {
|
||||
// Create container that throws exception when getting session
|
||||
$failingContainer = new class () implements \App\Framework\DI\Container {
|
||||
public \App\Framework\DI\MethodInvoker $invoker {
|
||||
get => new \App\Framework\DI\MethodInvoker(new \App\Framework\DI\DependencyResolver(new \App\Framework\DI\DefaultContainer()));
|
||||
}
|
||||
|
||||
public function get(string $class): object
|
||||
{
|
||||
throw new \RuntimeException("Service not found: $class");
|
||||
}
|
||||
|
||||
public function has(string $class): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function bind(string $abstract, callable|string|object $concrete): void
|
||||
{
|
||||
}
|
||||
|
||||
public function singleton(string $abstract, callable|string|object $concrete): void
|
||||
{
|
||||
}
|
||||
|
||||
public function instance(string $abstract, object $instance): void
|
||||
{
|
||||
}
|
||||
|
||||
public function forget(string $class): void
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
$middleware = new CsrfMiddleware($failingContainer);
|
||||
|
||||
$postRequest = new HttpRequest(
|
||||
method: Method::POST,
|
||||
path: '/test',
|
||||
parsedBody: new RequestBody(Method::POST, new Headers(), '', [])
|
||||
);
|
||||
|
||||
$context = new MiddlewareContext($postRequest);
|
||||
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
||||
|
||||
$next = new class () implements Next {
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context->withResponse(new HttpResponse(Status::OK, new Headers(), 'success'));
|
||||
}
|
||||
};
|
||||
|
||||
$result = $middleware->__invoke($context, $next, $stateManager);
|
||||
|
||||
// Should skip CSRF validation and proceed
|
||||
expect($result->hasResponse())->toBeTrue();
|
||||
expect($result->response->status)->toBe(Status::OK);
|
||||
});
|
||||
|
||||
it('throws exception when form ID is missing', function () {
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'multipart/form-data',
|
||||
]);
|
||||
|
||||
$requestBody = new RequestBody(Method::POST, $headers, '', [
|
||||
'_token' => str_repeat('f', 64),
|
||||
// Missing _form_id
|
||||
]);
|
||||
|
||||
$postRequest = new HttpRequest(
|
||||
method: Method::POST,
|
||||
headers: $headers,
|
||||
path: '/test',
|
||||
parsedBody: $requestBody
|
||||
);
|
||||
|
||||
$context = new MiddlewareContext($postRequest);
|
||||
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
||||
|
||||
$next = new class () implements Next {
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context;
|
||||
}
|
||||
};
|
||||
|
||||
$this->middleware->__invoke($context, $next, $stateManager);
|
||||
})->throws(InvalidArgumentException::class, 'CSRF protection requires both form ID and token');
|
||||
|
||||
it('throws exception when token is missing', function () {
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'multipart/form-data',
|
||||
]);
|
||||
|
||||
$requestBody = new RequestBody(Method::POST, $headers, '', [
|
||||
'_form_id' => 'test-form',
|
||||
// Missing _token
|
||||
]);
|
||||
|
||||
$postRequest = new HttpRequest(
|
||||
method: Method::POST,
|
||||
headers: $headers,
|
||||
path: '/test',
|
||||
parsedBody: $requestBody
|
||||
);
|
||||
|
||||
$context = new MiddlewareContext($postRequest);
|
||||
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
||||
|
||||
$next = new class () implements Next {
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context;
|
||||
}
|
||||
};
|
||||
|
||||
$this->middleware->__invoke($context, $next, $stateManager);
|
||||
})->throws(InvalidArgumentException::class, 'CSRF protection requires both form ID and token');
|
||||
|
||||
it('throws exception when token validation fails', function () {
|
||||
// Set CSRF protection to fail validation
|
||||
$this->csrfProtection->shouldValidate = false;
|
||||
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'multipart/form-data',
|
||||
]);
|
||||
|
||||
$requestBody = new RequestBody(Method::POST, $headers, '', [
|
||||
'_form_id' => 'test-form',
|
||||
'_token' => str_repeat('f', 64),
|
||||
]);
|
||||
|
||||
$postRequest = new HttpRequest(
|
||||
method: Method::POST,
|
||||
headers: $headers,
|
||||
path: '/test',
|
||||
parsedBody: $requestBody
|
||||
);
|
||||
|
||||
$context = new MiddlewareContext($postRequest);
|
||||
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
||||
|
||||
$next = new class () implements Next {
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context;
|
||||
}
|
||||
};
|
||||
|
||||
$this->middleware->__invoke($context, $next, $stateManager);
|
||||
})->throws(RuntimeException::class, 'CSRF token validation failed. This may indicate a security threat.');
|
||||
|
||||
it('throws exception for invalid token format', function () {
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'multipart/form-data',
|
||||
]);
|
||||
|
||||
$requestBody = new RequestBody(Method::POST, $headers, '', [
|
||||
'_form_id' => 'test-form',
|
||||
'_token' => 'invalid-token', // Too short and not hex
|
||||
]);
|
||||
|
||||
$postRequest = new HttpRequest(
|
||||
method: Method::POST,
|
||||
headers: $headers,
|
||||
path: '/test',
|
||||
parsedBody: $requestBody
|
||||
);
|
||||
|
||||
$context = new MiddlewareContext($postRequest);
|
||||
$stateManager = new RequestStateManager(new WeakMap(), $postRequest);
|
||||
|
||||
$next = new class () implements Next {
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context;
|
||||
}
|
||||
};
|
||||
|
||||
$this->middleware->__invoke($context, $next, $stateManager);
|
||||
})->throws(InvalidArgumentException::class, 'Invalid CSRF token format');
|
||||
375
tests/Framework/Http/Middlewares/RateLimitMiddlewareTest.php
Normal file
375
tests/Framework/Http/Middlewares/RateLimitMiddlewareTest.php
Normal file
@@ -0,0 +1,375 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
<?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\RemovePoweredByMiddleware;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\ResponseManipulator;
|
||||
use App\Framework\Http\Status;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->responseManipulator = new ResponseManipulator();
|
||||
$this->middleware = new RemovePoweredByMiddleware($this->responseManipulator);
|
||||
|
||||
// Create a test request
|
||||
$this->request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/test'
|
||||
);
|
||||
|
||||
$this->stateManager = new RequestStateManager(new WeakMap(), $this->request);
|
||||
$this->context = new MiddlewareContext($this->request);
|
||||
});
|
||||
|
||||
it('removes X-Powered-By header from response', function () {
|
||||
// Create response with X-Powered-By header
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'text/html',
|
||||
'X-Powered-By' => 'PHP/8.2.0',
|
||||
]);
|
||||
|
||||
$response = new HttpResponse(Status::OK, $headers, 'test content');
|
||||
|
||||
// Create next handler that returns context with response
|
||||
$next = new class ($response) implements Next {
|
||||
public function __construct(private HttpResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context->withResponse($this->response);
|
||||
}
|
||||
};
|
||||
|
||||
$result = $this->middleware->__invoke(
|
||||
$this->context,
|
||||
$next,
|
||||
$this->stateManager
|
||||
);
|
||||
|
||||
expect($result->hasResponse())->toBeTrue();
|
||||
expect($result->response->headers->has('X-Powered-By'))->toBeFalse();
|
||||
expect($result->response->headers->has('Content-Type'))->toBeTrue();
|
||||
expect($result->response->headers->getFirst('Content-Type'))->toBe('text/html');
|
||||
});
|
||||
|
||||
it('leaves response unchanged when no X-Powered-By header', function () {
|
||||
// Create response without X-Powered-By header
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
|
||||
$response = new HttpResponse(Status::OK, $headers, '{"test": true}');
|
||||
|
||||
$next = new class ($response) implements Next {
|
||||
public function __construct(private HttpResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context->withResponse($this->response);
|
||||
}
|
||||
};
|
||||
|
||||
$result = $this->middleware->__invoke(
|
||||
$this->context,
|
||||
$next,
|
||||
$this->stateManager
|
||||
);
|
||||
|
||||
expect($result->hasResponse())->toBeTrue();
|
||||
expect($result->response->headers->has('X-Powered-By'))->toBeFalse();
|
||||
expect($result->response->headers->has('Content-Type'))->toBeTrue();
|
||||
expect($result->response->headers->getFirst('Content-Type'))->toBe('application/json');
|
||||
});
|
||||
|
||||
it('passes through context when no response present', function () {
|
||||
// Next handler that doesn't set a response
|
||||
$next = new class () implements Next {
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context;
|
||||
}
|
||||
};
|
||||
|
||||
$result = $this->middleware->__invoke(
|
||||
$this->context,
|
||||
$next,
|
||||
$this->stateManager
|
||||
);
|
||||
|
||||
expect($result->hasResponse())->toBeFalse();
|
||||
});
|
||||
|
||||
it('removes multiple X-Powered-By headers', function () {
|
||||
// Create response with multiple headers including X-Powered-By
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'text/html',
|
||||
'X-Powered-By' => 'PHP/8.2.0',
|
||||
'Cache-Control' => 'no-cache',
|
||||
'Server' => 'nginx',
|
||||
]);
|
||||
|
||||
$response = new HttpResponse(Status::OK, $headers, 'test content');
|
||||
|
||||
$next = new class ($response) implements Next {
|
||||
public function __construct(private HttpResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context->withResponse($this->response);
|
||||
}
|
||||
};
|
||||
|
||||
$result = $this->middleware->__invoke(
|
||||
$this->context,
|
||||
$next,
|
||||
$this->stateManager
|
||||
);
|
||||
|
||||
expect($result->hasResponse())->toBeTrue();
|
||||
expect($result->response->headers->has('X-Powered-By'))->toBeFalse();
|
||||
expect($result->response->headers->has('Content-Type'))->toBeTrue();
|
||||
expect($result->response->headers->has('Cache-Control'))->toBeTrue();
|
||||
expect($result->response->headers->has('Server'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('preserves response body and status', function () {
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Powered-By' => 'Custom-Server/1.0',
|
||||
]);
|
||||
|
||||
$response = new HttpResponse(Status::CREATED, $headers, '{"created": true}');
|
||||
|
||||
$next = new class ($response) implements Next {
|
||||
public function __construct(private HttpResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context->withResponse($this->response);
|
||||
}
|
||||
};
|
||||
|
||||
$result = $this->middleware->__invoke(
|
||||
$this->context,
|
||||
$next,
|
||||
$this->stateManager
|
||||
);
|
||||
|
||||
expect($result->hasResponse())->toBeTrue();
|
||||
expect($result->response->status)->toBe(Status::CREATED);
|
||||
expect($result->response->body)->toBe('{"created": true}');
|
||||
expect($result->response->headers->has('X-Powered-By'))->toBeFalse();
|
||||
expect($result->response->headers->getFirst('Content-Type'))->toBe('application/json');
|
||||
});
|
||||
177
tests/Framework/Http/Middlewares/RequestIdMiddlewareTest.php
Normal file
177
tests/Framework/Http/Middlewares/RequestIdMiddlewareTest.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?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\RequestIdMiddleware;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestIdGenerator;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\ResponseManipulator;
|
||||
use App\Framework\Http\Status;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->responseManipulator = new ResponseManipulator();
|
||||
|
||||
// Create real request ID generator with test secret
|
||||
$this->requestIdGenerator = new RequestIdGenerator('test-secret-for-testing');
|
||||
|
||||
$this->middleware = new RequestIdMiddleware(
|
||||
$this->requestIdGenerator,
|
||||
$this->responseManipulator
|
||||
);
|
||||
|
||||
// Create test request
|
||||
$this->request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/test'
|
||||
);
|
||||
|
||||
$this->stateManager = new RequestStateManager(new WeakMap(), $this->request);
|
||||
$this->context = new MiddlewareContext($this->request);
|
||||
});
|
||||
|
||||
it('adds request ID header to response', function () {
|
||||
// Create response
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
|
||||
$response = new HttpResponse(Status::OK, $headers, '{"test": true}');
|
||||
|
||||
// Create next handler that returns context with response
|
||||
$next = new class ($response) implements Next {
|
||||
public function __construct(private HttpResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context->withResponse($this->response);
|
||||
}
|
||||
};
|
||||
|
||||
$result = $this->middleware->__invoke(
|
||||
$this->context,
|
||||
$next,
|
||||
$this->stateManager
|
||||
);
|
||||
|
||||
expect($result->hasResponse())->toBeTrue();
|
||||
expect($result->response->headers->has('X-Request-ID'))->toBeTrue();
|
||||
expect($result->response->headers->getFirst('X-Request-ID'))->not->toBeEmpty();
|
||||
expect($result->response->headers->has('Content-Type'))->toBeTrue();
|
||||
expect($result->response->headers->getFirst('Content-Type'))->toBe('application/json');
|
||||
});
|
||||
|
||||
it('preserves existing headers when adding request ID', function () {
|
||||
// Create response with multiple headers
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'text/html',
|
||||
'Cache-Control' => 'no-cache',
|
||||
'Server' => 'test-server',
|
||||
]);
|
||||
|
||||
$response = new HttpResponse(Status::OK, $headers, '<html>test</html>');
|
||||
|
||||
$next = new class ($response) implements Next {
|
||||
public function __construct(private HttpResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context->withResponse($this->response);
|
||||
}
|
||||
};
|
||||
|
||||
$result = $this->middleware->__invoke(
|
||||
$this->context,
|
||||
$next,
|
||||
$this->stateManager
|
||||
);
|
||||
|
||||
expect($result->hasResponse())->toBeTrue();
|
||||
expect($result->response->headers->has('X-Request-ID'))->toBeTrue();
|
||||
expect($result->response->headers->getFirst('X-Request-ID'))->not->toBeEmpty();
|
||||
expect($result->response->headers->has('Content-Type'))->toBeTrue();
|
||||
expect($result->response->headers->getFirst('Content-Type'))->toBe('text/html');
|
||||
expect($result->response->headers->has('Cache-Control'))->toBeTrue();
|
||||
expect($result->response->headers->getFirst('Cache-Control'))->toBe('no-cache');
|
||||
expect($result->response->headers->has('Server'))->toBeTrue();
|
||||
expect($result->response->headers->getFirst('Server'))->toBe('test-server');
|
||||
});
|
||||
|
||||
it('passes through context when no response present', function () {
|
||||
// Next handler that doesn't set a response
|
||||
$next = new class () implements Next {
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context;
|
||||
}
|
||||
};
|
||||
|
||||
$result = $this->middleware->__invoke(
|
||||
$this->context,
|
||||
$next,
|
||||
$this->stateManager
|
||||
);
|
||||
|
||||
expect($result->hasResponse())->toBeFalse();
|
||||
});
|
||||
|
||||
it('preserves response body and status', function () {
|
||||
$headers = new Headers([
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
|
||||
$response = new HttpResponse(Status::CREATED, $headers, '{"created": true}');
|
||||
|
||||
$next = new class ($response) implements Next {
|
||||
public function __construct(private HttpResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context->withResponse($this->response);
|
||||
}
|
||||
};
|
||||
|
||||
$result = $this->middleware->__invoke(
|
||||
$this->context,
|
||||
$next,
|
||||
$this->stateManager
|
||||
);
|
||||
|
||||
expect($result->hasResponse())->toBeTrue();
|
||||
expect($result->response->status)->toBe(Status::CREATED);
|
||||
expect($result->response->body)->toBe('{"created": true}');
|
||||
expect($result->response->headers->has('X-Request-ID'))->toBeTrue();
|
||||
expect($result->response->headers->getFirst('X-Request-ID'))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('uses correct header name', function () {
|
||||
$headers = new Headers(['Content-Type' => 'text/plain']);
|
||||
$response = new HttpResponse(Status::OK, $headers, 'test');
|
||||
|
||||
$next = new class ($response) implements Next {
|
||||
public function __construct(private HttpResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context): MiddlewareContext
|
||||
{
|
||||
return $context->withResponse($this->response);
|
||||
}
|
||||
};
|
||||
|
||||
$result = $this->middleware->__invoke($this->context, $next, $this->stateManager);
|
||||
|
||||
expect($result->response->headers->has('X-Request-ID'))->toBeTrue();
|
||||
expect(RequestIdGenerator::getHeaderName())->toBe('X-Request-ID');
|
||||
});
|
||||
Reference in New Issue
Block a user