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');