feat(webhook): add WebhookController — store, verify, fire event, return 200

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-12 14:23:32 +00:00
parent 39ede84d0e
commit a1de171871
2 changed files with 187 additions and 0 deletions

View file

@ -0,0 +1,49 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Webhook;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WebhookController
{
public function handle(Request $request, string $source): JsonResponse
{
$signatureValid = null;
// Check for registered verifier
$verifier = app()->bound("webhook.verifier.{$source}")
? app("webhook.verifier.{$source}")
: null;
if ($verifier instanceof WebhookVerifier) {
$secret = config("webhook.secrets.{$source}", '');
$signatureValid = $verifier->verify($request, $secret);
}
// Extract event type from common payload patterns
$payload = $request->json()->all();
$eventType = $payload['type'] ?? $payload['event_type'] ?? $payload['event'] ?? null;
$call = WebhookCall::create([
'source' => $source,
'event_type' => is_string($eventType) ? $eventType : null,
'headers' => $request->headers->all(),
'payload' => $payload,
'signature_valid' => $signatureValid,
]);
event(new WebhookReceived($source, $call->id));
return response()->json(['ok' => true]);
}
}

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Core\Tests\Feature;
use Core\Tests\TestCase;
use Core\Webhook\WebhookCall;
use Core\Webhook\WebhookReceived;
use Core\Webhook\WebhookVerifier;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
class WebhookControllerTest extends TestCase
{
use RefreshDatabase;
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__.'/../../database/migrations');
}
protected function defineRoutes($router): void
{
$router->post('/webhooks/{source}', [\Core\Webhook\WebhookController::class, 'handle'])
->where('source', '[a-z0-9\-]+');
}
public function test_stores_webhook_call(): void
{
$response = $this->postJson('/webhooks/altum-biolinks', [
'type' => 'link.created',
'data' => ['id' => 42],
]);
$response->assertOk();
$call = WebhookCall::first();
$this->assertNotNull($call);
$this->assertSame('altum-biolinks', $call->source);
$this->assertSame(['type' => 'link.created', 'data' => ['id' => 42]], $call->payload);
$this->assertNull($call->processed_at);
}
public function test_captures_headers(): void
{
$this->postJson('/webhooks/stripe', ['type' => 'invoice.paid'], [
'Webhook-Id' => 'msg_abc123',
'Webhook-Timestamp' => '1234567890',
]);
$call = WebhookCall::first();
$this->assertArrayHasKey('webhook-id', $call->headers);
}
public function test_fires_webhook_received_event(): void
{
Event::fake([WebhookReceived::class]);
$this->postJson('/webhooks/altum-biolinks', ['type' => 'test']);
Event::assertDispatched(WebhookReceived::class, function ($event) {
return $event->source === 'altum-biolinks' && ! empty($event->callId);
});
}
public function test_extracts_event_type_from_payload(): void
{
$this->postJson('/webhooks/stripe', ['type' => 'invoice.paid']);
$this->assertSame('invoice.paid', WebhookCall::first()->event_type);
}
public function test_handles_empty_payload(): void
{
$response = $this->postJson('/webhooks/test', []);
$response->assertOk();
$this->assertCount(1, WebhookCall::all());
}
public function test_signature_valid_null_when_no_verifier(): void
{
$this->postJson('/webhooks/unknown-source', ['data' => 1]);
$this->assertNull(WebhookCall::first()->signature_valid);
}
public function test_signature_verified_when_verifier_registered(): void
{
$verifier = new class implements WebhookVerifier
{
public function verify(Request $request, string $secret): bool
{
return $request->header('webhook-signature') === 'valid';
}
};
$this->app->instance('webhook.verifier.test-source', $verifier);
$this->app['config']->set('webhook.secrets.test-source', 'test-secret');
$this->postJson('/webhooks/test-source', ['data' => 1], [
'Webhook-Signature' => 'valid',
]);
$this->assertTrue(WebhookCall::first()->signature_valid);
}
public function test_signature_invalid_still_stores_call(): void
{
$verifier = new class implements WebhookVerifier
{
public function verify(Request $request, string $secret): bool
{
return false;
}
};
$this->app->instance('webhook.verifier.test-source', $verifier);
$this->app['config']->set('webhook.secrets.test-source', 'test-secret');
$this->postJson('/webhooks/test-source', ['data' => 1]);
$call = WebhookCall::first();
$this->assertNotNull($call);
$this->assertFalse($call->signature_valid);
}
public function test_source_is_sanitised(): void
{
$response = $this->postJson('/webhooks/valid-source-123', ['data' => 1]);
$response->assertOk();
$response = $this->postJson('/webhooks/invalid source!', ['data' => 1]);
$response->assertStatus(404);
}
}