From 9390db18c25082427025a25a1e514977a276e237 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 1 Oct 2026 14:32:54 +0200 Subject: [PATCH] Send a webhook for every change a person makes to a record Events are {resource}.created, .updated, .deleted and .restored for content, the inbox, redirects, repositories and media, taken from the audit log so the two agree on what happened. Subscriptions take wildcards. Users, tokens, webhooks and settings are never sent, nor what the application does by itself; the inbox's own words only with personal data enabled. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 +- .../Resources/Webhooks/WebhookResource.php | 52 +++++- app/Http/Requests/Api/V1/WebhookRequest.php | 12 +- app/Models/Webhook.php | 58 ++++++- app/Providers/AppServiceProvider.php | 2 + app/Support/Audit/Auditor.php | 6 +- app/Support/Webhooks/Webhooks.php | 87 ++++++++++ lang/ca/panel.php | 30 ++++ lang/ca/rules.php | 4 + lang/en/panel.php | 30 ++++ lang/en/rules.php | 4 + lang/es/panel.php | 30 ++++ lang/es/rules.php | 4 + public/openapi.yaml | 28 +++- tests/Feature/Api/WebhookRecordEventsTest.php | 158 ++++++++++++++++++ 15 files changed, 499 insertions(+), 22 deletions(-) create mode 100644 tests/Feature/Api/WebhookRecordEventsTest.php diff --git a/README.md b/README.md index a5e315e..d77efac 100644 --- a/README.md +++ b/README.md @@ -1472,7 +1472,21 @@ POST /api/v1/webhook-deliveries/{id}/redeliver ``` Avisan a otro sistema de `application.received`, `content.published`, -`content.unpublished`, `review.requested` y `review.returned` (o `*`). Salen de +`content.unpublished`, `review.requested` y `review.returned`, y además de +**cualquier cambio que haga una persona en una ficha**: `{tipo}.created`, +`.updated`, `.deleted` y `.restored` para páginas, categorías, etiquetas, +proyectos, miembros, servicios, clientes, testimonios, charlas, tecnologías, +convocatorias, redirecciones, repositorios, medios y el buzón. Se puede +suscribir a uno, a `*`, a todo un tipo (`projects.*`) o a una acción en +cualquiera (`*.deleted`). + +Esos eventos salen del [registro de auditoría](#registro-de-auditoría), así +que dicen lo mismo que él —qué ficha, quién, por dónde (panel, API, token) y, +al editar, qué campos cambiaron con su valor anterior y el nuevo— y dejan fuera +lo mismo: lo que hace la aplicación sola y los secretos. Del buzón solo se +cuenta cómo se está atendiendo, salvo con `include_personal_data`. Usuarios, +tokens, webhooks y ajustes no se envían nunca: son la seguridad de la +aplicación, no su contenido. Salen de los propios registros —el historial ya distingue publicar de editar, venga del panel, de la API o de un lote— y no de los controladores, así que no hay forma de publicar algo sin que se avise. diff --git a/app/Filament/Resources/Webhooks/WebhookResource.php b/app/Filament/Resources/Webhooks/WebhookResource.php index 4be31fa..dd152c5 100644 --- a/app/Filament/Resources/Webhooks/WebhookResource.php +++ b/app/Filament/Resources/Webhooks/WebhookResource.php @@ -15,7 +15,7 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; -use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; @@ -96,16 +96,13 @@ class WebhookResource extends Resource ->maxLength(2048) ->rule(new PublicHttpsUrl), - CheckboxList::make('events') + Select::make('events') ->label(__('panel.webhooks.fields.events')) - ->options(fn () => collect(['*', ...Webhook::EVENTS]) - ->mapWithKeys(fn (string $event) => [$event => self::eventLabel($event)]) - ->all()) - ->descriptions(fn () => collect(['*', ...Webhook::EVENTS]) - ->mapWithKeys(fn (string $event) => [$event => $event]) - ->all()) + ->helperText(__('panel.webhooks.events_hint')) + ->multiple() + ->searchable() + ->options(fn () => self::eventOptions()) ->required() - ->columns(2) ->columnSpanFull(), Toggle::make('include_personal_data') @@ -162,6 +159,43 @@ class WebhookResource extends Resource return __("panel.webhooks.events.{$key}"); } + /** + * Grouped for the select: everything, the events with a meaning of their + * own, any record by action, then each kind of record. + * + * @return array> + */ + public static function eventOptions(): array + { + $groups = [ + __('panel.webhooks.groups.everything') => [ + '*' => __('panel.webhooks.events.*'), + ...collect(Webhook::ACTIONS)->mapWithKeys(fn (string $action) => [ + "*.{$action}" => __('panel.webhooks.any_record', ['action' => self::actionLabel($action)]), + ])->all(), + ], + __('panel.webhooks.groups.notices') => collect(Webhook::EVENTS) + ->mapWithKeys(fn (string $event) => [$event => self::eventLabel($event)]) + ->all(), + ]; + + foreach (Webhook::RESOURCES as $resource) { + $groups[__("panel.webhooks.resources.{$resource}")] = [ + "{$resource}.*" => __('panel.webhooks.all_of', ['resource' => __("panel.webhooks.resources.{$resource}")]), + ...collect(Webhook::ACTIONS)->mapWithKeys(fn (string $action) => [ + "{$resource}.{$action}" => __("panel.webhooks.resources.{$resource}").' · '.self::actionLabel($action), + ])->all(), + ]; + } + + return $groups; + } + + protected static function actionLabel(string $action): string + { + return __("panel.webhooks.actions_on.{$action}"); + } + public static function pingAction(): Action { return Action::make('ping') diff --git a/app/Http/Requests/Api/V1/WebhookRequest.php b/app/Http/Requests/Api/V1/WebhookRequest.php index e3d15e5..17817ee 100644 --- a/app/Http/Requests/Api/V1/WebhookRequest.php +++ b/app/Http/Requests/Api/V1/WebhookRequest.php @@ -6,8 +6,8 @@ namespace App\Http\Requests\Api\V1; use App\Models\Webhook; use App\Rules\PublicHttpsUrl; +use Closure; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class WebhookRequest extends FormRequest { @@ -36,7 +36,15 @@ class WebhookRequest extends FormRequest new PublicHttpsUrl, ], 'events' => [$sometimes, 'array', 'min:1'], - 'events.*' => ['string', 'distinct', Rule::in(['*', ...Webhook::EVENTS])], + 'events.*' => [ + 'string', + 'distinct', + function (string $attribute, mixed $value, Closure $fail): void { + if (! is_string($value) || ! Webhook::isSubscribable($value)) { + $fail(__('rules.webhooks.event', ['event' => (string) (is_scalar($value) ? $value : '')])); + } + }, + ], 'secret' => [$creating ? 'sometimes' : 'prohibited', 'nullable', 'string', 'min:16', 'max:255'], 'rotate_secret' => [$creating ? 'prohibited' : 'sometimes', 'boolean'], 'include_personal_data' => ['sometimes', 'boolean'], diff --git a/app/Models/Webhook.php b/app/Models/Webhook.php index fe1a440..4032835 100644 --- a/app/Models/Webhook.php +++ b/app/Models/Webhook.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Str; /** * Somewhere to tell when something happens here. @@ -21,7 +22,7 @@ class Webhook extends Model use HasFactory; /** - * What can be subscribed to. `*` is all of them. + * Events with a meaning of their own, whatever record they are about. */ public const EVENTS = [ 'application.received', @@ -31,6 +32,53 @@ class Webhook extends Model 'review.returned', ]; + /** + * Kinds of record whose every change can be subscribed to, as + * `{resource}.{action}`. Not users, tokens, webhooks or settings: those + * are the application's security, not its content, and do not leave it. + */ + public const RESOURCES = [ + 'pages', 'categories', 'tags', 'projects', 'members', 'services', + 'clients', 'testimonials', 'talks', 'technologies', 'calls', + 'redirects', 'repositories', 'media', 'applications', + ]; + + public const ACTIONS = ['created', 'updated', 'deleted', 'restored']; + + /** + * Every event that is ever sent. + * + * @return array + */ + public static function events(): array + { + $changes = []; + + foreach (self::RESOURCES as $resource) { + foreach (self::ACTIONS as $action) { + $changes[] = "{$resource}.{$action}"; + } + } + + return [...self::EVENTS, ...$changes]; + } + + /** + * What a webhook may subscribe to: an event, `*`, everything about one + * kind of record (`projects.*`) or one action on any (`*.deleted`). + */ + public static function isSubscribable(string $pattern): bool + { + if ($pattern === '*' || in_array($pattern, self::events(), true)) { + return true; + } + + [$resource, $action] = array_pad(explode('.', $pattern, 2), 2, null); + + return ($action === '*' && in_array($resource, self::RESOURCES, true)) + || ($resource === '*' && in_array($action, self::ACTIONS, true)); + } + /** * Switched off after this many failed deliveries in a row, so a dead * endpoint does not keep a queue busy forever. Switching it back on is a @@ -64,9 +112,13 @@ class Webhook extends Model public function listensTo(string $event): bool { - $events = $this->events ?? []; + foreach ($this->events ?? [] as $pattern) { + if ($pattern === $event || Str::is($pattern, $event)) { + return true; + } + } - return in_array('*', $events, true) || in_array($event, $events, true); + return false; } /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 016ea78..a033e9f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,6 +6,7 @@ namespace App\Providers; use App\Auth\PireonTokenGuard; use App\Models\Application; +use App\Models\AuditLog; use App\Models\ReviewComment; use App\Models\Revision; use App\Models\User; @@ -64,6 +65,7 @@ class AppServiceProvider extends ServiceProvider Application::created(fn (Application $application) => Webhooks::applicationReceived($application)); Revision::created(fn (Revision $revision) => Webhooks::revisionRecorded($revision)); ReviewComment::created(fn (ReviewComment $comment) => Webhooks::reviewRecorded($comment)); + AuditLog::created(fn (AuditLog $entry) => Webhooks::auditRecorded($entry)); // The audit log: every change a person makes to an audited record, // role changes, and signing in to the panel. diff --git a/app/Support/Audit/Auditor.php b/app/Support/Audit/Auditor.php index b6222a2..932d3f3 100644 --- a/app/Support/Audit/Auditor.php +++ b/app/Support/Audit/Auditor.php @@ -76,19 +76,19 @@ final class Auditor /** * @param array|null $changes */ - public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): void + public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): ?AuditLog { $actor ??= Auth::user(); if (! $actor instanceof User) { - return; + return null; } $request = app()->bound('request') ? request() : null; $route = $request?->route(); $token = $actor->currentAccessToken(); - AuditLog::query()->create([ + return AuditLog::query()->create([ 'user_id' => $actor->id, 'user_name' => $actor->name, 'via' => self::via($route?->getName(), $token !== null), diff --git a/app/Support/Webhooks/Webhooks.php b/app/Support/Webhooks/Webhooks.php index 84f8991..ed220f8 100644 --- a/app/Support/Webhooks/Webhooks.php +++ b/app/Support/Webhooks/Webhooks.php @@ -6,11 +6,14 @@ namespace App\Support\Webhooks; use App\Jobs\DeliverWebhook; use App\Models\Application; +use App\Models\AuditLog; +use App\Models\Member; use App\Models\ReviewComment; use App\Models\Revision; use App\Models\Webhook; use App\Models\WebhookDelivery; use App\Support\AdminContent; +use App\Support\Audit\Auditor; use App\Support\ContentTypes; use App\Support\SiteUrls; use Illuminate\Database\Eloquent\Model; @@ -93,6 +96,90 @@ final class Webhooks ]); } + /** + * `projects.created`, `pages.updated`, `media.deleted`…: whatever a person + * did to a record, from the audit log — which already leaves out what the + * application does by itself, and already masks secrets, so the two + * cannot disagree about what happened. + */ + public static function auditRecorded(AuditLog $entry): void + { + [$resource, $action] = array_pad(explode('.', $entry->action, 2), 2, null); + + if (! in_array($resource, Webhook::RESOURCES, true) || ! in_array($action, Webhook::ACTIONS, true)) { + return; + } + + $record = self::find($resource, $entry->subject_id); + $changes = $entry->changes; + + $data = [ + 'type' => $resource, + 'id' => is_numeric($entry->subject_id) ? (int) $entry->subject_id : $entry->subject_id, + 'title' => $entry->subject_label, + 'action' => $action, + 'by' => ['id' => $entry->user_id, 'name' => $entry->user_name], + 'via' => $entry->via, + 'changes' => $changes, + 'url' => $action === 'deleted' || $record === null ? null : self::publicUrl($record), + ]; + + $personal = []; + + // A submission is somebody else's words: what is sent about it by + // default is how it is being handled, nothing they wrote. + if ($resource === 'applications') { + $data['title'] = $record instanceof Application ? $record->subject() : null; + $data['changes'] = $changes === null ? null : array_intersect_key($changes, array_flip(['status', 'handled_by', 'handled_at'])); + $personal = [ + 'name' => $record?->name, + 'email' => $record?->email, + 'changes' => $changes, + ]; + } + + self::dispatch($entry->action, $data, $personal); + } + + protected static function find(string $resource, ?string $id): ?Model + { + $model = array_search($resource, Auditor::AUDITED, true); + + if ($model === false || $id === null) { + return null; + } + + $query = $model::query(); + + if (method_exists($model, 'bootSoftDeletes')) { + $query->withTrashed(); + } + + return $query->find($id); + } + + /** + * Where it can be seen on the site, when it can be. + */ + protected static function publicUrl(Model $record): ?string + { + if (method_exists($record, 'isPublishedNow') && ! $record->isPublishedNow()) { + return null; + } + + if ($record instanceof Member) { + return $record->is_public + ? rtrim((string) config('site.url'), '/').SiteUrls::path('member', (string) config('locales.default'), $record->slug) + : null; + } + + $siteType = AdminContent::PREVIEWABLE[AdminContent::nameOf($record) ?? ''] ?? null; + + return $siteType + ? (SiteUrls::absoluteAlternates($record, $siteType)[(string) config('locales.default')] ?? null) + : null; + } + /** * Publishing and unpublishing are already told apart by the history, for * every kind of record and from wherever it happened; this listens there. diff --git a/lang/ca/panel.php b/lang/ca/panel.php index 3f021f0..eb4af34 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -148,6 +148,36 @@ return [ 'webhooks' => [ 'label' => 'Webhook', + 'events_hint' => 'Pots triar-ne diversos. «Tot de…» inclou qualsevol canvi en aquest tipus de fitxa.', + 'any_record' => 'Qualsevol fitxa · :action', + 'all_of' => 'Tot de :resource', + 'groups' => [ + 'everything' => 'General', + 'notices' => 'Avisos', + ], + 'actions_on' => [ + 'created' => 'Creada', + 'updated' => 'Editada', + 'deleted' => 'Esborrada', + 'restored' => 'Recuperada', + ], + 'resources' => [ + 'pages' => 'Pàgines', + 'categories' => 'Categories', + 'tags' => 'Etiquetes', + 'projects' => 'Projectes', + 'members' => 'Membres', + 'services' => 'Serveis', + 'clients' => 'Clients', + 'testimonials' => 'Testimonis', + 'talks' => 'Xerrades', + 'technologies' => 'Tecnologies', + 'calls' => 'Convocatòries', + 'redirects' => 'Redireccions', + 'repositories' => 'Repositoris', + 'media' => 'Mitjans', + 'applications' => 'Bústia', + ], 'plural' => 'Webhooks', 'fields' => [ 'name' => 'Nom', diff --git a/lang/ca/rules.php b/lang/ca/rules.php index d5f7a5a..86796e4 100644 --- a/lang/ca/rules.php +++ b/lang/ca/rules.php @@ -44,6 +44,10 @@ return [ 'too_many' => 'Com a molt :max files per importació.', ], + 'webhooks' => [ + 'event' => '«:event» no és un esdeveniment al qual subscriure\'s.', + ], + 'tokens' => [ 'ability' => '«:ability» no és un permís que tinguis, així que un token teu no el pot tenir.', 'machine' => 'Els tokens els gestiona una persona, no un altre token.', diff --git a/lang/en/panel.php b/lang/en/panel.php index 768addc..677ce45 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -148,6 +148,36 @@ return [ 'webhooks' => [ 'label' => 'Webhook', + 'events_hint' => 'Pick as many as you like. "All of…" covers any change to that kind of record.', + 'any_record' => 'Any record · :action', + 'all_of' => 'All of :resource', + 'groups' => [ + 'everything' => 'General', + 'notices' => 'Notices', + ], + 'actions_on' => [ + 'created' => 'Created', + 'updated' => 'Edited', + 'deleted' => 'Deleted', + 'restored' => 'Restored', + ], + 'resources' => [ + 'pages' => 'Pages', + 'categories' => 'Categories', + 'tags' => 'Tags', + 'projects' => 'Projects', + 'members' => 'Members', + 'services' => 'Services', + 'clients' => 'Clients', + 'testimonials' => 'Testimonials', + 'talks' => 'Talks', + 'technologies' => 'Technologies', + 'calls' => 'Calls', + 'redirects' => 'Redirects', + 'repositories' => 'Repositories', + 'media' => 'Media', + 'applications' => 'Inbox', + ], 'plural' => 'Webhooks', 'fields' => [ 'name' => 'Name', diff --git a/lang/en/rules.php b/lang/en/rules.php index 450dfbc..efd9ae6 100644 --- a/lang/en/rules.php +++ b/lang/en/rules.php @@ -44,6 +44,10 @@ return [ 'too_many' => 'At most :max rows per import.', ], + 'webhooks' => [ + 'event' => '":event" is not an event that can be subscribed to.', + ], + 'tokens' => [ 'ability' => '":ability" is not a permission you hold, so a token of yours cannot hold it.', 'machine' => 'Tokens are managed by a person, not by another token.', diff --git a/lang/es/panel.php b/lang/es/panel.php index 995a6d4..430b905 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -148,6 +148,36 @@ return [ 'webhooks' => [ 'label' => 'Webhook', + 'events_hint' => 'Puedes elegir varios. «Todo de…» incluye cualquier cambio en ese tipo de ficha.', + 'any_record' => 'Cualquier ficha · :action', + 'all_of' => 'Todo de :resource', + 'groups' => [ + 'everything' => 'General', + 'notices' => 'Avisos', + ], + 'actions_on' => [ + 'created' => 'Creada', + 'updated' => 'Editada', + 'deleted' => 'Borrada', + 'restored' => 'Recuperada', + ], + 'resources' => [ + 'pages' => 'Páginas', + 'categories' => 'Categorías', + 'tags' => 'Etiquetas', + 'projects' => 'Proyectos', + 'members' => 'Miembros', + 'services' => 'Servicios', + 'clients' => 'Clientes', + 'testimonials' => 'Testimonios', + 'talks' => 'Charlas', + 'technologies' => 'Tecnologías', + 'calls' => 'Convocatorias', + 'redirects' => 'Redirecciones', + 'repositories' => 'Repositorios', + 'media' => 'Medios', + 'applications' => 'Buzón', + ], 'plural' => 'Webhooks', 'fields' => [ 'name' => 'Nombre', diff --git a/lang/es/rules.php b/lang/es/rules.php index eec4904..24e146e 100644 --- a/lang/es/rules.php +++ b/lang/es/rules.php @@ -44,6 +44,10 @@ return [ 'too_many' => 'Como mucho :max filas por importación.', ], + 'webhooks' => [ + 'event' => '«:event» no es un evento al que suscribirse.', + ], + 'tokens' => [ 'ability' => '«:ability» no es un permiso que tengas, así que un token tuyo no puede tenerlo.', 'machine' => 'Los tokens los gestiona una persona, no otro token.', diff --git a/public/openapi.yaml b/public/openapi.yaml index a7a81dc..cf58a91 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -3945,9 +3945,26 @@ paths: times over about two and a half hours; twenty failures in a row switch the webhook off. - Events: `application.received` (its sender's details only with - `include_personal_data`), `content.published`, `content.unpublished`, - `review.requested`, `review.returned` — or `*`. + Events with a meaning of their own: `application.received` (its + sender's details only with `include_personal_data`), + `content.published`, `content.unpublished`, `review.requested`, + `review.returned`. + + And every change a person makes to a record, as `{resource}.{action}` + — `projects.created`, `pages.updated`, `media.deleted`, + `calls.restored` — for pages, categories, tags, projects, members, + services, clients, testimonials, talks, technologies, calls, + redirects, repositories, media and applications. Its `data` says + what (`type`, `id`, `title`, `url` when it is on the site), who + (`by`), how they came in (`via`: panel, api, token) and, on an + update, `changes` as field → `{old, new}`. What the application does + by itself — a sync, a scheduled publication — is not sent. Changes to + an application carry only how it is handled unless + `include_personal_data`. Users, tokens, webhooks and settings are + never sent. + + Subscribe to an event, to `*`, to all of one kind of record + (`projects.*`) or to one action on any (`*.deleted`). requestBody: required: true content: @@ -6200,7 +6217,10 @@ components: url: { type: string, format: uri, description: "HTTPS, to a public address." } events: type: array - items: { type: string, enum: ["*", application.received, content.published, content.unpublished, review.requested, review.returned] } + items: + type: string + description: "An event, `*`, `{resource}.*` or `*.{action}`." + example: projects.* secret: { type: string, minLength: 16, description: On creation only; generated when left out. } rotate_secret: { type: boolean, description: On update only. } include_personal_data: { type: boolean, default: false } diff --git a/tests/Feature/Api/WebhookRecordEventsTest.php b/tests/Feature/Api/WebhookRecordEventsTest.php new file mode 100644 index 0000000..683032b --- /dev/null +++ b/tests/Feature/Api/WebhookRecordEventsTest.php @@ -0,0 +1,158 @@ +seed(RolePermissionSeeder::class); + Queue::fake(); + + $this->app->instance(HostResolver::class, new class extends HostResolver + { + public function resolve(string $host): array + { + return ['93.184.216.34']; + } + }); + } + + protected function actingWithRole(string $role): User + { + $user = User::factory()->create(); + $user->syncRoles([$role]); + + $this->withHeaders($this->apiHeadersFor($user)); + + return $user; + } + + #[Test] + public function wildcards_cover_a_kind_of_record_or_an_action(): void + { + $webhook = Webhook::factory()->make(['events' => ['projects.*', '*.deleted']]); + + $this->assertTrue($webhook->listensTo('projects.updated')); + $this->assertTrue($webhook->listensTo('pages.deleted')); + $this->assertFalse($webhook->listensTo('pages.updated')); + $this->assertFalse($webhook->listensTo('content.published')); + } + + #[Test] + public function editing_a_project_says_what_changed_who_did_it_and_how(): void + { + $editor = $this->actingWithRole('editor'); + + $webhook = Webhook::factory()->create(['events' => ['projects.*']]); + $project = Project::factory()->create(['status' => 'active']); + + $this->patchJson("/api/v1/projects/{$project->id}", ['status' => 'paused'])->assertOk(); + + $delivery = $webhook->deliveries()->where('event', 'projects.updated')->sole(); + + $this->assertSame($project->id, $delivery->payload['id']); + $this->assertSame(['old' => 'active', 'new' => 'paused'], $delivery->payload['changes']['status']); + $this->assertSame($editor->id, $delivery->payload['by']['id']); + $this->assertSame('api', $delivery->payload['via']); + } + + #[Test] + public function creating_and_deleting_are_events_too(): void + { + $this->actingWithRole('editor'); + + $webhook = Webhook::factory()->create(['events' => ['pages.created', '*.deleted']]); + + $id = $this->postJson('/api/v1/pages', ['title' => ['es' => 'Hola'], 'slug' => ['es' => 'hola']])->json('data.id'); + $this->deleteJson("/api/v1/pages/{$id}")->assertNoContent(); + + $this->assertSame(['pages.deleted', 'pages.created'], $webhook->deliveries()->pluck('event')->all()); + $this->assertNull($webhook->deliveries()->first()->payload['url']); + } + + #[Test] + public function what_the_application_does_by_itself_is_not_announced(): void + { + $webhook = Webhook::factory()->create(['events' => ['*']]); + + Repository::factory()->create()->forceFill(['stars' => 10])->save(); + + $this->assertSame(0, $webhook->deliveries()->count()); + } + + #[Test] + public function security_records_cannot_be_subscribed_to(): void + { + $this->actingWithRole('admin'); + + foreach (['users.updated', 'tokens.*', 'settings.updated', 'webhooks.created', 'projects.exploded'] as $event) { + $this->postJson('/api/v1/webhooks', ['name' => 'X', 'url' => 'https://hooks.example.org/x', 'events' => [$event]]) + ->assertUnprocessable() + ->assertJsonValidationErrors('events.0'); + } + + $this->postJson('/api/v1/webhooks', ['name' => 'X', 'url' => 'https://hooks.example.org/x', 'events' => ['projects.*', '*.restored']]) + ->assertCreated(); + } + + #[Test] + public function the_inbox_tells_how_it_is_handled_and_its_words_only_when_asked(): void + { + $this->actingWithRole('editor'); + + $plain = Webhook::factory()->create(['events' => ['applications.updated']]); + $personal = Webhook::factory()->create(['events' => ['applications.*'], 'include_personal_data' => true]); + + $application = Application::factory()->create(['email' => 'ada@example.org']); + + $this->patchJson("/api/v1/applications/{$application->id}", ['status' => 'contacted', 'notes' => 'Llamar el lunes'])->assertOk(); + + $sent = $plain->deliveries()->latest('id')->first()->payload; + $full = $personal->deliveries()->latest('id')->first()->payload; + + $this->assertArrayNotHasKey('notes', $sent['changes'] ?? []); + $this->assertArrayNotHasKey('email', $sent); + $this->assertSame('ada@example.org', $full['email']); + $this->assertSame(ApplicationStatus::Contacted->value, $application->fresh()->status->value); + } + + #[Test] + public function the_events_answer_as_queued_deliveries(): void + { + $this->actingWithRole('editor'); + + Webhook::factory()->create(['events' => ['*']]); + $page = Page::factory()->create(); + + $this->patchJson("/api/v1/pages/{$page->id}", ['is_featured' => ! $page->is_featured])->assertOk(); + + $this->assertTrue(WebhookDelivery::query()->where('event', 'pages.updated')->exists()); + } +}