rateLimit('test-mutation', 3, function () { $this->executionCount++; $this->actionMessage = 'Action executed.'; $this->actionType = 'success'; }); } public function export() { return $this->rateLimit('test-export', 2, function () { $this->executionCount++; return 'export-data'; }); } public function destroy(): void { $this->rateLimit('test-deletion', 1, function () { $this->executionCount++; $this->actionMessage = 'Deleted.'; $this->actionType = 'success'; }); } public function render(): string { return <<<'HTML'
Executions: {{ $executionCount }} Message: {{ $actionMessage }} Type: {{ $actionType }}
HTML; } } /** * Component without actionMessage/actionType (session flash fallback). */ class RateLimitedSessionComponent extends Component { use HasRateLimiting; public int $executionCount = 0; public function mutate(): void { $this->rateLimit('test-session-mutation', 2, function () { $this->executionCount++; }); } public function render(): string { return <<<'HTML'
Executions: {{ $executionCount }}
HTML; } } // ============================================================================= // Rate Limiting Enforcement Tests // ============================================================================= beforeEach(function () { RateLimiter::clear('test-mutation:1'); RateLimiter::clear('test-export:1'); RateLimiter::clear('test-deletion:1'); RateLimiter::clear('test-session-mutation:1'); }); describe('Rate limiting enforcement', function () { it('allows actions within the rate limit', function () { $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); Livewire::test(RateLimitedActionComponent::class) ->call('mutate') ->assertSet('executionCount', 1) ->assertSet('actionMessage', 'Action executed.') ->assertSet('actionType', 'success') ->call('mutate') ->assertSet('executionCount', 2) ->call('mutate') ->assertSet('executionCount', 3); }); it('blocks actions exceeding the rate limit', function () { $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); $component = Livewire::test(RateLimitedActionComponent::class); // Execute up to the limit $component->call('mutate') ->call('mutate') ->call('mutate') ->assertSet('executionCount', 3); // Fourth call should be blocked $component->call('mutate') ->assertSet('executionCount', 3) // Not incremented ->assertSet('actionType', 'error') ->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests')); }); it('blocks export actions exceeding the rate limit', function () { $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); $component = Livewire::test(RateLimitedActionComponent::class); // Execute up to the limit (2 for exports) $component->call('export') ->assertSet('executionCount', 1) ->call('export') ->assertSet('executionCount', 2); // Third call should be blocked $component->call('export') ->assertSet('executionCount', 2) // Not incremented ->assertSet('actionType', 'error'); }); it('enforces strict limits on destructive actions', function () { $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); $component = Livewire::test(RateLimitedActionComponent::class); // Execute up to the limit (1 for deletions) $component->call('destroy') ->assertSet('executionCount', 1) ->assertSet('actionMessage', 'Deleted.'); // Second call should be blocked $component->call('destroy') ->assertSet('executionCount', 1) // Not incremented ->assertSet('actionType', 'error') ->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests')); }); }); // ============================================================================= // Rate Limit Key Scoping Tests // ============================================================================= describe('Rate limit key scoping', function () { it('scopes rate limits per user', function () { // User 1 exhausts their limit $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); RateLimiter::clear('test-deletion:1'); RateLimiter::clear('test-deletion:2'); $component1 = Livewire::test(RateLimitedActionComponent::class); $component1->call('destroy') ->assertSet('executionCount', 1); $component1->call('destroy') ->assertSet('executionCount', 1); // Blocked // User 2 should not be affected $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 2])); Livewire::test(RateLimitedActionComponent::class) ->call('destroy') ->assertSet('executionCount', 1) // User 2's own count ->assertSet('actionMessage', 'Deleted.'); }); it('uses separate limits for different action types', function () { $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); $component = Livewire::test(RateLimitedActionComponent::class); // Exhaust deletion limit (1) $component->call('destroy') ->assertSet('executionCount', 1); // Mutation limit (3) should still be available $component->call('mutate') ->assertSet('executionCount', 2) ->assertSet('actionMessage', 'Action executed.') ->assertSet('actionType', 'success'); }); }); // ============================================================================= // User Feedback Tests // ============================================================================= describe('User feedback when rate limited', function () { it('shows error message with retry time via actionMessage', function () { $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); $component = Livewire::test(RateLimitedActionComponent::class); // Exhaust limit $component->call('destroy'); // Next call should show error with seconds $component->call('destroy') ->assertSet('actionType', 'error') ->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests') && str_contains($msg, 'seconds')); }); it('flashes error to session when component lacks actionMessage property', function () { $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); $component = Livewire::test(RateLimitedSessionComponent::class); // Exhaust limit (2) $component->call('mutate')->call('mutate'); // Third call should be blocked and flash to session $component->call('mutate') ->assertSet('executionCount', 2); // Not incremented }); }); // ============================================================================= // Rate Limit Reset Tests // ============================================================================= describe('Rate limit reset', function () { it('allows actions after rate limit window resets', function () { $this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1])); $component = Livewire::test(RateLimitedActionComponent::class); // Exhaust limit $component->call('destroy') ->assertSet('executionCount', 1); $component->call('destroy') ->assertSet('executionCount', 1); // Blocked // Clear the rate limiter (simulates window expiry) RateLimiter::clear('test-deletion:1'); // Should work again $component->call('destroy') ->assertSet('executionCount', 2) ->assertSet('actionMessage', 'Deleted.') ->assertSet('actionType', 'success'); }); });