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
109 lines
3.8 KiB
PHP
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();
|
|
});
|
|
});
|