session = new class () implements SessionInterface { private array $tokens = []; public function __get(string $name): mixed { if ($name === 'csrf') { return new class ($this) { public function __construct(private $session) { } public function generateToken(string $formId): CsrfToken { $token = CsrfToken::generate(); $this->session->tokens[$formId] = $token->toString(); return $token; } public function validateToken(string $formId, CsrfToken $token): bool { return isset($this->session->tokens[$formId]) && hash_equals($this->session->tokens[$formId], $token->toString()); } }; } return null; } 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 regenerate(): bool { return true; } public function destroy(): void { } public function getId(): string { return 'test-session-id'; } }; $this->eventDispatcher = new ComponentEventDispatcher(); $this->handler = new LiveComponentHandler($this->eventDispatcher, $this->session); }); describe('CSRF Token Generation', function () { it('generates unique CSRF token for each component instance', function () { $renderer = new LiveComponentRenderer( $this->createMock(TemplateRenderer::class), $this->session ); $html1 = $renderer->renderWithWrapper( 'counter:instance1', '
Component 1
', ['count' => 0] ); $html2 = $renderer->renderWithWrapper( 'counter:instance2', '
Component 2
', ['count' => 0] ); // Both should have CSRF tokens expect($html1)->toContain('data-csrf-token'); expect($html2)->toContain('data-csrf-token'); // Extract tokens (they should be different) preg_match('/data-csrf-token="([^"]+)"/', $html1, $matches1); preg_match('/data-csrf-token="([^"]+)"/', $html2, $matches2); expect($matches1[1])->not->toBe($matches2[1]); }); it('generates CSRF token with correct formId pattern', function () { $componentId = 'counter:test123'; $expectedFormId = 'livecomponent:counter:test123'; // Generate token using the session $token = $this->session->csrf->generateToken($expectedFormId); expect($token)->toBeInstanceOf(CsrfToken::class); expect($token->toString())->toHaveLength(32); }); it('includes CSRF token in rendered wrapper HTML', function () { $renderer = new LiveComponentRenderer( $this->createMock(TemplateRenderer::class), $this->session ); $html = $renderer->renderWithWrapper( 'counter:csrf-test', '
Test Component
', ['count' => 42] ); // Should contain data-csrf-token attribute expect($html)->toMatch('/data-csrf-token="[a-f0-9]{32}"/'); // Should contain component ID and state expect($html)->toContain('data-live-component="counter:csrf-test"'); expect($html)->toContain('data-state'); }); }); describe('CSRF Token Validation', function () { it('validates correct CSRF token', function () { $componentId = ComponentId::fromString('counter:validation-test'); $formId = 'livecomponent:counter:validation-test'; // Generate valid token $csrfToken = $this->session->csrf->generateToken($formId); // Create ActionParameters with valid token $params = ActionParameters::fromArray([], $csrfToken); // Create test component $component = new class ($componentId) implements LiveComponentContract { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray([]); } public function increment(): ComponentData { return ComponentData::fromArray(['count' => 1]); } }; // Should not throw exception $result = $this->handler->handle($component, 'increment', $params); expect($result)->not->toBeNull(); }); it('rejects request without CSRF token', function () { $componentId = ComponentId::fromString('counter:no-token-test'); // Create ActionParameters WITHOUT token $params = ActionParameters::fromArray([]); $component = new class ($componentId) implements LiveComponentContract { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray([]); } public function action(): ComponentData { return ComponentData::fromArray([]); } }; expect(fn () => $this->handler->handle($component, 'action', $params)) ->toThrow(\InvalidArgumentException::class, 'CSRF token is required'); }); it('rejects request with invalid CSRF token', function () { $componentId = ComponentId::fromString('counter:invalid-token-test'); // Create INVALID token (not generated by session) $invalidToken = CsrfToken::fromString(bin2hex(random_bytes(16))); $params = ActionParameters::fromArray([], $invalidToken); $component = new class ($componentId) implements LiveComponentContract { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray([]); } public function action(): ComponentData { return ComponentData::fromArray([]); } }; expect(fn () => $this->handler->handle($component, 'action', $params)) ->toThrow(\RuntimeException::class, 'CSRF token validation failed'); }); it('validates CSRF token for different component instances separately', function () { // Generate token for component A $componentIdA = ComponentId::fromString('counter:instanceA'); $formIdA = 'livecomponent:counter:instanceA'; $tokenA = $this->session->csrf->generateToken($formIdA); // Try to use token A with component B (should fail) $componentIdB = ComponentId::fromString('counter:instanceB'); $params = ActionParameters::fromArray([], $tokenA); $componentB = new class ($componentIdB) implements LiveComponentContract { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray([]); } public function action(): ComponentData { return ComponentData::fromArray([]); } }; expect(fn () => $this->handler->handle($componentB, 'action', $params)) ->toThrow(\RuntimeException::class, 'CSRF token validation failed'); }); }); describe('ComponentAction CSRF Extraction', function () { it('extracts CSRF token from request body', function () { $mockRequest = new class () { public ?object $parsedBody = null; public object $headers; public function __construct() { $this->parsedBody = (object)[ 'toArray' => fn () => [ 'component_id' => 'counter:test', 'method' => 'increment', '_csrf_token' => '0123456789abcdef0123456789abcdef', 'params' => [], ], ]; $this->headers = new class () { public function getFirst(string $key): ?string { return null; } }; } }; $action = ComponentAction::fromRequest($mockRequest); expect($action->params->hasCsrfToken())->toBeTrue(); expect($action->params->getCsrfToken()->toString())->toBe('0123456789abcdef0123456789abcdef'); }); it('extracts CSRF token from X-CSRF-Token header', function () { $mockRequest = new class () { public ?object $parsedBody = null; public object $headers; public function __construct() { $this->parsedBody = (object)[ 'toArray' => fn () => [ 'component_id' => 'counter:test', 'method' => 'increment', 'params' => [], ], ]; $this->headers = new class () { public function getFirst(string $key): ?string { return $key === 'X-CSRF-Token' ? 'fedcba9876543210fedcba9876543210' : null; } }; } }; $action = ComponentAction::fromRequest($mockRequest); expect($action->params->hasCsrfToken())->toBeTrue(); expect($action->params->getCsrfToken()->toString())->toBe('fedcba9876543210fedcba9876543210'); }); it('handles missing CSRF token gracefully', function () { $mockRequest = new class () { public ?object $parsedBody = null; public object $headers; public function __construct() { $this->parsedBody = (object)[ 'toArray' => fn () => [ 'component_id' => 'counter:test', 'method' => 'increment', 'params' => [], ], ]; $this->headers = new class () { public function getFirst(string $key): ?string { return null; } }; } }; $action = ComponentAction::fromRequest($mockRequest); expect($action->params->hasCsrfToken())->toBeFalse(); expect($action->params->getCsrfToken())->toBeNull(); }); }); describe('File Upload CSRF Protection', function () { it('validates CSRF token for file uploads', function () { $componentId = ComponentId::fromString('uploader:test'); $formId = 'livecomponent:uploader:test'; // Generate valid token $csrfToken = $this->session->csrf->generateToken($formId); $params = ActionParameters::fromArray([], $csrfToken); // Mock UploadedFile $file = new class () { public string $name = 'test.txt'; public int $size = 1024; public string $tmpName = '/tmp/test'; public int $error = 0; }; // Create component that supports file upload $component = new class ($componentId) implements LiveComponentContract, \App\Framework\LiveComponents\Contracts\SupportsFileUpload { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray([]); } public function handleUpload($file, ActionParameters $params, ComponentEventDispatcher $dispatcher): ComponentData { return ComponentData::fromArray(['uploaded' => true]); } }; // Should not throw exception $result = $this->handler->handleUpload($component, $file, $params); expect($result)->not->toBeNull(); }); it('rejects file upload without CSRF token', function () { $componentId = ComponentId::fromString('uploader:no-token'); $params = ActionParameters::fromArray([]); $file = new class () { public string $name = 'test.txt'; }; $component = new class ($componentId) implements LiveComponentContract, \App\Framework\LiveComponents\Contracts\SupportsFileUpload { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray([]); } public function handleUpload($file, ActionParameters $params, ComponentEventDispatcher $dispatcher): ComponentData { return ComponentData::fromArray([]); } }; expect(fn () => $this->handler->handleUpload($component, $file, $params)) ->toThrow(\InvalidArgumentException::class, 'CSRF token is required'); }); }); describe('End-to-End CSRF Flow', function () { it('completes full CSRF flow from render to action execution', function () { // Step 1: Render component with CSRF token $renderer = new LiveComponentRenderer( $this->createMock(TemplateRenderer::class), $this->session ); $componentId = 'counter:e2e-test'; $html = $renderer->renderWithWrapper( $componentId, '
Counter: 0
', ['count' => 0] ); // Step 2: Extract CSRF token from rendered HTML preg_match('/data-csrf-token="([^"]+)"/', $html, $matches); expect($matches)->toHaveCount(2); $csrfTokenString = $matches[1]; // Step 3: Simulate client sending action with CSRF token $csrfToken = CsrfToken::fromString($csrfTokenString); $params = ActionParameters::fromArray(['amount' => 1], $csrfToken); // Step 4: Execute action with CSRF validation $component = new class (ComponentId::fromString($componentId)) implements LiveComponentContract { public function __construct(private ComponentId $id, private int $count = 0) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray(['count' => $this->count]); } public function increment(int $amount): ComponentData { $this->count += $amount; return $this->getData(); } }; $result = $this->handler->handle($component, 'increment', $params); // Step 5: Verify action executed successfully expect($result)->not->toBeNull(); expect($result->state->data['count'])->toBe(1); }); });