Send signed outgoing webhooks
Fed by the records themselves, delivered only to public HTTPS addresses checked at every attempt, retried, and switched off after repeated failures. A submitter's details travel only to webhooks that opted into them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
219
tests/Feature/Api/WebhookTest.php
Normal file
219
tests/Feature/Api/WebhookTest.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Jobs\DeliverWebhook;
|
||||
use App\Models\Application;
|
||||
use App\Models\Page;
|
||||
use App\Models\User;
|
||||
use App\Models\Webhook;
|
||||
use App\Models\WebhookDelivery;
|
||||
use App\Services\Media\HostResolver;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request as ClientRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Outgoing webhooks: managed through the API, fed by what happens to records,
|
||||
* delivered signed and only to public addresses.
|
||||
*/
|
||||
class WebhookTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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<string, array<int, string>> $answers
|
||||
*/
|
||||
protected function resolving(array $answers): void
|
||||
{
|
||||
$this->app->instance(HostResolver::class, new class($answers) extends HostResolver
|
||||
{
|
||||
/** @param array<string, array<int, string>> $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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user