237 lines
9.0 KiB
PHP
237 lines
9.0 KiB
PHP
<?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;
|
|
}
|
|
}
|