- 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.
470 lines
16 KiB
PHP
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);
|
|
});
|
|
});
|