Implement webhook inspector
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthenticationTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_a_user_can_register_and_is_asked_to_verify_email(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$response = $this->post(route('register.store'), [
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$user = User::query()->where('email', 'ada@example.com')->firstOrFail();
|
||||
|
||||
$response->assertRedirect('/dashboard');
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->assertNull($user->email_verified_at);
|
||||
Notification::assertSentTo($user, VerifyEmail::class);
|
||||
}
|
||||
|
||||
public function test_an_unverified_user_cannot_open_the_dashboard(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$this->actingAs($user)->get(route('dashboard'))
|
||||
->assertRedirect(route('verification.notice'));
|
||||
}
|
||||
|
||||
public function test_a_verified_user_can_log_in_and_log_out(): void
|
||||
{
|
||||
$user = User::factory()->create(['password' => 'password']);
|
||||
|
||||
$this->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
])->assertRedirect('/dashboard');
|
||||
$this->assertAuthenticatedAs($user);
|
||||
|
||||
$this->post(route('logout'))->assertRedirect('/');
|
||||
$this->assertGuest();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Dashboard;
|
||||
use App\Livewire\Inspector;
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookEndpoint;
|
||||
use App\Models\WebhookRequest;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebhookInspectorTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_private_endpoints_are_only_visible_to_the_owner(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$endpoint = WebhookEndpoint::factory()->ownedBy($owner)->create();
|
||||
|
||||
$this->get(route('inspect.show', ['token' => $endpoint->token]))->assertForbidden();
|
||||
$this->actingAs($otherUser)->get(route('inspect.show', ['token' => $endpoint->token]))->assertForbidden();
|
||||
$this->actingAs($owner)->get(route('inspect.show', ['token' => $endpoint->token]))->assertOk();
|
||||
}
|
||||
|
||||
public function test_private_endpoints_can_only_be_deleted_by_the_owner(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$endpoint = WebhookEndpoint::factory()->ownedBy($owner)->create();
|
||||
|
||||
$this->actingAs($otherUser)
|
||||
->delete(route('inspect.destroy', ['token' => $endpoint->token]))
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertModelExists($endpoint);
|
||||
|
||||
$this->actingAs($owner)
|
||||
->delete(route('inspect.destroy', ['token' => $endpoint->token]))
|
||||
->assertRedirect(route('home'));
|
||||
|
||||
$this->assertModelMissing($endpoint);
|
||||
}
|
||||
|
||||
public function test_livewire_inspector_can_filter_search_select_and_delete_requests(): void
|
||||
{
|
||||
$endpoint = WebhookEndpoint::factory()->create();
|
||||
$getRequest = WebhookRequest::factory()->forEndpoint($endpoint)->create([
|
||||
'method' => 'GET',
|
||||
'request_uri' => '/hook/test?search=visible',
|
||||
'body' => '<script>alert(1)</script>',
|
||||
'body_size' => 25,
|
||||
]);
|
||||
WebhookRequest::factory()->forEndpoint($endpoint)->create([
|
||||
'method' => 'POST',
|
||||
'request_uri' => '/hook/other',
|
||||
]);
|
||||
|
||||
Livewire::test(Inspector::class, ['endpoint' => $endpoint])
|
||||
->assertSee('/hook/test?search=visible')
|
||||
->set('methodFilter', 'POST')
|
||||
->assertSee('/hook/other')
|
||||
->assertDontSee('/hook/test?search=visible')
|
||||
->set('methodFilter', 'ALL')
|
||||
->set('search', 'visible')
|
||||
->assertSee('/hook/test?search=visible')
|
||||
->assertDontSee('/hook/other')
|
||||
->call('selectRequest', $getRequest->getKey())
|
||||
->assertSee('<script>alert(1)</script>', false)
|
||||
->assertDontSee('<script>alert(1)</script>', false)
|
||||
->call('deleteRequest', $getRequest->getKey());
|
||||
|
||||
$this->assertModelMissing($getRequest);
|
||||
}
|
||||
|
||||
public function test_private_endpoints_can_be_created_from_the_dashboard(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Dashboard::class)
|
||||
->set('endpointName', 'Payments')
|
||||
->call('saveEndpoint')
|
||||
->assertRedirect();
|
||||
|
||||
$endpoint = $user->webhookEndpoints()->firstOrFail();
|
||||
$this->assertFalse($endpoint->is_public);
|
||||
$this->assertSame('Payments', $endpoint->name);
|
||||
$this->assertNull($endpoint->expires_at);
|
||||
}
|
||||
|
||||
public function test_private_endpoint_response_can_be_configured_from_the_inspector(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$endpoint = WebhookEndpoint::factory()->ownedBy($user)->create();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Inspector::class, ['endpoint' => $endpoint])
|
||||
->set('responseStatus', 202)
|
||||
->set('responseHeadersJson', '{"Content-Type":"text/plain","X-Inspector":"accepted"}')
|
||||
->set('responseBody', 'queued')
|
||||
->call('saveResponse')
|
||||
->assertSet('responseSaved', true);
|
||||
|
||||
$endpoint->refresh();
|
||||
|
||||
$this->assertSame(202, $endpoint->response_status);
|
||||
$this->assertSame(
|
||||
['Content-Type' => 'text/plain', 'X-Inspector' => 'accepted'],
|
||||
$endpoint->response_headers,
|
||||
);
|
||||
$this->assertSame('queued', $endpoint->response_body);
|
||||
}
|
||||
|
||||
public function test_request_body_is_escaped_in_the_inspector(): void
|
||||
{
|
||||
$endpoint = WebhookEndpoint::factory()->create();
|
||||
$webhookRequest = WebhookRequest::factory()->forEndpoint($endpoint)->create([
|
||||
'body' => '<img src=x onerror=alert(1)>',
|
||||
'body_size' => 29,
|
||||
]);
|
||||
|
||||
Livewire::test(Inspector::class, ['endpoint' => $endpoint])
|
||||
->call('selectRequest', $webhookRequest->getKey())
|
||||
->assertDontSee('<img src=x onerror=alert(1)>', false)
|
||||
->assertSee('<img src=x onerror=alert(1)>', false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user