agent/php/tests/Feature/Mod/Api/WebhookFoundationTest.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

109 lines
3.8 KiB
PHP

<?php
declare(strict_types=1);
use Carbon\Carbon;
use Core\Mod\Agentic\Mod\Api\Boot as ApiBoot;
use Core\Mod\Agentic\Mod\Api\Jobs\DeliverWebhookJob;
use Core\Mod\Agentic\Mod\Api\Models\WebhookDelivery;
use Core\Mod\Agentic\Mod\Api\Models\WebhookEndpoint;
use Core\Mod\Agentic\Mod\Api\Services\WebhookService;
use Core\Mod\Agentic\Mod\Api\Services\WebhookSignature;
use Illuminate\Support\Facades\Queue;
beforeEach(function (): void {
$this->app->register(ApiBoot::class);
$this->workspace = createWorkspace();
});
afterEach(function (): void {
Carbon::setTestNow();
});
describe('Webhook foundation', function () {
it('queues deliveries after the transaction commits', function (): void {
Queue::fake();
WebhookEndpoint::createForWorkspace(
$this->workspace->id,
'https://example.com/webhooks/core',
['mcp.tool.executed'],
);
$deliveries = app(WebhookService::class)->dispatch(
$this->workspace->id,
'mcp.tool.executed',
['tool' => 'brain_recall'],
);
expect($deliveries)->toHaveCount(1)
->and(WebhookDelivery::count())->toBe(1);
Queue::assertPushed(DeliverWebhookJob::class, function (DeliverWebhookJob $job): bool {
return $job->delivery->event_type === 'mcp.tool.executed';
});
});
it('accepts both current and previous secrets during the rotation window', function (): void {
$endpoint = WebhookEndpoint::createForWorkspace(
$this->workspace->id,
'https://example.com/webhooks/core',
['mcp.tool.executed'],
);
$payload = '{"tool":"brain_recall"}';
$timestamp = time();
$signer = app(WebhookSignature::class);
$oldSecret = $endpoint->getRawOriginal('secret');
$oldSignature = $signer->sign($payload, (string) $oldSecret, $timestamp);
$newSecret = $endpoint->rotateSecret(300);
$endpoint->refresh();
expect($endpoint->verifySignature($payload, $oldSignature, $timestamp))->toBeTrue()
->and($endpoint->verifySignature($payload, $signer->sign($payload, $newSecret, $timestamp), $timestamp))->toBeTrue();
$endpoint->update(['previous_secret_expires_at' => now()->subSecond()]);
expect($endpoint->fresh()->verifySignature($payload, $oldSignature, $timestamp))->toBeFalse();
});
it('auto-disables endpoints after ten consecutive failures', function (): void {
$endpoint = WebhookEndpoint::createForWorkspace(
$this->workspace->id,
'https://example.com/webhooks/core',
['mcp.tool.executed'],
);
foreach (range(1, 10) as $attempt) {
$endpoint->recordFailure();
}
expect($endpoint->fresh()->failure_count)->toBe(10)
->and($endpoint->fresh()->is_active)->toBeFalse()
->and($endpoint->fresh()->disabled_at)->not->toBeNull();
});
it('schedules the first retry five minutes after a failed delivery', function (): void {
Carbon::setTestNow(Carbon::parse('2026-04-25 12:00:00'));
$endpoint = WebhookEndpoint::createForWorkspace(
$this->workspace->id,
'https://example.com/webhooks/core',
['mcp.tool.executed'],
);
$delivery = WebhookDelivery::createForEvent(
$endpoint,
'mcp.tool.executed',
['tool' => 'brain_recall'],
$this->workspace->id,
);
$delivery->markFailed(500, 'Upstream timeout');
expect($delivery->fresh()->attempt)->toBe(2)
->and($delivery->fresh()->status)->toBe(WebhookDelivery::STATUS_RETRYING)
->and($delivery->fresh()->next_retry_at?->equalTo(now()->addMinutes(5)))->toBeTrue();
});
});