agent/php/tests/Feature/Mod/Api/RateLimitFoundationTest.php
Snider 5385385314 feat(agent/api): RFC foundation — API keys, webhooks, rate limiting, docs split
Foundation slice for Mantis #844 php/Mod/Api RFC implementation:

* New php/Mod/Api/ package: Boot, Controllers, Documentation, Jobs,
  Middleware, Models, RateLimit, Routes, Services
* Models: ApiKey, WebhookEndpoint, WebhookDelivery
* WebhookService::dispatch() with DB::transaction + afterCommit
* DeliverWebhookJob with retry/backoff
* WebhookSignature with timing-safe verification + 5-minute tolerance +
  dual-secret rotation support
* Sliding-window rate limiter in RateLimit/RateLimitService.php
* AuthenticateApiKey middleware: hk_ prefix + Sanctum fallback
* DocsController / DocumentationController split
* 3 root migrations: api_keys, webhook_endpoints, webhook_deliveries
* Foundation tests under php/tests/Feature/Mod/Api/
* FOLLOWUP.md tracks remaining RFC scope

php -l clean across 21 PHP files. Pest unrunnable in sandbox (no vendor/).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=844
2026-04-25 21:01:54 +01:00

59 lines
2.2 KiB
PHP

<?php
declare(strict_types=1);
use Carbon\Carbon;
use Core\Mod\Agentic\Mod\Api\RateLimit\RateLimit;
use Core\Mod\Agentic\Mod\Api\RateLimit\RateLimitService;
use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\Repository;
beforeEach(function (): void {
Carbon::setTestNow(Carbon::parse('2026-04-25 12:00:00'));
$this->service = new RateLimitService(new Repository(new ArrayStore));
});
afterEach(function (): void {
Carbon::setTestNow();
});
describe('RateLimit foundation', function () {
it('tracks hits separately from limit checks using a sliding window', function (): void {
$rateLimit = new RateLimit(limit: 2, window: 60);
$bucket = $this->service->buildEndpointKey('api_key:demo', 'docs.index');
$checked = $this->service->checkLimit('demo', 'docs.index', $rateLimit);
$recorded = $this->service->recordHit('demo', 'docs.index', $rateLimit);
expect($checked->allowed)->toBeTrue()
->and($checked->remaining)->toBe(2)
->and($recorded->allowed)->toBeTrue()
->and($recorded->remaining)->toBe(1)
->and($this->service->attempts($bucket, 60))->toBe(1);
});
it('denies requests once the current window is full', function (): void {
$rateLimit = new RateLimit(limit: 2, window: 60);
$this->service->recordHit('demo', 'docs.index', $rateLimit);
$this->service->recordHit('demo', 'docs.index', $rateLimit);
$result = $this->service->checkLimit('demo', 'docs.index', $rateLimit);
expect($result->allowed)->toBeFalse()
->and($result->remaining)->toBe(0)
->and($result->retryAfter)->toBe(60);
});
it('expires old hits and rejects invalid limit definitions', function (): void {
$rateLimit = new RateLimit(limit: 2, window: 60);
$this->service->recordHit('demo', 'docs.index', $rateLimit);
Carbon::setTestNow(Carbon::parse('2026-04-25 12:01:01'));
expect($this->service->checkLimit('demo', 'docs.index', $rateLimit)->allowed)->toBeTrue();
expect(fn () => $this->service->checkLimit('demo', 'docs.index', new RateLimit(limit: 0, window: 60)))
->toThrow(InvalidArgumentException::class);
});
});