Files
michaelschiemer/tests/Feature/Framework/LiveComponents/CsrfIntegrationTest.php
Michael Schiemer a93a086ee4 refactor(di): add analysis components for dependency parsing and resolution
- Introduce `CodeParser` to extract dependencies from `container->get()` calls and `return new` statements.
- Add `DependencyPathAnalyzer` for recursive analysis of dependency paths with cycle detection.
- Implement `InitializerFinder` to locate initializers based on naming conventions.
- Include `InterfaceResolver` to determine interface implementations using introspection and initializers.
- Add `NamespaceResolver` for resolving class names from use statements and namespaces.
- Introduce `ReturnTypeAnalyzer` for method and closure return type analysis.
2025-11-03 22:38:06 +01:00

470 lines
16 KiB
PHP

<?php
declare(strict_types=1);
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\Session\Session;
use App\Framework\Http\Session\SessionId;
use App\Framework\LiveComponents\ComponentEventDispatcher;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\LiveComponentHandler;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentAction;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Random\SecureRandomGenerator;
use App\Framework\Security\CsrfToken;
use App\Framework\Security\CsrfTokenGenerator;
use App\Framework\View\LiveComponentRenderer;
use App\Framework\View\Loading\TemplateLoader;
use App\Framework\View\TemplateProcessor;
beforeEach(function () {
$this->session = Session::fromArray(
SessionId::fromString(str_repeat('a', 32)),
new SystemClock(),
new CsrfTokenGenerator(new SecureRandomGenerator()),
[]
);
$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(TemplateLoader::class),
$this->createMock(TemplateProcessor::class),
$this->session
);
$html1 = $renderer->renderWithWrapper(
'counter:instance1',
'<div>Component 1</div>',
['count' => 0]
);
$html2 = $renderer->renderWithWrapper(
'counter:instance2',
'<div>Component 2</div>',
['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(TemplateLoader::class),
$this->createMock(TemplateProcessor::class),
$this->session
);
$html = $renderer->renderWithWrapper(
'counter:csrf-test',
'<div>Test Component</div>',
['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(TemplateLoader::class),
$this->createMock(TemplateProcessor::class),
$this->session
);
$componentId = 'counter:e2e-test';
$html = $renderer->renderWithWrapper(
$componentId,
'<div>Counter: 0</div>',
['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);
});
});