From a1de171871c875744b864238586d60a097d830c4 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 12 Mar 2026 14:23:32 +0000 Subject: [PATCH] =?UTF-8?q?feat(webhook):=20add=20WebhookController=20?= =?UTF-8?q?=E2=80=94=20store,=20verify,=20fire=20event,=20return=20200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/Core/Webhook/WebhookController.php | 49 +++++++++ tests/Feature/WebhookControllerTest.php | 138 ++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 src/Core/Webhook/WebhookController.php create mode 100644 tests/Feature/WebhookControllerTest.php diff --git a/src/Core/Webhook/WebhookController.php b/src/Core/Webhook/WebhookController.php new file mode 100644 index 0000000..d133495 --- /dev/null +++ b/src/Core/Webhook/WebhookController.php @@ -0,0 +1,49 @@ +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]); + } +} diff --git a/tests/Feature/WebhookControllerTest.php b/tests/Feature/WebhookControllerTest.php new file mode 100644 index 0000000..3f68725 --- /dev/null +++ b/tests/Feature/WebhookControllerTest.php @@ -0,0 +1,138 @@ +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); + } +}