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