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; } }