Implement webhook inspector

This commit is contained in:
2026-08-04 17:09:39 +02:00
parent b021836c14
commit 75d8b25b64
57 changed files with 5110 additions and 230 deletions
+236
View File
@@ -0,0 +1,236 @@
<?php
namespace Tests\Feature;
use App\Actions\ConfigureWebhookEndpointAction;
use App\Events\WebhookRequestReceived;
use App\Models\User;
use App\Models\WebhookEndpoint;
use App\Models\WebhookRequest;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
class WebhookEndpointTest extends TestCase
{
use LazilyRefreshDatabase;
public function test_a_guest_can_create_an_anonymous_endpoint(): void
{
$response = $this->post(route('inspectors.store'));
$endpoint = WebhookEndpoint::query()->latest()->firstOrFail();
$response->assertRedirect(route('inspect.show', ['token' => $endpoint->token]));
$this->assertTrue($endpoint->is_public);
$this->assertNull($endpoint->user_id);
$this->assertNotEmpty($endpoint->token_hash);
$this->assertTrue($endpoint->expires_at->isFuture());
}
public function test_all_supported_http_methods_are_captured(): void
{
$endpoint = WebhookEndpoint::factory()->create();
Event::fake();
foreach (['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as $method) {
$response = $this->call($method, $this->hookUrl($endpoint), [], [], [], [
'CONTENT_TYPE' => 'application/json',
], '{"method":"'.$method.'"}');
$response->assertOk()->assertContent('{}');
}
$this->assertSame(6, $endpoint->requests()->count());
$this->assertSame(
['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
$endpoint->requests()->oldest('received_at')->pluck('method')->all(),
);
Event::assertDispatched(WebhookRequestReceived::class, 6);
}
public function test_request_metadata_and_json_payload_are_stored(): void
{
$endpoint = WebhookEndpoint::factory()->create();
$response = $this->withHeaders([
'X-Request-ID' => 'req-123',
'User-Agent' => 'Inspector Test Client',
])->postJson($this->hookUrl($endpoint).'?source=checkout&source=retry', [
'event' => 'payment.created',
'amount' => 1299,
]);
$response->assertOk();
$webhookRequest = $endpoint->requests()->firstOrFail();
$this->assertSame('POST', $webhookRequest->method);
$this->assertStringContainsString('source=checkout', $webhookRequest->request_uri);
$this->assertSame(['source' => 'retry'], $webhookRequest->query_parameters);
$this->assertSame('payment.created', $webhookRequest->json_payload['event']);
$this->assertSame(1299, $webhookRequest->json_payload['amount']);
$this->assertSame('application/json', $webhookRequest->content_type);
$this->assertSame(strlen((string) $webhookRequest->body), $webhookRequest->body_size);
$this->assertSame('req-123', $webhookRequest->headers['x-request-id'][0]);
$this->assertSame('Inspector Test Client', $webhookRequest->user_agent);
$this->assertNotNull($endpoint->fresh()->last_request_at);
}
public function test_invalid_json_and_form_data_are_retained_without_a_parsed_payload(): void
{
$endpoint = WebhookEndpoint::factory()->create();
$this->call('POST', $this->hookUrl($endpoint), ['name' => 'Ada'], [], [], [
'CONTENT_TYPE' => 'application/x-www-form-urlencoded',
]);
$this->call('POST', $this->hookUrl($endpoint), [], [], [], [
'CONTENT_TYPE' => 'application/json',
], '{invalid-json');
$requests = $endpoint->requests()->oldest('received_at')->get();
$this->assertCount(2, $requests);
$this->assertNull($requests[0]->json_payload);
$this->assertSame('application/x-www-form-urlencoded', $requests[0]->content_type);
$this->assertNull($requests[1]->json_payload);
$this->assertSame('{invalid-json', $requests[1]->body);
}
public function test_a_private_endpoint_returns_its_configured_response(): void
{
$user = User::factory()->create();
$endpoint = WebhookEndpoint::factory()->ownedBy($user)->create();
app(ConfigureWebhookEndpointAction::class)->handle(
$user,
$endpoint,
202,
[
'Content-Type' => 'text/plain',
'X-Inspector' => 'accepted',
],
'queued',
);
$response = $this->post($this->hookUrl($endpoint), ['ignored' => true]);
$response->assertStatus(202)
->assertContent('queued')
->assertHeader('Content-Type', 'text/plain; charset=UTF-8')
->assertHeader('X-Inspector', 'accepted');
}
public function test_unknown_expired_and_disabled_endpoints_return_not_found(): void
{
$expiredEndpoint = WebhookEndpoint::factory()->expired()->create();
$disabledEndpoint = WebhookEndpoint::factory()->inactive()->create();
$this->post($this->hookUrl($expiredEndpoint))->assertNotFound();
$this->post($this->hookUrl($disabledEndpoint))->assertNotFound();
$this->post('/hook/does-not-exist')->assertNotFound();
}
public function test_requests_larger_than_the_limit_return_413_and_are_not_saved(): void
{
$endpoint = WebhookEndpoint::factory()->create();
$body = str_repeat('x', (int) config('webhooks.max_body_bytes') + 1);
$response = $this->call('POST', $this->hookUrl($endpoint), [], [], [], [
'CONTENT_TYPE' => 'text/plain',
], $body);
$response->assertStatus(413);
$this->assertSame(0, $endpoint->requests()->count());
}
public function test_the_webhook_rate_limit_returns_429(): void
{
config(['webhooks.rate_limit_per_minute' => 1]);
$endpoint = WebhookEndpoint::factory()->create();
RateLimiter::clear('webhook:'.$this->token($endpoint).':127.0.0.1');
$this->post($this->hookUrl($endpoint))->assertOk();
$this->post($this->hookUrl($endpoint))->assertStatus(429);
}
public function test_response_headers_reject_header_injection(): void
{
$this->expectException(ValidationException::class);
$user = User::factory()->create();
$endpoint = WebhookEndpoint::factory()->ownedBy($user)->create();
app(ConfigureWebhookEndpointAction::class)->handle(
$user,
$endpoint,
200,
['X-Bad' => "ok\r\nX-Injected: true"],
'{}',
);
}
public function test_response_body_size_is_limited_in_bytes(): void
{
config(['webhooks.max_response_body_bytes' => 3]);
$this->expectException(ValidationException::class);
$user = User::factory()->create();
$endpoint = WebhookEndpoint::factory()->ownedBy($user)->create();
app(ConfigureWebhookEndpointAction::class)->handle(
$user,
$endpoint,
200,
['Content-Type' => 'text/plain'],
'ää',
);
}
public function test_broadcast_event_exposes_only_ids_and_uses_the_correct_channel_visibility(): void
{
$publicEvent = new WebhookRequestReceived('public-endpoint', 'request-1', true);
$privateEvent = new WebhookRequestReceived('private-endpoint', 'request-2', false);
$publicChannel = $publicEvent->broadcastOn()[0];
$privateChannel = $privateEvent->broadcastOn()[0];
$this->assertInstanceOf(Channel::class, $publicChannel);
$this->assertNotInstanceOf(PrivateChannel::class, $publicChannel);
$this->assertSame('webhooks.public-endpoint', $publicChannel->name);
$this->assertInstanceOf(PrivateChannel::class, $privateChannel);
$this->assertSame('private-webhooks.private-endpoint', $privateChannel->name);
$this->assertSame(
['endpointId' => 'public-endpoint', 'requestId' => 'request-1'],
$publicEvent->broadcastWith(),
);
}
public function test_prune_command_removes_old_data_expired_endpoints_and_excess_requests(): void
{
$endpoint = WebhookEndpoint::factory()->create();
WebhookRequest::factory()->forEndpoint($endpoint)->create([
'received_at' => now()->subDays(8),
]);
WebhookRequest::factory()->forEndpoint($endpoint)->count(1001)->create();
$expiredEndpoint = WebhookEndpoint::factory()->expired()->create();
$this->artisan('webhooks:prune')->assertExitCode(0);
$this->assertModelMissing($expiredEndpoint);
$this->assertSame(1000, $endpoint->requests()->count());
$this->assertFalse($endpoint->requests()->where('received_at', '<', now()->subDays(7))->exists());
}
private function hookUrl(WebhookEndpoint $endpoint): string
{
return route('webhooks.receive', ['token' => $this->token($endpoint)]);
}
private function token(WebhookEndpoint $endpoint): string
{
return (string) $endpoint->token;
}
}