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:
2026-10-01 09:42:39 +02:00
parent f93775e549
commit 9bdbf834f6
23 changed files with 1325 additions and 0 deletions

View File

@@ -22,6 +22,7 @@ use App\Models\Talk;
use App\Models\Technology;
use App\Models\Testimonial;
use App\Models\User;
use App\Models\Webhook;
use App\Support\Maintenance;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -94,6 +95,7 @@ class AdminApiContractTest extends TestCase
'repositories' => ['path' => '/v1/repositories'],
'media' => ['path' => '/v1/media'],
'tokens' => ['path' => '/v1/tokens'],
'webhooks' => ['path' => '/v1/webhooks'],
];
}
@@ -118,6 +120,8 @@ class AdminApiContractTest extends TestCase
$media = Media::factory()->create();
$page->comment('Primera nota', $this->caller);
ApiToken::issue($this->caller, 'CI', ['pages.*'], now()->addDay());
$webhook = Webhook::factory()->create(['is_active' => false]);
$webhook->deliveries()->create(['uuid' => 'contract', 'event' => 'ping', 'payload' => []]);
// One request, one documented path — the URL a real caller hits and
// the path key that describes it, side by side rather than derived
@@ -159,6 +163,9 @@ class AdminApiContractTest extends TestCase
'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'],
'revisions' => ["/v1/projects/{$project->id}/revisions", '/v1/projects/{project}/revisions'],
'tokens' => ['/v1/tokens', '/v1/tokens'],
'webhooks' => ['/v1/webhooks', '/v1/webhooks'],
'webhook' => ["/v1/webhooks/{$webhook->id}", '/v1/webhooks/{webhook}'],
'deliveries' => ["/v1/webhooks/{$webhook->id}/deliveries", '/v1/webhooks/{webhook}/deliveries'],
'review thread' => ["/v1/pages/{$page->id}/reviews", '/v1/pages/{page}/reviews'],
'users' => ['/v1/users', '/v1/users'],
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],

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