seed(RolePermissionSeeder::class); $this->resolving([ 'hooks.example.org' => ['93.184.216.34'], 'internal.example.org' => ['10.0.0.5'], ]); } protected function actingWithRole(string $role): User { $user = User::factory()->create(); $user->syncRoles([$role]); $this->withHeaders($this->apiHeadersFor($user)); return $user; } /** * @param array> $answers */ protected function resolving(array $answers): void { $this->app->instance(HostResolver::class, new class($answers) extends HostResolver { /** @param array> $answers */ public function __construct(private array $answers) {} public function resolve(string $host): array { return $this->answers[$host] ?? []; } }); } #[Test] public function an_admin_registers_a_webhook_and_sees_its_secret_once(): void { $this->actingWithRole('admin'); $response = $this->postJson('/api/v1/webhooks', [ 'name' => 'Avisos', 'url' => 'https://hooks.example.org/pireon', 'events' => ['application.received', 'content.published'], ]) ->assertCreated() ->assertJsonPath('data.is_active', true) ->assertJsonMissingPath('data.secret'); $this->assertSame(40, strlen($response->json('secret'))); $this->assertSame($response->json('secret'), Webhook::query()->sole()->secret); $this->getJson('/api/v1/webhooks')->assertJsonMissingPath('data.0.secret'); } #[Test] public function a_webhook_only_points_at_public_https_addresses(): void { $this->actingWithRole('admin'); foreach (['http://hooks.example.org/x', 'https://internal.example.org/x', 'https://127.0.0.1/x', 'https://nowhere.example.org/x'] as $url) { $this->postJson('/api/v1/webhooks', ['name' => 'X', 'url' => $url, 'events' => ['*']]) ->assertUnprocessable() ->assertJsonValidationErrors('url'); } } #[Test] public function an_editor_cannot_manage_webhooks(): void { $this->actingWithRole('editor'); $this->getJson('/api/v1/webhooks')->assertForbidden(); $this->postJson('/api/v1/webhooks', ['name' => 'X', 'url' => 'https://hooks.example.org/x', 'events' => ['*']]) ->assertForbidden(); } #[Test] public function a_delivery_is_signed_with_the_secret(): void { Http::fake(['hooks.example.org/*' => Http::response('ok')]); $webhook = Webhook::factory()->create(['secret' => 'shh-a-secret-of-some-length', 'events' => ['content.published']]); Page::factory()->published()->create(); $delivery = WebhookDelivery::query()->sole(); $this->assertSame('content.published', $delivery->event); $this->assertSame(WebhookDelivery::SUCCEEDED, $delivery->status); $this->assertNotNull($webhook->fresh()->last_delivered_at); Http::assertSent(function (ClientRequest $request) { $timestamp = $request->header('X-Webhook-Timestamp')[0]; $expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$request->body(), 'shh-a-secret-of-some-length'); return $request->header('X-Webhook-Signature')[0] === $expected && $request['event'] === 'content.published' && $request['data']['type'] === 'pages'; }); } #[Test] public function personal_data_goes_only_where_it_was_asked_for(): void { Queue::fake(); $plain = Webhook::factory()->create(['events' => ['application.received']]); $personal = Webhook::factory()->create(['events' => ['*'], 'include_personal_data' => true]); Application::factory()->create(['email' => 'ada@example.org']); $this->assertArrayNotHasKey('email', $plain->deliveries()->sole()->payload); $this->assertSame('ada@example.org', $personal->deliveries()->sole()->payload['email']); Queue::assertPushed(DeliverWebhook::class, 2); } #[Test] public function a_webhook_that_keeps_failing_is_switched_off(): void { Http::fake(['hooks.example.org/*' => Http::response('nope', 500)]); $webhook = Webhook::factory()->create(['consecutive_failures' => Webhook::MAX_FAILURES - 1]); $delivery = $webhook->deliveries()->create(['uuid' => 'u-1', 'event' => 'ping', 'payload' => []]); $job = new DeliverWebhook($delivery); $job->failed(new \RuntimeException('nope')); $this->assertSame(WebhookDelivery::FAILED, $delivery->fresh()->status); $this->assertFalse($webhook->fresh()->is_active); } #[Test] public function a_name_that_moved_somewhere_private_is_not_delivered_to(): void { Http::fake(); $webhook = Webhook::factory()->create(['url' => 'https://hooks.example.org/x']); $delivery = $webhook->deliveries()->create(['uuid' => 'u-2', 'event' => 'ping', 'payload' => []]); $this->resolving(['hooks.example.org' => ['192.168.1.10']]); DeliverWebhook::dispatchSync($delivery); Http::assertNothingSent(); $this->assertSame(WebhookDelivery::FAILED, $delivery->fresh()->status); } #[Test] public function deliveries_are_listed_pinged_and_sent_again(): void { Http::fake(['hooks.example.org/*' => Http::response('ok')]); $this->actingWithRole('admin'); $webhook = Webhook::factory()->create(); $this->postJson("/api/v1/webhooks/{$webhook->id}/ping")->assertAccepted()->assertJsonPath('data.event', 'ping'); $id = $this->getJson("/api/v1/webhooks/{$webhook->id}/deliveries") ->assertOk() ->assertJsonPath('data.0.status', 'succeeded') ->json('data.0.id'); $this->postJson("/api/v1/webhook-deliveries/{$id}/redeliver")->assertAccepted(); $this->assertSame(2, $webhook->deliveries()->count()); } #[Test] public function rotating_the_secret_shows_the_new_one_once(): void { $this->actingWithRole('admin'); $webhook = Webhook::factory()->create(['secret' => 'old-secret-old-secret']); $new = $this->patchJson("/api/v1/webhooks/{$webhook->id}", ['rotate_secret' => true]) ->assertOk() ->json('secret'); $this->assertNotSame('old-secret-old-secret', $new); $this->assertSame($new, $webhook->fresh()->secret); } }