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
59 lines
2.2 KiB
PHP
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);
|
|
});
|
|
});
|