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()); + } +}