Some checks failed
Deploy Application / deploy (push) Has been cancelled
277 lines
8.5 KiB
PHP
277 lines
8.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Framework\LiveComponents;
|
|
|
|
use App\Application\LiveComponents\Counter\CounterComponent;
|
|
use App\Framework\LiveComponents\Attributes\Action;
|
|
use App\Framework\LiveComponents\Attributes\LiveComponent;
|
|
use App\Framework\LiveComponents\Attributes\RequiresPermission;
|
|
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
|
use App\Framework\LiveComponents\Exceptions\RateLimitExceededException;
|
|
use App\Framework\LiveComponents\LiveComponentHandler;
|
|
use App\Application\LiveComponents\LiveComponentState;
|
|
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
|
use Tests\Feature\Framework\LiveComponents\TestHarness\LiveComponentTestCase;
|
|
|
|
/**
|
|
* Comprehensive Security Tests for LiveComponents
|
|
*
|
|
* Tests all security features:
|
|
* - CSRF Protection
|
|
* - Rate Limiting
|
|
* - Idempotency
|
|
* - Action Allow-List
|
|
* - Authorization
|
|
*/
|
|
class SecurityComprehensiveTest extends LiveComponentTestCase
|
|
{
|
|
public function test_csrf_protection_requires_valid_token(): void
|
|
{
|
|
$this->mount('counter:test', ['count' => 0]);
|
|
|
|
// Try to call action without CSRF token
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('CSRF token is required');
|
|
|
|
$this->call('increment', []);
|
|
}
|
|
|
|
public function test_csrf_protection_rejects_invalid_token(): void
|
|
{
|
|
$this->mount('counter:test', ['count' => 0]);
|
|
|
|
// Try with invalid token
|
|
$this->expectException(\RuntimeException::class);
|
|
$this->expectExceptionMessage('CSRF token validation failed');
|
|
|
|
$params = ActionParameters::fromArray([
|
|
'csrf_token' => 'invalid-token-12345678901234567890123456789012'
|
|
]);
|
|
|
|
$component = $this->getComponent();
|
|
if ($component === null) {
|
|
$this->fail('Component not mounted');
|
|
}
|
|
|
|
$this->handler->handle(
|
|
$component,
|
|
'increment',
|
|
$params
|
|
);
|
|
}
|
|
|
|
public function test_rate_limiting_enforces_limits(): void
|
|
{
|
|
$this->mount('counter:test', ['count' => 0]);
|
|
|
|
// Get valid CSRF token
|
|
$component = $this->getComponent();
|
|
if ($component === null) {
|
|
$this->fail('Component not mounted');
|
|
}
|
|
$csrfToken = $this->registry->generateCsrfToken($component->id);
|
|
|
|
// Execute action multiple times (assuming default limit is 10)
|
|
for ($i = 0; $i < 10; $i++) {
|
|
$params = ActionParameters::fromArray([
|
|
'csrf_token' => $csrfToken->toString()
|
|
]);
|
|
|
|
try {
|
|
$component = $this->getComponent();
|
|
if ($component === null) {
|
|
$this->fail('Component not mounted');
|
|
}
|
|
|
|
$this->handler->handle(
|
|
$component,
|
|
'increment',
|
|
$params
|
|
);
|
|
|
|
// Re-mount to get updated component
|
|
$this->mount('counter:test', $this->getState());
|
|
} catch (RateLimitExceededException $e) {
|
|
// Expected after limit
|
|
if ($i >= 9) {
|
|
$this->assertInstanceOf(RateLimitExceededException::class, $e);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 11th request should be rate limited
|
|
$this->expectException(RateLimitExceededException::class);
|
|
|
|
$params = ActionParameters::fromArray([
|
|
'csrf_token' => $csrfToken->toString()
|
|
]);
|
|
|
|
$component = $this->getComponent();
|
|
if ($component === null) {
|
|
$this->fail('Component not mounted');
|
|
}
|
|
|
|
$this->handler->handle(
|
|
$component,
|
|
'increment',
|
|
$params
|
|
);
|
|
}
|
|
|
|
public function test_action_allow_list_only_allows_marked_actions(): void
|
|
{
|
|
$component = new TestComponentWithActions(
|
|
ComponentId::create('test', 'demo'),
|
|
LiveComponentState::empty()
|
|
);
|
|
|
|
// Action with #[Action] should work
|
|
$csrfToken = $this->registry->generateCsrfToken($component->id);
|
|
$params = ActionParameters::fromArray([
|
|
'csrf_token' => $csrfToken->toString()
|
|
]);
|
|
|
|
$result = $this->handler->handle($component, 'allowedAction', $params);
|
|
$this->assertNotNull($result);
|
|
|
|
// Action without #[Action] should fail
|
|
$this->expectException(\BadMethodCallException::class);
|
|
$this->expectExceptionMessage('is not marked as an action');
|
|
|
|
$this->handler->handle($component, 'notAllowedAction', $params);
|
|
}
|
|
|
|
public function test_reserved_methods_cannot_be_called_as_actions(): void
|
|
{
|
|
$component = new TestComponentWithActions(
|
|
ComponentId::create('test', 'demo'),
|
|
LiveComponentState::empty()
|
|
);
|
|
|
|
$csrfToken = $this->registry->generateCsrfToken($component->id);
|
|
$params = ActionParameters::fromArray([
|
|
'csrf_token' => $csrfToken->toString()
|
|
]);
|
|
|
|
// Try to call reserved method
|
|
$this->expectException(\BadMethodCallException::class);
|
|
$this->expectExceptionMessage('reserved method');
|
|
|
|
$this->handler->handle($component, 'mount', $params);
|
|
}
|
|
|
|
public function test_authorization_requires_permission(): void
|
|
{
|
|
// This test would require a component with #[RequiresPermission]
|
|
// and proper auth setup - skipping for now as it requires more setup
|
|
$this->markTestSkipped('Requires authentication setup');
|
|
}
|
|
|
|
public function test_idempotency_prevents_duplicate_execution(): void
|
|
{
|
|
$this->mount('counter:test', ['count' => 0]);
|
|
|
|
$component = $this->getComponent();
|
|
if ($component === null) {
|
|
$this->fail('Component not mounted');
|
|
}
|
|
|
|
$csrfToken = $this->registry->generateCsrfToken($component->id);
|
|
$idempotencyKey = 'test-key-' . uniqid();
|
|
|
|
// First execution
|
|
$params1 = ActionParameters::fromArray([
|
|
'csrf_token' => $csrfToken->toString(),
|
|
'idempotency_key' => $idempotencyKey
|
|
]);
|
|
|
|
$result1 = $this->handler->handle(
|
|
$component,
|
|
'increment',
|
|
$params1
|
|
);
|
|
|
|
$this->assertEquals(1, $result1->state->data['count'] ?? 0);
|
|
|
|
// Second execution with same key should return cached result
|
|
$params2 = ActionParameters::fromArray([
|
|
'csrf_token' => $csrfToken->toString(),
|
|
'idempotency_key' => $idempotencyKey
|
|
]);
|
|
|
|
$result2 = $this->handler->handle(
|
|
$component,
|
|
'increment',
|
|
$params2
|
|
);
|
|
|
|
// Count should still be 1 (not 2) because action was cached
|
|
$this->assertEquals(1, $result2->state->data['count'] ?? 0);
|
|
}
|
|
|
|
public function test_security_order_csrf_before_rate_limit(): void
|
|
{
|
|
$this->mount('counter:test', ['count' => 0]);
|
|
|
|
// Invalid CSRF should fail before rate limit check
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('CSRF token is required');
|
|
|
|
$params = ActionParameters::fromArray([]);
|
|
$component = $this->getComponent();
|
|
if ($component === null) {
|
|
$this->fail('Component not mounted');
|
|
}
|
|
|
|
$this->handler->handle(
|
|
$component,
|
|
'increment',
|
|
$params
|
|
);
|
|
}
|
|
}
|
|
|
|
// Test component with actions
|
|
#[LiveComponent('test-with-actions')]
|
|
final readonly class TestComponentWithActions implements LiveComponentContract
|
|
{
|
|
public function __construct(
|
|
public ComponentId $id,
|
|
public LiveComponentState $state
|
|
) {
|
|
}
|
|
|
|
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
|
|
{
|
|
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData(
|
|
templatePath: 'test',
|
|
data: []
|
|
);
|
|
}
|
|
|
|
#[Action]
|
|
public function allowedAction(): LiveComponentState
|
|
{
|
|
return $this->state;
|
|
}
|
|
|
|
// No #[Action] attribute - should not be callable
|
|
public function notAllowedAction(): LiveComponentState
|
|
{
|
|
return $this->state;
|
|
}
|
|
|
|
// Reserved method - should not be callable
|
|
public function mount(): LiveComponentState
|
|
{
|
|
return $this->state;
|
|
}
|
|
}
|
|
|
|
|
|
|