0]); // Without CSRF token should fail expect(fn() => callAction($component, 'increment', [ '_csrf_token' => 'invalid_token' ]))->toThrow(\App\Framework\Exception\Security\CsrfTokenMismatchException::class); }); it('accepts valid CSRF token', function () { $component = mountComponent('counter:test', ['count' => 0]); // Get valid CSRF token from session $session = container()->get(\App\Framework\Http\Session::class); $csrfToken = $session->getCsrfToken(); $result = callAction($component, 'increment', [ '_csrf_token' => $csrfToken ]); expect($result['state']['count'])->toBe(1); }); it('regenerates CSRF token after action', function () { $component = mountComponent('counter:test', ['count' => 0]); $session = container()->get(\App\Framework\Http\Session::class); $oldToken = $session->getCsrfToken(); callAction($component, 'increment', [ '_csrf_token' => $oldToken ]); $newToken = $session->getCsrfToken(); expect($newToken)->not->toBe($oldToken); }); }); describe('Rate Limiting', function () { it('enforces rate limits on actions', function () { $component = mountComponent('counter:test', ['count' => 0]); // Execute action multiple times rapidly for ($i = 0; $i < 10; $i++) { callAction($component, 'increment'); } // 11th request should be rate limited expect(fn() => callAction($component, 'increment')) ->toThrow(\App\Framework\Exception\Http\RateLimitExceededException::class); }); it('includes retry-after header in rate limit response', function () { $component = mountComponent('counter:test', ['count' => 0]); // Exhaust rate limit for ($i = 0; $i < 10; $i++) { callAction($component, 'increment'); } try { callAction($component, 'increment'); } catch (\App\Framework\Exception\Http\RateLimitExceededException $e) { expect($e->getRetryAfter())->toBeGreaterThan(0); } }); it('resets rate limit after cooldown period', function () { $component = mountComponent('counter:test', ['count' => 0]); // Exhaust rate limit for ($i = 0; $i < 10; $i++) { callAction($component, 'increment'); } // Wait for cooldown (simulate with cache clear in tests) $cache = container()->get(\App\Framework\Cache\Cache::class); $cache->clear(); // Should work again after cooldown $result = callAction($component, 'increment'); expect($result['state']['count'])->toBe(11); }); }); describe('Idempotency Keys', function () { it('prevents duplicate action execution with same idempotency key', function () { $component = mountComponent('counter:test', ['count' => 0]); $idempotencyKey = 'test-key-' . uniqid(); // First execution $result1 = callAction($component, 'increment', [ 'idempotency_key' => $idempotencyKey ]); expect($result1['state']['count'])->toBe(1); // Second execution with same key should return cached result $result2 = callAction($result1, 'increment', [ 'idempotency_key' => $idempotencyKey ]); // Count should still be 1 (not 2) because action was not re-executed expect($result2['state']['count'])->toBe(1); }); it('allows different actions with different idempotency keys', function () { $component = mountComponent('counter:test', ['count' => 0]); $result1 = callAction($component, 'increment', [ 'idempotency_key' => 'key-1' ]); $result2 = callAction($result1, 'increment', [ 'idempotency_key' => 'key-2' ]); expect($result2['state']['count'])->toBe(2); }); it('idempotency key expires after TTL', function () { $component = mountComponent('counter:test', ['count' => 0]); $idempotencyKey = 'test-key-expiry'; // First execution callAction($component, 'increment', [ 'idempotency_key' => $idempotencyKey ]); // Simulate TTL expiry by clearing cache $cache = container()->get(\App\Framework\Cache\Cache::class); $cache->clear(); // Should execute again after expiry $result = callAction($component, 'increment', [ 'idempotency_key' => $idempotencyKey ]); expect($result['state']['count'])->toBe(2); }); it('includes idempotency metadata in response', function () { $component = mountComponent('counter:test', ['count' => 0]); $idempotencyKey = 'test-key-metadata'; $result = callAction($component, 'increment', [ 'idempotency_key' => $idempotencyKey ]); // Check for idempotency metadata (if implemented) // This depends on your specific implementation expect($result)->toHaveKey('idempotency'); expect($result['idempotency']['key'])->toBe($idempotencyKey); expect($result['idempotency']['cached'])->toBeFalse(); }); }); describe('Combined Security Features', function () { it('enforces all security layers together', function () { $component = mountComponent('counter:test', ['count' => 0]); $session = container()->get(\App\Framework\Http\Session::class); $csrfToken = $session->getCsrfToken(); $idempotencyKey = 'combined-test-' . uniqid(); // Valid request with all security features $result = callAction($component, 'increment', [ '_csrf_token' => $csrfToken, 'idempotency_key' => $idempotencyKey ]); expect($result['state']['count'])->toBe(1); // Retry with same idempotency key but new CSRF token $newCsrfToken = $session->getCsrfToken(); $result2 = callAction($result, 'increment', [ '_csrf_token' => $newCsrfToken, 'idempotency_key' => $idempotencyKey ]); // Should return cached result due to idempotency expect($result2['state']['count'])->toBe(1); }); it('validates security in correct order: CSRF -> Rate Limit -> Idempotency', function () { $component = mountComponent('counter:test', ['count' => 0]); // Invalid CSRF should fail before rate limit check try { callAction($component, 'increment', [ '_csrf_token' => 'invalid' ]); expect(false)->toBeTrue('Should have thrown CSRF exception'); } catch (\Exception $e) { expect($e)->toBeInstanceOf(\App\Framework\Exception\Security\CsrfTokenMismatchException::class); } // Rate limit should be checked before idempotency $session = container()->get(\App\Framework\Http\Session::class); $csrfToken = $session->getCsrfToken(); // Exhaust rate limit for ($i = 0; $i < 10; $i++) { callAction($component, 'increment', [ '_csrf_token' => $session->getCsrfToken() ]); } // Even with valid idempotency key, rate limit should trigger first try { callAction($component, 'increment', [ '_csrf_token' => $session->getCsrfToken(), 'idempotency_key' => 'test-key' ]); expect(false)->toBeTrue('Should have thrown rate limit exception'); } catch (\Exception $e) { expect($e)->toBeInstanceOf(\App\Framework\Exception\Http\RateLimitExceededException::class); } }); }); });