Files
michaelschiemer/tests/Feature/Framework/LiveComponents/SecurityComprehensiveTest.php
2025-11-24 21:28:25 +01:00

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;
}
}