From a932c51bbb95348ed93f662bdad4038aabf2ec88 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 4 Sep 2026 12:00:32 -0300 Subject: [PATCH] Expose workspace webhooks through the API and MCP (#330) * Expose workspace webhooks through the API and MCP. The same create/update/test/rotate/replay/delete flow now lives in Actions so the web UI, REST API, and MCP tools stay in lockstep. * Keep webhook validation local to each web, API, and MCP entry point. * Extract MCP webhook rules into request classes and close remaining API/MCP review gaps. * Tighten webhook updates to a field whitelist and reset failures only on re-enable. * Treat a mismatched webhook replay as not found and mark secret rotation destructive. --- app/Actions/Webhook/CreateWebhook.php | 31 + app/Actions/Webhook/DeleteWebhook.php | 15 + app/Actions/Webhook/ReplayWebhookLog.php | 22 + app/Actions/Webhook/RotateWebhookSecret.php | 19 + app/Actions/Webhook/SendWebhookTest.php | 16 + app/Actions/Webhook/UpdateWebhook.php | 35 + .../Controllers/Api/WebhookController.php | 152 +++++ .../Controllers/App/WebhookController.php | 81 +-- .../Api/Webhook/StoreWebhookRequest.php | 29 + .../Api/Webhook/UpdateWebhookRequest.php | 31 + app/Http/Resources/Api/WebhookLogResource.php | 29 + app/Http/Resources/Api/WebhookResource.php | 33 + app/Mcp/Concerns/ResolvesWorkspaceWebhook.php | 38 ++ .../Requests/Webhook/CreateWebhookRequest.php | 23 + .../Webhook/ListWebhookLogsRequest.php | 19 + .../Webhook/ReplayWebhookLogRequest.php | 19 + .../Requests/Webhook/UpdateWebhookRequest.php | 26 + app/Mcp/Requests/Webhook/WebhookIdRequest.php | 18 + app/Mcp/Servers/TryPostServer.php | 22 +- app/Mcp/Tools/Webhook/CreateWebhookTool.php | 67 ++ app/Mcp/Tools/Webhook/DeleteWebhookTool.php | 62 ++ app/Mcp/Tools/Webhook/GetWebhookTool.php | 62 ++ app/Mcp/Tools/Webhook/ListWebhookLogsTool.php | 68 ++ app/Mcp/Tools/Webhook/ListWebhooksTool.php | 45 ++ .../Tools/Webhook/ReplayWebhookLogTool.php | 70 ++ .../Tools/Webhook/RotateWebhookSecretTool.php | 65 ++ app/Mcp/Tools/Webhook/SendWebhookTestTool.php | 70 ++ app/Mcp/Tools/Webhook/UpdateWebhookTool.php | 83 +++ routes/api.php | 12 + tests/Feature/Api/WebhookApiTest.php | 599 ++++++++++++++++++ tests/Feature/App/WebhookTest.php | 15 + .../Feature/Mcp/McpRoleAuthorizationTest.php | 27 + tests/Feature/Mcp/WebhookToolTest.php | 501 +++++++++++++++ .../Feature/Webhook/ReplayWebhookLogTest.php | 2 +- 34 files changed, 2349 insertions(+), 57 deletions(-) create mode 100644 app/Actions/Webhook/CreateWebhook.php create mode 100644 app/Actions/Webhook/DeleteWebhook.php create mode 100644 app/Actions/Webhook/ReplayWebhookLog.php create mode 100644 app/Actions/Webhook/RotateWebhookSecret.php create mode 100644 app/Actions/Webhook/SendWebhookTest.php create mode 100644 app/Actions/Webhook/UpdateWebhook.php create mode 100644 app/Http/Controllers/Api/WebhookController.php create mode 100644 app/Http/Requests/Api/Webhook/StoreWebhookRequest.php create mode 100644 app/Http/Requests/Api/Webhook/UpdateWebhookRequest.php create mode 100644 app/Http/Resources/Api/WebhookLogResource.php create mode 100644 app/Http/Resources/Api/WebhookResource.php create mode 100644 app/Mcp/Concerns/ResolvesWorkspaceWebhook.php create mode 100644 app/Mcp/Requests/Webhook/CreateWebhookRequest.php create mode 100644 app/Mcp/Requests/Webhook/ListWebhookLogsRequest.php create mode 100644 app/Mcp/Requests/Webhook/ReplayWebhookLogRequest.php create mode 100644 app/Mcp/Requests/Webhook/UpdateWebhookRequest.php create mode 100644 app/Mcp/Requests/Webhook/WebhookIdRequest.php create mode 100644 app/Mcp/Tools/Webhook/CreateWebhookTool.php create mode 100644 app/Mcp/Tools/Webhook/DeleteWebhookTool.php create mode 100644 app/Mcp/Tools/Webhook/GetWebhookTool.php create mode 100644 app/Mcp/Tools/Webhook/ListWebhookLogsTool.php create mode 100644 app/Mcp/Tools/Webhook/ListWebhooksTool.php create mode 100644 app/Mcp/Tools/Webhook/ReplayWebhookLogTool.php create mode 100644 app/Mcp/Tools/Webhook/RotateWebhookSecretTool.php create mode 100644 app/Mcp/Tools/Webhook/SendWebhookTestTool.php create mode 100644 app/Mcp/Tools/Webhook/UpdateWebhookTool.php create mode 100644 tests/Feature/Api/WebhookApiTest.php create mode 100644 tests/Feature/Mcp/WebhookToolTest.php diff --git a/app/Actions/Webhook/CreateWebhook.php b/app/Actions/Webhook/CreateWebhook.php new file mode 100644 index 00000000..e8f41e76 --- /dev/null +++ b/app/Actions/Webhook/CreateWebhook.php @@ -0,0 +1,31 @@ + $data + */ + public static function execute(Workspace $workspace, array $data, WebhookService $webhooks): Webhook + { + $endpoint = (string) data_get($data, 'endpoint'); + + $webhooks->assertEndpointAllowed($endpoint); + + return Webhook::query()->create([ + 'workspace_id' => $workspace->id, + 'endpoint' => $endpoint, + 'events' => data_get($data, 'events'), + 'status' => Status::Enabled, + 'signing_secret' => Webhook::generateSigningSecret(), + ]); + } +} diff --git a/app/Actions/Webhook/DeleteWebhook.php b/app/Actions/Webhook/DeleteWebhook.php new file mode 100644 index 00000000..b61365c1 --- /dev/null +++ b/app/Actions/Webhook/DeleteWebhook.php @@ -0,0 +1,15 @@ +delete(); + } +} diff --git a/app/Actions/Webhook/ReplayWebhookLog.php b/app/Actions/Webhook/ReplayWebhookLog.php new file mode 100644 index 00000000..546eb07d --- /dev/null +++ b/app/Actions/Webhook/ReplayWebhookLog.php @@ -0,0 +1,22 @@ +event_type, + data_get($webhookLog->payload, 'data') ?? [], + force: true, + ); + } +} diff --git a/app/Actions/Webhook/RotateWebhookSecret.php b/app/Actions/Webhook/RotateWebhookSecret.php new file mode 100644 index 00000000..4450532d --- /dev/null +++ b/app/Actions/Webhook/RotateWebhookSecret.php @@ -0,0 +1,19 @@ +update([ + 'signing_secret' => Webhook::generateSigningSecret(), + ]); + + return $webhook->refresh(); + } +} diff --git a/app/Actions/Webhook/SendWebhookTest.php b/app/Actions/Webhook/SendWebhookTest.php new file mode 100644 index 00000000..e514e420 --- /dev/null +++ b/app/Actions/Webhook/SendWebhookTest.php @@ -0,0 +1,16 @@ +ping($webhook->endpoint, $webhook->signing_secret); + } +} diff --git a/app/Actions/Webhook/UpdateWebhook.php b/app/Actions/Webhook/UpdateWebhook.php new file mode 100644 index 00000000..75c0692a --- /dev/null +++ b/app/Actions/Webhook/UpdateWebhook.php @@ -0,0 +1,35 @@ + $data + */ + public static function execute(Webhook $webhook, array $data, WebhookService $webhooks): Webhook + { + $attributes = Arr::only($data, ['endpoint', 'events', 'status']); + $endpoint = data_get($attributes, 'endpoint'); + + if (is_string($endpoint) && $endpoint !== $webhook->endpoint) { + $webhooks->assertEndpointAllowed($endpoint); + } + + if (data_get($attributes, 'status') === Status::Enabled->value && $webhook->status !== Status::Enabled) { + $attributes['consecutive_failures'] = 0; + $attributes['paused_at'] = null; + } + + $webhook->update($attributes); + + return $webhook->refresh(); + } +} diff --git a/app/Http/Controllers/Api/WebhookController.php b/app/Http/Controllers/Api/WebhookController.php new file mode 100644 index 00000000..71e1f694 --- /dev/null +++ b/app/Http/Controllers/Api/WebhookController.php @@ -0,0 +1,152 @@ +authorize('viewAny', Webhook::class); + + $webhooks = Webhook::query() + ->where('workspace_id', $request->user()->currentWorkspace->id) + ->orderByDesc('created_at') + ->get(); + + return WebhookResource::collection($webhooks); + } + + public function store(StoreWebhookRequest $request, WebhookService $webhookService): JsonResponse + { + $this->authorize('create', Webhook::class); + + try { + $webhook = CreateWebhook::execute( + $request->user()->currentWorkspace, + $request->validated(), + $webhookService, + ); + } catch (RuntimeException $e) { + return $this->endpointFailure($e); + } + + return (new WebhookResource($webhook->makeVisible('signing_secret'))) + ->response() + ->setStatusCode(Response::HTTP_CREATED); + } + + public function show(Request $request, Webhook $webhook): WebhookResource + { + $this->authorize('view', $this->webhookInWorkspace($request, $webhook)); + + return new WebhookResource($webhook->makeVisible('signing_secret')); + } + + public function update(UpdateWebhookRequest $request, Webhook $webhook, WebhookService $webhookService): WebhookResource|JsonResponse + { + $this->authorize('update', $this->webhookInWorkspace($request, $webhook)); + + try { + $webhook = UpdateWebhook::execute($webhook, $request->validated(), $webhookService); + } catch (RuntimeException $e) { + return $this->endpointFailure($e); + } + + return new WebhookResource($webhook); + } + + public function sendTest(Request $request, Webhook $webhook, WebhookService $webhookService): JsonResponse + { + $this->authorize('update', $this->webhookInWorkspace($request, $webhook)); + + try { + SendWebhookTest::execute($webhook, $webhookService); + } catch (RuntimeException $e) { + return $this->endpointFailure($e); + } + + return response()->json(['tested' => true]); + } + + public function rotateSecret(Request $request, Webhook $webhook): WebhookResource + { + $this->authorize('update', $this->webhookInWorkspace($request, $webhook)); + + $webhook = RotateWebhookSecret::execute($webhook); + + return new WebhookResource($webhook->makeVisible('signing_secret')); + } + + public function logs(Request $request, Webhook $webhook): AnonymousResourceCollection + { + $this->authorize('view', $this->webhookInWorkspace($request, $webhook)); + + $logs = $webhook->logs() + ->orderByDesc('created_at') + ->paginate(15); + + return WebhookLogResource::collection($logs); + } + + public function replay(Request $request, Webhook $webhook, WebhookLog $webhookLog): JsonResponse + { + $webhook = $this->webhookInWorkspace($request, $webhook); + + if ($webhookLog->webhook_id !== $webhook->id) { + abort(Response::HTTP_NOT_FOUND); + } + + $this->authorize('replay', [$webhookLog, $webhook]); + + ReplayWebhookLog::execute($webhook, $webhookLog); + + return response()->json(['replayed' => true]); + } + + public function destroy(Request $request, Webhook $webhook): JsonResponse + { + $this->authorize('delete', $this->webhookInWorkspace($request, $webhook)); + + DeleteWebhook::execute($webhook); + + return response()->json(null, Response::HTTP_NO_CONTENT); + } + + private function webhookInWorkspace(Request $request, Webhook $webhook): Webhook + { + if ($webhook->workspace_id !== $request->user()->currentWorkspace->id) { + abort(Response::HTTP_NOT_FOUND); + } + + return $webhook; + } + + private function endpointFailure(RuntimeException $e): JsonResponse + { + return response()->json([ + 'message' => $e->getMessage(), + 'errors' => ['endpoint' => [$e->getMessage()]], + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } +} diff --git a/app/Http/Controllers/App/WebhookController.php b/app/Http/Controllers/App/WebhookController.php index 2a1c686d..b4bcb0b2 100644 --- a/app/Http/Controllers/App/WebhookController.php +++ b/app/Http/Controllers/App/WebhookController.php @@ -4,10 +4,14 @@ namespace App\Http\Controllers\App; -use App\Enums\Webhook\Status; +use App\Actions\Webhook\CreateWebhook; +use App\Actions\Webhook\DeleteWebhook; +use App\Actions\Webhook\ReplayWebhookLog; +use App\Actions\Webhook\RotateWebhookSecret; +use App\Actions\Webhook\SendWebhookTest; +use App\Actions\Webhook\UpdateWebhook; use App\Http\Requests\App\Webhook\StoreWebhookRequest; use App\Http\Requests\App\Webhook\UpdateWebhookRequest; -use App\Jobs\DispatchWebhook; use App\Models\Webhook; use App\Models\WebhookLog; use App\Services\WebhookService; @@ -53,21 +57,14 @@ public function store(StoreWebhookRequest $request, WebhookService $webhookServi $this->authorize('create', Webhook::class); - $validated = $request->validated(); - $endpoint = data_get($validated, 'endpoint'); - - if ($error = $this->endpointError($webhookService, $endpoint)) { - return $error; + try { + $webhook = CreateWebhook::execute($workspace, $request->validated(), $webhookService); + } catch (RuntimeException $e) { + return back()->withErrors([ + 'endpoint' => $e->getMessage(), + ]); } - $webhook = Webhook::query()->create([ - 'workspace_id' => $workspace->id, - 'endpoint' => $endpoint, - 'events' => data_get($validated, 'events'), - 'status' => Status::Enabled, - 'signing_secret' => Webhook::generateSigningSecret(), - ]); - session()->flash('flash.banner', __('webhooks.flash.created')); session()->flash('flash.bannerStyle', 'success'); @@ -78,20 +75,14 @@ public function update(UpdateWebhookRequest $request, Webhook $webhook, WebhookS { $this->authorize('update', $webhook); - $validated = $request->validated(); - $endpoint = data_get($validated, 'endpoint'); - - if ($endpoint !== $webhook->endpoint && ($error = $this->endpointError($webhookService, $endpoint))) { - return $error; + try { + UpdateWebhook::execute($webhook, $request->validated(), $webhookService); + } catch (RuntimeException $e) { + return back()->withErrors([ + 'endpoint' => $e->getMessage(), + ]); } - if (data_get($validated, 'status') === Status::Enabled->value) { - $validated['consecutive_failures'] = 0; - $validated['paused_at'] = null; - } - - $webhook->update($validated); - session()->flash('flash.banner', __('webhooks.flash.updated')); session()->flash('flash.bannerStyle', 'success'); @@ -103,7 +94,7 @@ public function sendTest(Webhook $webhook, WebhookService $webhookService): Redi $this->authorize('update', $webhook); try { - $webhookService->ping($webhook->endpoint, $webhook->signing_secret); + SendWebhookTest::execute($webhook, $webhookService); } catch (RuntimeException $e) { session()->flash('flash.banner', $e->getMessage()); session()->flash('flash.bannerStyle', 'danger'); @@ -121,9 +112,7 @@ public function rotateSecret(Webhook $webhook): RedirectResponse { $this->authorize('update', $webhook); - $webhook->update([ - 'signing_secret' => Webhook::generateSigningSecret(), - ]); + RotateWebhookSecret::execute($webhook); session()->flash('flash.banner', __('webhooks.flash.secret_rotated')); session()->flash('flash.bannerStyle', 'success'); @@ -133,14 +122,13 @@ public function rotateSecret(Webhook $webhook): RedirectResponse public function replay(Webhook $webhook, WebhookLog $webhookLog): RedirectResponse { + if ($webhookLog->webhook_id !== $webhook->id) { + abort(404); + } + $this->authorize('replay', [$webhookLog, $webhook]); - DispatchWebhook::dispatch( - $webhook, - $webhookLog->event_type, - data_get($webhookLog->payload, 'data') ?? [], - force: true, - ); + ReplayWebhookLog::execute($webhook, $webhookLog); session()->flash('flash.banner', __('webhooks.flash.replayed')); session()->flash('flash.bannerStyle', 'success'); @@ -152,28 +140,11 @@ public function destroy(Webhook $webhook): RedirectResponse { $this->authorize('delete', $webhook); - $webhook->delete(); + DeleteWebhook::execute($webhook); session()->flash('flash.banner', __('webhooks.flash.deleted')); session()->flash('flash.bannerStyle', 'success'); return redirect()->route('app.webhooks.index'); } - - private function endpointError(WebhookService $webhookService, mixed $endpoint): ?RedirectResponse - { - if (! is_string($endpoint)) { - return null; - } - - try { - $webhookService->assertEndpointAllowed($endpoint); - } catch (RuntimeException $e) { - return back()->withErrors([ - 'endpoint' => $e->getMessage(), - ]); - } - - return null; - } } diff --git a/app/Http/Requests/Api/Webhook/StoreWebhookRequest.php b/app/Http/Requests/Api/Webhook/StoreWebhookRequest.php new file mode 100644 index 00000000..8efe588c --- /dev/null +++ b/app/Http/Requests/Api/Webhook/StoreWebhookRequest.php @@ -0,0 +1,29 @@ + + */ + public function rules(): array + { + return [ + 'endpoint' => ['required', 'url', 'max:255'], + 'events' => ['required', 'array', 'min:1'], + 'events.*' => ['string', Rule::enum(EventType::class)], + ]; + } +} diff --git a/app/Http/Requests/Api/Webhook/UpdateWebhookRequest.php b/app/Http/Requests/Api/Webhook/UpdateWebhookRequest.php new file mode 100644 index 00000000..3ed21084 --- /dev/null +++ b/app/Http/Requests/Api/Webhook/UpdateWebhookRequest.php @@ -0,0 +1,31 @@ + + */ + public function rules(): array + { + return [ + 'endpoint' => ['sometimes', 'url', 'max:255'], + 'events' => ['sometimes', 'array', 'min:1'], + 'events.*' => ['string', Rule::enum(EventType::class)], + 'status' => ['sometimes', 'string', Rule::enum(Status::class)->only([Status::Enabled, Status::Disabled])], + ]; + } +} diff --git a/app/Http/Resources/Api/WebhookLogResource.php b/app/Http/Resources/Api/WebhookLogResource.php new file mode 100644 index 00000000..045f2aed --- /dev/null +++ b/app/Http/Resources/Api/WebhookLogResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'event_type' => $this->event_type, + 'payload' => $this->payload, + 'response_status' => $this->response_status, + 'response_body' => $this->response_body, + 'delivered_at' => $this->delivered_at?->toIso8601String(), + 'failed_at' => $this->failed_at?->toIso8601String(), + 'attempts' => $this->attempts, + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/WebhookResource.php b/app/Http/Resources/Api/WebhookResource.php new file mode 100644 index 00000000..aaff500e --- /dev/null +++ b/app/Http/Resources/Api/WebhookResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'endpoint' => $this->endpoint, + 'events' => $this->events, + 'status' => $this->status->value, + 'last_sent_at' => $this->last_sent_at?->toIso8601String(), + 'paused_at' => $this->paused_at?->toIso8601String(), + 'consecutive_failures' => $this->consecutive_failures, + 'signing_secret' => $this->when( + ! in_array('signing_secret', $this->resource->getHidden(), true), + $this->signing_secret, + ), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Mcp/Concerns/ResolvesWorkspaceWebhook.php b/app/Mcp/Concerns/ResolvesWorkspaceWebhook.php new file mode 100644 index 00000000..07d19139 --- /dev/null +++ b/app/Mcp/Concerns/ResolvesWorkspaceWebhook.php @@ -0,0 +1,38 @@ +where('workspace_id', $workspace->id) + ->find($id); + + if (! $webhook instanceof Webhook) { + return Response::error('Webhook not found.'); + } + + return $webhook; + } + + protected function webhookLogFor(Webhook $webhook, mixed $id): WebhookLog|Response|ResponseFactory + { + $log = $webhook->logs()->find($id); + + if (! $log instanceof WebhookLog) { + return Response::error('Webhook log not found.'); + } + + return $log; + } +} diff --git a/app/Mcp/Requests/Webhook/CreateWebhookRequest.php b/app/Mcp/Requests/Webhook/CreateWebhookRequest.php new file mode 100644 index 00000000..cde14bb7 --- /dev/null +++ b/app/Mcp/Requests/Webhook/CreateWebhookRequest.php @@ -0,0 +1,23 @@ + + */ + public static function rules(): array + { + return [ + 'endpoint' => ['required', 'url', 'max:255'], + 'events' => ['required', 'array', 'min:1'], + 'events.*' => ['string', Rule::enum(EventType::class)], + ]; + } +} diff --git a/app/Mcp/Requests/Webhook/ListWebhookLogsRequest.php b/app/Mcp/Requests/Webhook/ListWebhookLogsRequest.php new file mode 100644 index 00000000..d50ac897 --- /dev/null +++ b/app/Mcp/Requests/Webhook/ListWebhookLogsRequest.php @@ -0,0 +1,19 @@ + + */ + public static function rules(): array + { + return [ + 'webhook_id' => ['required', 'string'], + 'limit' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Mcp/Requests/Webhook/ReplayWebhookLogRequest.php b/app/Mcp/Requests/Webhook/ReplayWebhookLogRequest.php new file mode 100644 index 00000000..16d1df1c --- /dev/null +++ b/app/Mcp/Requests/Webhook/ReplayWebhookLogRequest.php @@ -0,0 +1,19 @@ + + */ + public static function rules(): array + { + return [ + 'webhook_id' => ['required', 'string'], + 'log_id' => ['required', 'string'], + ]; + } +} diff --git a/app/Mcp/Requests/Webhook/UpdateWebhookRequest.php b/app/Mcp/Requests/Webhook/UpdateWebhookRequest.php new file mode 100644 index 00000000..97f44daf --- /dev/null +++ b/app/Mcp/Requests/Webhook/UpdateWebhookRequest.php @@ -0,0 +1,26 @@ + + */ + public static function rules(): array + { + return [ + 'webhook_id' => ['required', 'string'], + 'endpoint' => ['sometimes', 'url', 'max:255'], + 'events' => ['sometimes', 'array', 'min:1'], + 'events.*' => ['string', Rule::enum(EventType::class)], + 'status' => ['sometimes', 'string', Rule::enum(Status::class)->only([Status::Enabled, Status::Disabled])], + ]; + } +} diff --git a/app/Mcp/Requests/Webhook/WebhookIdRequest.php b/app/Mcp/Requests/Webhook/WebhookIdRequest.php new file mode 100644 index 00000000..724ea745 --- /dev/null +++ b/app/Mcp/Requests/Webhook/WebhookIdRequest.php @@ -0,0 +1,18 @@ + + */ + public static function rules(): array + { + return [ + 'webhook_id' => ['required', 'string'], + ]; + } +} diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index a8d98810..e2b0bffb 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -34,6 +34,15 @@ use App\Mcp\Tools\SocialAccount\ListPinterestBoardsTool; use App\Mcp\Tools\SocialAccount\ListSocialAccountsTool; use App\Mcp\Tools\SocialAccount\ToggleSocialAccountTool; +use App\Mcp\Tools\Webhook\CreateWebhookTool; +use App\Mcp\Tools\Webhook\DeleteWebhookTool; +use App\Mcp\Tools\Webhook\GetWebhookTool; +use App\Mcp\Tools\Webhook\ListWebhookLogsTool; +use App\Mcp\Tools\Webhook\ListWebhooksTool; +use App\Mcp\Tools\Webhook\ReplayWebhookLogTool; +use App\Mcp\Tools\Webhook\RotateWebhookSecretTool; +use App\Mcp\Tools\Webhook\SendWebhookTestTool; +use App\Mcp\Tools\Webhook\UpdateWebhookTool; use App\Mcp\Tools\Workspace\GetWorkspaceTool; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Attributes\Icon; @@ -44,7 +53,7 @@ #[Name('TryPost')] #[Version('1.0.0')] #[Icon('images/trypost/icon.png', mimeType: 'image/png')] -#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, the Asset Library, signatures, labels, social accounts, workspaces, and API keys.')] +#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, the Asset Library, signatures, labels, social accounts, workspaces, outgoing webhooks, and API keys.')] class TryPostServer extends Server { public int $defaultPaginationLength = 100; @@ -89,6 +98,17 @@ class TryPostServer extends Server ListDiscordChannelsTool::class, ToggleSocialAccountTool::class, + // Webhooks + ListWebhooksTool::class, + GetWebhookTool::class, + CreateWebhookTool::class, + UpdateWebhookTool::class, + DeleteWebhookTool::class, + SendWebhookTestTool::class, + RotateWebhookSecretTool::class, + ListWebhookLogsTool::class, + ReplayWebhookLogTool::class, + // Workspace GetWorkspaceTool::class, diff --git a/app/Mcp/Tools/Webhook/CreateWebhookTool.php b/app/Mcp/Tools/Webhook/CreateWebhookTool.php new file mode 100644 index 00000000..1d05db70 --- /dev/null +++ b/app/Mcp/Tools/Webhook/CreateWebhookTool.php @@ -0,0 +1,67 @@ +authorizeCurrentWorkspace( + $request, + 'manageWebhooks', + 'Not authorized to manage webhooks.', + ); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(CreateWebhookRequest::rules()); + + try { + $webhook = CreateWebhook::execute($workspace, $validated, $this->webhooks); + } catch (RuntimeException $e) { + return Response::error($e->getMessage()); + } + + return Response::structured( + (new WebhookResource($webhook->makeVisible('signing_secret')))->resolve(), + ); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'endpoint' => $schema->string()->required()->description('Public URL that will receive signed webhook payloads. Private or local addresses are rejected.'), + 'events' => $schema->array() + ->items($schema->string()->enum(array_column(EventType::cases(), 'value'))) + ->required() + ->description('At least one event to subscribe to: post.created, post.scheduled, post.unscheduled, post.published, post.partially_published, post.failed, post.deleted.'), + ]; + } +} diff --git a/app/Mcp/Tools/Webhook/DeleteWebhookTool.php b/app/Mcp/Tools/Webhook/DeleteWebhookTool.php new file mode 100644 index 00000000..6cdd27c6 --- /dev/null +++ b/app/Mcp/Tools/Webhook/DeleteWebhookTool.php @@ -0,0 +1,62 @@ +authorizeCurrentWorkspace( + $request, + 'manageWebhooks', + 'Not authorized to manage webhooks.', + ); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(WebhookIdRequest::rules()); + + $webhook = $this->webhookInWorkspace($workspace, data_get($validated, 'webhook_id')); + + if (! $webhook instanceof Webhook) { + return $webhook; + } + + DeleteWebhook::execute($webhook); + + return Response::structured(['deleted' => true]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'webhook_id' => $schema->string()->required()->description('The webhook ID to delete.'), + ]; + } +} diff --git a/app/Mcp/Tools/Webhook/GetWebhookTool.php b/app/Mcp/Tools/Webhook/GetWebhookTool.php new file mode 100644 index 00000000..f33896c6 --- /dev/null +++ b/app/Mcp/Tools/Webhook/GetWebhookTool.php @@ -0,0 +1,62 @@ +authorizeCurrentWorkspace( + $request, + 'manageWebhooks', + 'Not authorized to manage webhooks.', + ); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(WebhookIdRequest::rules()); + + $webhook = $this->webhookInWorkspace($workspace, data_get($validated, 'webhook_id')); + + if (! $webhook instanceof Webhook) { + return $webhook; + } + + return Response::structured( + (new WebhookResource($webhook->makeVisible('signing_secret')))->resolve(), + ); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'webhook_id' => $schema->string()->required()->description('The webhook ID.'), + ]; + } +} diff --git a/app/Mcp/Tools/Webhook/ListWebhookLogsTool.php b/app/Mcp/Tools/Webhook/ListWebhookLogsTool.php new file mode 100644 index 00000000..a522b48d --- /dev/null +++ b/app/Mcp/Tools/Webhook/ListWebhookLogsTool.php @@ -0,0 +1,68 @@ +authorizeCurrentWorkspace( + $request, + 'manageWebhooks', + 'Not authorized to manage webhooks.', + ); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(ListWebhookLogsRequest::rules()); + + $webhook = $this->webhookInWorkspace($workspace, data_get($validated, 'webhook_id')); + + if (! $webhook instanceof Webhook) { + return $webhook; + } + + $logs = $webhook->logs() + ->orderByDesc('created_at') + ->limit((int) data_get($validated, 'limit', 50)) + ->get(); + + return Response::structured([ + 'logs' => WebhookLogResource::collection($logs)->resolve(), + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'webhook_id' => $schema->string()->required()->description('The webhook ID.'), + 'limit' => $schema->integer()->description('Max results (1-100, default 50).'), + ]; + } +} diff --git a/app/Mcp/Tools/Webhook/ListWebhooksTool.php b/app/Mcp/Tools/Webhook/ListWebhooksTool.php new file mode 100644 index 00000000..50fb1aa5 --- /dev/null +++ b/app/Mcp/Tools/Webhook/ListWebhooksTool.php @@ -0,0 +1,45 @@ +authorizeCurrentWorkspace( + $request, + 'manageWebhooks', + 'Not authorized to manage webhooks.', + ); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $webhooks = Webhook::query() + ->where('workspace_id', $workspace->id) + ->orderByDesc('created_at') + ->get(); + + return Response::structured([ + 'webhooks' => WebhookResource::collection($webhooks)->resolve(), + ]); + } +} diff --git a/app/Mcp/Tools/Webhook/ReplayWebhookLogTool.php b/app/Mcp/Tools/Webhook/ReplayWebhookLogTool.php new file mode 100644 index 00000000..caf4975b --- /dev/null +++ b/app/Mcp/Tools/Webhook/ReplayWebhookLogTool.php @@ -0,0 +1,70 @@ +authorizeCurrentWorkspace( + $request, + 'manageWebhooks', + 'Not authorized to manage webhooks.', + ); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(ReplayWebhookLogRequest::rules()); + + $webhook = $this->webhookInWorkspace($workspace, data_get($validated, 'webhook_id')); + + if (! $webhook instanceof Webhook) { + return $webhook; + } + + $log = $this->webhookLogFor($webhook, data_get($validated, 'log_id')); + + if (! $log instanceof WebhookLog) { + return $log; + } + + ReplayWebhookLog::execute($webhook, $log); + + return Response::structured(['replayed' => true]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'webhook_id' => $schema->string()->required()->description('The webhook ID.'), + 'log_id' => $schema->string()->required()->description('The delivery log ID to replay.'), + ]; + } +} diff --git a/app/Mcp/Tools/Webhook/RotateWebhookSecretTool.php b/app/Mcp/Tools/Webhook/RotateWebhookSecretTool.php new file mode 100644 index 00000000..b983d5c7 --- /dev/null +++ b/app/Mcp/Tools/Webhook/RotateWebhookSecretTool.php @@ -0,0 +1,65 @@ +authorizeCurrentWorkspace( + $request, + 'manageWebhooks', + 'Not authorized to manage webhooks.', + ); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(WebhookIdRequest::rules()); + + $webhook = $this->webhookInWorkspace($workspace, data_get($validated, 'webhook_id')); + + if (! $webhook instanceof Webhook) { + return $webhook; + } + + $webhook = RotateWebhookSecret::execute($webhook); + + return Response::structured( + (new WebhookResource($webhook->makeVisible('signing_secret')))->resolve(), + ); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'webhook_id' => $schema->string()->required()->description('The webhook ID whose secret should be rotated.'), + ]; + } +} diff --git a/app/Mcp/Tools/Webhook/SendWebhookTestTool.php b/app/Mcp/Tools/Webhook/SendWebhookTestTool.php new file mode 100644 index 00000000..031842eb --- /dev/null +++ b/app/Mcp/Tools/Webhook/SendWebhookTestTool.php @@ -0,0 +1,70 @@ +authorizeCurrentWorkspace( + $request, + 'manageWebhooks', + 'Not authorized to manage webhooks.', + ); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(WebhookIdRequest::rules()); + + $webhook = $this->webhookInWorkspace($workspace, data_get($validated, 'webhook_id')); + + if (! $webhook instanceof Webhook) { + return $webhook; + } + + try { + SendWebhookTest::execute($webhook, $this->webhooks); + } catch (RuntimeException $e) { + return Response::error($e->getMessage()); + } + + return Response::structured(['tested' => true]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'webhook_id' => $schema->string()->required()->description('The webhook ID to ping.'), + ]; + } +} diff --git a/app/Mcp/Tools/Webhook/UpdateWebhookTool.php b/app/Mcp/Tools/Webhook/UpdateWebhookTool.php new file mode 100644 index 00000000..2a787bec --- /dev/null +++ b/app/Mcp/Tools/Webhook/UpdateWebhookTool.php @@ -0,0 +1,83 @@ +authorizeCurrentWorkspace( + $request, + 'manageWebhooks', + 'Not authorized to manage webhooks.', + ); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(UpdateWebhookRequest::rules()); + + $webhook = $this->webhookInWorkspace($workspace, data_get($validated, 'webhook_id')); + + if (! $webhook instanceof Webhook) { + return $webhook; + } + + try { + $webhook = UpdateWebhook::execute( + $webhook, + Arr::except($validated, ['webhook_id']), + $this->webhooks, + ); + } catch (RuntimeException $e) { + return Response::error($e->getMessage()); + } + + return Response::structured((new WebhookResource($webhook))->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'webhook_id' => $schema->string()->required()->description('The webhook ID.'), + 'endpoint' => $schema->string()->description('New public URL. Private or local addresses are rejected. Omitted fields are left unchanged.'), + 'events' => $schema->array() + ->items($schema->string()->enum(array_column(EventType::cases(), 'value'))) + ->description('Replacement event list. Must contain at least one event when provided.'), + 'status' => $schema->string() + ->enum([Status::Enabled->value, Status::Disabled->value]) + ->description('Set to enabled or disabled. Paused webhooks can only be re-enabled, not set to paused.'), + ]; + } +} diff --git a/routes/api.php b/routes/api.php index f1c1f469..138fdcbc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Api\SignatureController; use App\Http\Controllers\Api\SocialAccountController; use App\Http\Controllers\Api\UploadController; +use App\Http\Controllers\Api\WebhookController; use App\Http\Controllers\Api\WorkspaceController; use Illuminate\Support\Facades\Route; @@ -63,6 +64,17 @@ ->middleware('throttle:60,1') ->name('api.social-accounts.channels'); + // Webhooks + Route::get('/webhooks', [WebhookController::class, 'index'])->name('api.webhooks.index'); + Route::post('/webhooks', [WebhookController::class, 'store'])->name('api.webhooks.store'); + Route::get('/webhooks/{webhook}', [WebhookController::class, 'show'])->name('api.webhooks.show'); + Route::put('/webhooks/{webhook}', [WebhookController::class, 'update'])->name('api.webhooks.update'); + Route::post('/webhooks/{webhook}/send-test', [WebhookController::class, 'sendTest'])->name('api.webhooks.send-test'); + Route::post('/webhooks/{webhook}/rotate-secret', [WebhookController::class, 'rotateSecret'])->name('api.webhooks.rotate-secret'); + Route::get('/webhooks/{webhook}/logs', [WebhookController::class, 'logs'])->name('api.webhooks.logs'); + Route::post('/webhooks/{webhook}/logs/{webhookLog}/replay', [WebhookController::class, 'replay'])->name('api.webhooks.replay'); + Route::delete('/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('api.webhooks.destroy'); + // API Keys Route::get('/api-keys', [ApiKeyController::class, 'index'])->name('api.api-keys.index'); Route::post('/api-keys', [ApiKeyController::class, 'store'])->name('api.api-keys.store'); diff --git a/tests/Feature/Api/WebhookApiTest.php b/tests/Feature/Api/WebhookApiTest.php new file mode 100644 index 00000000..59b8a079 --- /dev/null +++ b/tests/Feature/Api/WebhookApiTest.php @@ -0,0 +1,599 @@ +user = $result['user']; + $this->workspace = $result['workspace']; + $this->plainToken = $result['plain_token']; + + $mock = Mockery::mock(WebhookService::class); + $mock->shouldReceive('assertEndpointAllowed')->andReturnNull(); + $mock->shouldReceive('ping')->andReturnNull(); + $this->app->instance(WebhookService::class, $mock); +}); + +test('list webhooks omits the signing secret', function () { + Webhook::factory()->count(2)->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->getJson( + route('api.webhooks.index'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertOk(); + $response->assertJsonCount(2); + expect($response->json('0'))->not->toHaveKey('signing_secret'); +}); + +test('list webhooks does not include other workspace webhooks', function () { + Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'endpoint' => 'https://own.example.com/hook', + ]); + Webhook::factory()->create([ + 'endpoint' => 'https://other.example.com/hook', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->getJson( + route('api.webhooks.index'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertOk(); + $response->assertJsonCount(1); + $response->assertJsonPath('0.endpoint', 'https://own.example.com/hook'); +}); + +test('create webhook returns the signing secret', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.store'), + [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => [EventType::PostPublished->value, EventType::PostFailed->value], + ], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertCreated(); + $response->assertJsonPath('endpoint', 'https://example.com/webhooks'); + $response->assertJsonPath('status', Status::Enabled->value); + expect($response->json('events'))->toEqual([ + EventType::PostPublished->value, + EventType::PostFailed->value, + ]); + expect($response->json('signing_secret'))->toStartWith('whsec_'); +}); + +test('create webhook validation errors', function () { + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.store'), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertUnprocessable() + ->assertJsonValidationErrors(['endpoint', 'events']); +}); + +test('create webhook fails when the endpoint is not allowed', function () { + $failingMock = Mockery::mock(WebhookService::class); + $failingMock->shouldReceive('assertEndpointAllowed') + ->andThrow(new RuntimeException(__('webhooks.errors.endpoint_not_allowed'))); + $this->app->instance(WebhookService::class, $failingMock); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.store'), + [ + 'endpoint' => 'http://127.0.0.1/webhooks', + 'events' => [EventType::PostPublished->value], + ], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertUnprocessable() + ->assertJsonValidationErrors(['endpoint']); + + $this->assertDatabaseMissing('webhooks', [ + 'endpoint' => 'http://127.0.0.1/webhooks', + ]); +}); + +test('create webhook does not ping the endpoint', function () { + $mock = Mockery::mock(WebhookService::class); + $mock->shouldReceive('assertEndpointAllowed')->once(); + $mock->shouldNotReceive('ping'); + $this->app->instance(WebhookService::class, $mock); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.store'), + [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => [EventType::PostPublished->value], + ], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertCreated(); +}); + +test('create webhook rejects wildcard and invalid events', function (array $events) { + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.store'), + [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => $events, + ], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertUnprocessable() + ->assertJsonValidationErrors(['events.0']); +})->with([ + [['*']], + [['foo.bar']], +]); + +test('show webhook includes the signing secret', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'signing_secret' => 'whsec_visible', + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->getJson( + route('api.webhooks.show', $webhook), + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertOk() + ->assertJsonPath('id', $webhook->id) + ->assertJsonPath('signing_secret', 'whsec_visible'); +}); + +test('update webhook', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->putJson( + route('api.webhooks.update', $webhook), + [ + 'endpoint' => 'https://hooks.example.com/updated', + 'status' => Status::Disabled->value, + ], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertOk(); + $response->assertJsonPath('endpoint', 'https://hooks.example.com/updated'); + $response->assertJsonPath('status', Status::Disabled->value); + expect($response->json())->not->toHaveKey('signing_secret'); +}); + +test('update webhook fails when the endpoint is not allowed', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'endpoint' => 'https://old.example.com/hook', + ]); + + $failingMock = Mockery::mock(WebhookService::class); + $failingMock->shouldReceive('assertEndpointAllowed') + ->andThrow(new RuntimeException(__('webhooks.errors.endpoint_not_allowed'))); + $this->app->instance(WebhookService::class, $failingMock); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->putJson( + route('api.webhooks.update', $webhook), + ['endpoint' => 'http://127.0.0.1/webhooks'], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertUnprocessable() + ->assertJsonValidationErrors(['endpoint']); + + expect($webhook->fresh()->endpoint)->toBe('https://old.example.com/hook'); +}); + +test('update webhook does not ping the endpoint', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'endpoint' => 'https://old.example.com/hook', + ]); + + $mock = Mockery::mock(WebhookService::class); + $mock->shouldReceive('assertEndpointAllowed')->once()->with('https://updated.example.com/hook'); + $mock->shouldNotReceive('ping'); + $this->app->instance(WebhookService::class, $mock); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->putJson( + route('api.webhooks.update', $webhook), + ['endpoint' => 'https://updated.example.com/hook'], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertOk(); +}); + +test('update webhook rejects wildcard and invalid events', function (array $events) { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->putJson( + route('api.webhooks.update', $webhook), + ['events' => $events], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertUnprocessable() + ->assertJsonValidationErrors(['events.0']); +})->with([ + [['*']], + [['foo.bar']], +]); + +test('update webhook cannot set paused status', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->putJson( + route('api.webhooks.update', $webhook), + ['status' => Status::Paused->value], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertUnprocessable() + ->assertJsonValidationErrors(['status']); +}); + +test('re-enabling a webhook resets consecutive failures', function () { + $webhook = Webhook::factory()->paused()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->putJson( + route('api.webhooks.update', $webhook), + ['status' => Status::Enabled->value], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertOk() + ->assertJsonPath('status', Status::Enabled->value) + ->assertJsonPath('consecutive_failures', 0) + ->assertJsonPath('paused_at', null); +}); + +test('updating an already enabled webhook does not reset consecutive failures', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'consecutive_failures' => 3, + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->putJson( + route('api.webhooks.update', $webhook), + ['status' => Status::Enabled->value], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertOk() + ->assertJsonPath('consecutive_failures', 3); +}); + +test('send test succeeds', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.send-test', $webhook), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertOk() + ->assertJsonPath('tested', true); +}); + +test('send test failure returns 422', function () { + $failingMock = Mockery::mock(WebhookService::class); + $failingMock->shouldReceive('assertEndpointAllowed')->andReturnNull(); + $failingMock->shouldReceive('ping') + ->andThrow(new RuntimeException('Endpoint unreachable')); + $this->app->instance(WebhookService::class, $failingMock); + + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.send-test', $webhook), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertUnprocessable() + ->assertJsonPath('message', 'Endpoint unreachable') + ->assertJsonValidationErrors(['endpoint']); +}); + +test('rotate secret returns a new signing secret', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'signing_secret' => 'whsec_oldsecretoldsecretoldsecre', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.rotate-secret', $webhook), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertOk(); + expect($response->json('signing_secret')) + ->toStartWith('whsec_') + ->not->toBe('whsec_oldsecretoldsecretoldsecre'); +}); + +test('list logs paginates at 15', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + WebhookLog::factory()->count(16)->create([ + 'webhook_id' => $webhook->id, + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->getJson( + route('api.webhooks.logs', $webhook), + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertOk() + ->assertJsonCount(15, 'data') + ->assertJsonPath('meta.per_page', 15) + ->assertJsonPath('data.0.event_type', EventType::PostPublished->value); +}); + +test('replay webhook log', function () { + Queue::fake(); + + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + $log = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + 'event_type' => EventType::PostPublished->value, + 'payload' => [ + 'type' => EventType::PostPublished->value, + 'data' => ['id' => 'post-1'], + ], + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.replay', [$webhook, $log]), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertOk() + ->assertJsonPath('replayed', true); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($webhook) { + return $job->webhook->id === $webhook->id + && $job->eventType === EventType::PostPublished->value + && data_get($job->payload, 'id') === 'post-1' + && $job->force; + }); +}); + +test('replay of a log from another webhook is not found', function () { + Queue::fake(); + + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + $otherWebhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + $log = WebhookLog::factory()->create([ + 'webhook_id' => $otherWebhook->id, + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.replay', [$webhook, $log]), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertNotFound(); + + Queue::assertNothingPushed(); +}); + +test('delete webhook', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->deleteJson( + route('api.webhooks.destroy', $webhook), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertStatus(Response::HTTP_NO_CONTENT); + + $this->assertDatabaseMissing('webhooks', ['id' => $webhook->id]); +}); + +test('webhooks from another workspace are not found', function () { + $other = Webhook::factory()->create(); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->getJson( + route('api.webhooks.show', $other), + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertNotFound(); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->putJson( + route('api.webhooks.update', $other), + ['status' => Status::Disabled->value], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertNotFound(); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->deleteJson( + route('api.webhooks.destroy', $other), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertNotFound(); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.send-test', $other), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertNotFound(); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.rotate-secret', $other), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertNotFound(); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->getJson( + route('api.webhooks.logs', $other), + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertNotFound(); + + $log = WebhookLog::factory()->create([ + 'webhook_id' => $other->id, + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->plainToken, + ])->postJson( + route('api.webhooks.replay', [$other, $log]), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ) + ->assertNotFound(); +}); + +test('members and viewers cannot manage webhooks through the api', function (Role $role) { + $teammate = User::factory()->create(['account_id' => $this->user->account_id]); + $this->workspace->members()->attach($teammate->id, ['role' => $role->value]); + $teammate->update(['current_workspace_id' => $this->workspace->id]); + $plainToken = passportToken($teammate, $this->workspace); + + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + $log = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + ]); + + $this->withHeaders(['Authorization' => "Bearer {$plainToken}"]) + ->getJson(route('api.webhooks.index')) + ->assertForbidden(); + + $this->withHeaders(['Authorization' => "Bearer {$plainToken}"]) + ->postJson(route('api.webhooks.store'), [ + 'endpoint' => 'https://member.example.com/webhooks', + 'events' => [EventType::PostPublished->value], + ]) + ->assertForbidden(); + + $this->withHeaders(['Authorization' => "Bearer {$plainToken}"]) + ->getJson(route('api.webhooks.show', $webhook)) + ->assertForbidden(); + + $this->withHeaders(['Authorization' => "Bearer {$plainToken}"]) + ->putJson(route('api.webhooks.update', $webhook), [ + 'status' => Status::Disabled->value, + ]) + ->assertForbidden(); + + $this->withHeaders(['Authorization' => "Bearer {$plainToken}"]) + ->postJson(route('api.webhooks.send-test', $webhook)) + ->assertForbidden(); + + $this->withHeaders(['Authorization' => "Bearer {$plainToken}"]) + ->postJson(route('api.webhooks.rotate-secret', $webhook)) + ->assertForbidden(); + + $this->withHeaders(['Authorization' => "Bearer {$plainToken}"]) + ->getJson(route('api.webhooks.logs', $webhook)) + ->assertForbidden(); + + $this->withHeaders(['Authorization' => "Bearer {$plainToken}"]) + ->postJson(route('api.webhooks.replay', [$webhook, $log])) + ->assertForbidden(); + + $this->withHeaders(['Authorization' => "Bearer {$plainToken}"]) + ->deleteJson(route('api.webhooks.destroy', $webhook)) + ->assertForbidden(); + + $this->assertDatabaseHas('webhooks', ['id' => $webhook->id]); + $this->assertDatabaseMissing('webhooks', [ + 'endpoint' => 'https://member.example.com/webhooks', + ]); +})->with([ + Role::Member, + Role::Viewer, +]); diff --git a/tests/Feature/App/WebhookTest.php b/tests/Feature/App/WebhookTest.php index 0bfa074b..74c875d4 100644 --- a/tests/Feature/App/WebhookTest.php +++ b/tests/Feature/App/WebhookTest.php @@ -161,6 +161,21 @@ expect($webhook->paused_at)->toBeNull(); })->with(['paused', 'disabled']); +test('updating an already enabled webhook does not reset consecutive failures', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'consecutive_failures' => 3, + ]); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'status' => 'enabled', + ]) + ->assertRedirect(); + + expect($webhook->fresh()->consecutive_failures)->toBe(3); +}); + test('webhook endpoint is required', function () { $this->actingAs($this->user) ->post(route('app.webhooks.store'), [ diff --git a/tests/Feature/Mcp/McpRoleAuthorizationTest.php b/tests/Feature/Mcp/McpRoleAuthorizationTest.php index 465dbb0e..181fdaf9 100644 --- a/tests/Feature/Mcp/McpRoleAuthorizationTest.php +++ b/tests/Feature/Mcp/McpRoleAuthorizationTest.php @@ -24,6 +24,8 @@ use App\Mcp\Tools\SocialAccount\ListPinterestBoardsTool; use App\Mcp\Tools\SocialAccount\ListSocialAccountsTool; use App\Mcp\Tools\SocialAccount\ToggleSocialAccountTool; +use App\Mcp\Tools\Webhook\CreateWebhookTool; +use App\Mcp\Tools\Webhook\ListWebhooksTool; use App\Models\Media; use App\Models\Post; use App\Models\SocialAccount; @@ -220,3 +222,28 @@ expect($account->fresh()->is_active)->toBeFalse(); }); + +test('viewers and members cannot list or create webhooks via mcp', function (Role $role) { + $user = $role === Role::Viewer ? $this->viewer : $this->member; + + TryPostServer::actingAs($user) + ->tool(ListWebhooksTool::class, []) + ->assertHasErrors(['Not authorized to manage webhooks.']); + + TryPostServer::actingAs($user) + ->tool(CreateWebhookTool::class, [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => ['post.published'], + ]) + ->assertHasErrors(['Not authorized to manage webhooks.']); +})->with([ + Role::Viewer, + Role::Member, +]); + +test('admins can list webhooks via mcp', function () { + TryPostServer::actingAs($this->owner) + ->tool(ListWebhooksTool::class, []) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->has('webhooks', 0)->etc()); +}); diff --git a/tests/Feature/Mcp/WebhookToolTest.php b/tests/Feature/Mcp/WebhookToolTest.php new file mode 100644 index 00000000..2ad40284 --- /dev/null +++ b/tests/Feature/Mcp/WebhookToolTest.php @@ -0,0 +1,501 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + + $mock = Mockery::mock(WebhookService::class); + $mock->shouldReceive('assertEndpointAllowed')->andReturnNull(); + $mock->shouldReceive('ping')->andReturnNull(); + $this->app->instance(WebhookService::class, $mock); +}); + +test('list webhooks returns wrapped webhooks without signing secret', function () { + Webhook::factory()->count(2)->create(['workspace_id' => $this->workspace->id]); + + TryPostServer::actingAs($this->user) + ->tool(ListWebhooksTool::class, []) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->has('webhooks', 2, function (AssertableJson $webhook) { + $webhook->hasAll(['id', 'endpoint', 'events', 'status', 'created_at', 'updated_at']) + ->missing('signing_secret') + ->missing('workspace_id') + ->etc(); + }); + }); +}); + +test('list webhooks only returns own workspace webhooks', function () { + Webhook::factory()->create(['workspace_id' => $this->workspace->id]); + Webhook::factory()->create(); + + TryPostServer::actingAs($this->user) + ->tool(ListWebhooksTool::class, []) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->has('webhooks', 1)->etc()); +}); + +test('get webhook includes the signing secret', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'signing_secret' => 'whsec_visible', + ]); + + TryPostServer::actingAs($this->user) + ->tool(GetWebhookTool::class, ['webhook_id' => $webhook->id]) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) use ($webhook) { + $json->where('id', $webhook->id) + ->where('signing_secret', 'whsec_visible') + ->etc(); + }); +}); + +test('cannot get webhook from another workspace', function () { + $webhook = Webhook::factory()->create(); + + TryPostServer::actingAs($this->user) + ->tool(GetWebhookTool::class, ['webhook_id' => $webhook->id]) + ->assertHasErrors(['Webhook not found.']); +}); + +test('create webhook returns signing secret', function () { + TryPostServer::actingAs($this->user) + ->tool(CreateWebhookTool::class, [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => [EventType::PostPublished->value], + ]) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->where('endpoint', 'https://example.com/webhooks') + ->where('status', Status::Enabled->value) + ->has('signing_secret') + ->etc(); + }); + + expect($this->workspace->webhooks()->count())->toBe(1); +}); + +test('create webhook validates required fields', function () { + TryPostServer::actingAs($this->user) + ->tool(CreateWebhookTool::class, []) + ->assertHasErrors(); +}); + +test('create webhook rejects wildcard events', function () { + TryPostServer::actingAs($this->user) + ->tool(CreateWebhookTool::class, [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => ['*'], + ]) + ->assertHasErrors(); +}); + +test('create webhook does not ping the endpoint', function () { + $mock = Mockery::mock(WebhookService::class); + $mock->shouldReceive('assertEndpointAllowed')->once(); + $mock->shouldNotReceive('ping'); + $this->app->instance(WebhookService::class, $mock); + + TryPostServer::actingAs($this->user) + ->tool(CreateWebhookTool::class, [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => [EventType::PostPublished->value], + ]) + ->assertOk(); +}); + +test('create webhook fails when the endpoint is not allowed', function () { + $failingMock = Mockery::mock(WebhookService::class); + $failingMock->shouldReceive('assertEndpointAllowed') + ->andThrow(new RuntimeException(__('webhooks.errors.endpoint_not_allowed'))); + $this->app->instance(WebhookService::class, $failingMock); + + TryPostServer::actingAs($this->user) + ->tool(CreateWebhookTool::class, [ + 'endpoint' => 'http://127.0.0.1/webhooks', + 'events' => [EventType::PostPublished->value], + ]) + ->assertHasErrors([__('webhooks.errors.endpoint_not_allowed')]); + + $this->assertDatabaseMissing('webhooks', [ + 'endpoint' => 'http://127.0.0.1/webhooks', + ]); +}); + +test('update webhook', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(UpdateWebhookTool::class, [ + 'webhook_id' => $webhook->id, + 'status' => Status::Disabled->value, + ]) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->where('status', Status::Disabled->value) + ->missing('signing_secret') + ->etc(); + }); +}); + +test('update webhook cannot set paused status', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(UpdateWebhookTool::class, [ + 'webhook_id' => $webhook->id, + 'status' => Status::Paused->value, + ]) + ->assertHasErrors(); +}); + +test('re-enabling a webhook via mcp resets consecutive failures', function () { + $webhook = Webhook::factory()->paused()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(UpdateWebhookTool::class, [ + 'webhook_id' => $webhook->id, + 'status' => Status::Enabled->value, + ]) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->where('status', Status::Enabled->value) + ->where('consecutive_failures', 0) + ->where('paused_at', null) + ->etc(); + }); +}); + +test('updating an already enabled webhook via mcp does not reset consecutive failures', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'consecutive_failures' => 3, + ]); + + TryPostServer::actingAs($this->user) + ->tool(UpdateWebhookTool::class, [ + 'webhook_id' => $webhook->id, + 'status' => Status::Enabled->value, + ]) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->where('consecutive_failures', 3)->etc(); + }); +}); + +test('update webhook rejects wildcard events', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(UpdateWebhookTool::class, [ + 'webhook_id' => $webhook->id, + 'events' => ['*'], + ]) + ->assertHasErrors(); +}); + +test('update webhook fails when the endpoint is not allowed', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'endpoint' => 'https://old.example.com/hook', + ]); + + $failingMock = Mockery::mock(WebhookService::class); + $failingMock->shouldReceive('assertEndpointAllowed') + ->andThrow(new RuntimeException(__('webhooks.errors.endpoint_not_allowed'))); + $this->app->instance(WebhookService::class, $failingMock); + + TryPostServer::actingAs($this->user) + ->tool(UpdateWebhookTool::class, [ + 'webhook_id' => $webhook->id, + 'endpoint' => 'http://127.0.0.1/webhooks', + ]) + ->assertHasErrors([__('webhooks.errors.endpoint_not_allowed')]); + + expect($webhook->fresh()->endpoint)->toBe('https://old.example.com/hook'); +}); + +test('cannot update webhook from another workspace', function () { + $webhook = Webhook::factory()->create(); + + TryPostServer::actingAs($this->user) + ->tool(UpdateWebhookTool::class, [ + 'webhook_id' => $webhook->id, + 'status' => Status::Disabled->value, + ]) + ->assertHasErrors(['Webhook not found.']); +}); + +test('send webhook test succeeds', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(SendWebhookTestTool::class, ['webhook_id' => $webhook->id]) + ->assertOk() + ->assertStructuredContent(['tested' => true]); +}); + +test('send webhook test failure returns an error', function () { + $failingMock = Mockery::mock(WebhookService::class); + $failingMock->shouldReceive('assertEndpointAllowed')->andReturnNull(); + $failingMock->shouldReceive('ping') + ->andThrow(new RuntimeException('Endpoint unreachable')); + $this->app->instance(WebhookService::class, $failingMock); + + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(SendWebhookTestTool::class, ['webhook_id' => $webhook->id]) + ->assertHasErrors(['Endpoint unreachable']); +}); + +test('rotate webhook secret returns a new secret', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'signing_secret' => 'whsec_oldsecretoldsecretoldsecre', + ]); + + TryPostServer::actingAs($this->user) + ->tool(RotateWebhookSecretTool::class, ['webhook_id' => $webhook->id]) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->has('signing_secret') + ->where('signing_secret', fn (string $secret) => $secret !== 'whsec_oldsecretoldsecretoldsecre' && str_starts_with($secret, 'whsec_')) + ->etc(); + }); +}); + +test('list webhook logs', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + WebhookLog::factory()->count(2)->create([ + 'webhook_id' => $webhook->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(ListWebhookLogsTool::class, ['webhook_id' => $webhook->id]) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->has('logs', 2, function (AssertableJson $log) { + $log->hasAll(['id', 'event_type', 'payload', 'response_status', 'created_at']) + ->etc(); + }); + }); +}); + +test('list webhook logs honors limit', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + WebhookLog::factory()->count(3)->create([ + 'webhook_id' => $webhook->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(ListWebhookLogsTool::class, [ + 'webhook_id' => $webhook->id, + 'limit' => 1, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->has('logs', 1)->etc()); +}); + +test('replay webhook log', function () { + Queue::fake(); + + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + $log = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + 'event_type' => EventType::PostFailed->value, + 'payload' => [ + 'type' => EventType::PostFailed->value, + 'data' => ['id' => 'post-9'], + ], + ]); + + TryPostServer::actingAs($this->user) + ->tool(ReplayWebhookLogTool::class, [ + 'webhook_id' => $webhook->id, + 'log_id' => $log->id, + ]) + ->assertOk() + ->assertStructuredContent(['replayed' => true]); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($webhook) { + return $job->webhook->id === $webhook->id + && $job->eventType === EventType::PostFailed->value + && data_get($job->payload, 'id') === 'post-9' + && $job->force; + }); +}); + +test('cannot replay a log from another webhook', function () { + Queue::fake(); + + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + $other = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + $log = WebhookLog::factory()->create([ + 'webhook_id' => $other->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(ReplayWebhookLogTool::class, [ + 'webhook_id' => $webhook->id, + 'log_id' => $log->id, + ]) + ->assertHasErrors(['Webhook log not found.']); + + Queue::assertNothingPushed(); +}); + +test('delete webhook', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(DeleteWebhookTool::class, ['webhook_id' => $webhook->id]) + ->assertOk() + ->assertStructuredContent(['deleted' => true]); + + $this->assertDatabaseMissing('webhooks', ['id' => $webhook->id]); +}); + +test('cannot delete webhook from another workspace', function () { + $webhook = Webhook::factory()->create(); + + TryPostServer::actingAs($this->user) + ->tool(DeleteWebhookTool::class, ['webhook_id' => $webhook->id]) + ->assertHasErrors(['Webhook not found.']); + + expect($webhook->fresh())->not->toBeNull(); +}); + +test('cannot send test rotate or list logs for a webhook from another workspace', function () { + $webhook = Webhook::factory()->create(); + + TryPostServer::actingAs($this->user) + ->tool(SendWebhookTestTool::class, ['webhook_id' => $webhook->id]) + ->assertHasErrors(['Webhook not found.']); + + TryPostServer::actingAs($this->user) + ->tool(RotateWebhookSecretTool::class, ['webhook_id' => $webhook->id]) + ->assertHasErrors(['Webhook not found.']); + + TryPostServer::actingAs($this->user) + ->tool(ListWebhookLogsTool::class, ['webhook_id' => $webhook->id]) + ->assertHasErrors(['Webhook not found.']); +}); + +test('members and viewers cannot manage webhooks through mcp', function (Role $role) { + $teammate = User::factory()->create(['account_id' => $this->user->account_id]); + $this->workspace->members()->attach($teammate->id, ['role' => $role->value]); + $teammate->update(['current_workspace_id' => $this->workspace->id]); + + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + $log = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + ]); + + TryPostServer::actingAs($teammate) + ->tool(ListWebhooksTool::class, []) + ->assertHasErrors(['Not authorized to manage webhooks.']); + + TryPostServer::actingAs($teammate) + ->tool(CreateWebhookTool::class, [ + 'endpoint' => 'https://member.example.com/webhooks', + 'events' => [EventType::PostPublished->value], + ]) + ->assertHasErrors(['Not authorized to manage webhooks.']); + + TryPostServer::actingAs($teammate) + ->tool(GetWebhookTool::class, ['webhook_id' => $webhook->id]) + ->assertHasErrors(['Not authorized to manage webhooks.']); + + TryPostServer::actingAs($teammate) + ->tool(UpdateWebhookTool::class, [ + 'webhook_id' => $webhook->id, + 'status' => Status::Disabled->value, + ]) + ->assertHasErrors(['Not authorized to manage webhooks.']); + + TryPostServer::actingAs($teammate) + ->tool(SendWebhookTestTool::class, ['webhook_id' => $webhook->id]) + ->assertHasErrors(['Not authorized to manage webhooks.']); + + TryPostServer::actingAs($teammate) + ->tool(RotateWebhookSecretTool::class, ['webhook_id' => $webhook->id]) + ->assertHasErrors(['Not authorized to manage webhooks.']); + + TryPostServer::actingAs($teammate) + ->tool(ListWebhookLogsTool::class, ['webhook_id' => $webhook->id]) + ->assertHasErrors(['Not authorized to manage webhooks.']); + + TryPostServer::actingAs($teammate) + ->tool(ReplayWebhookLogTool::class, [ + 'webhook_id' => $webhook->id, + 'log_id' => $log->id, + ]) + ->assertHasErrors(['Not authorized to manage webhooks.']); + + TryPostServer::actingAs($teammate) + ->tool(DeleteWebhookTool::class, ['webhook_id' => $webhook->id]) + ->assertHasErrors(['Not authorized to manage webhooks.']); + + expect($webhook->fresh())->not->toBeNull(); + $this->assertDatabaseMissing('webhooks', [ + 'endpoint' => 'https://member.example.com/webhooks', + ]); +})->with([ + Role::Member, + Role::Viewer, +]); diff --git a/tests/Feature/Webhook/ReplayWebhookLogTest.php b/tests/Feature/Webhook/ReplayWebhookLogTest.php index 74739f7d..b7ea249d 100644 --- a/tests/Feature/Webhook/ReplayWebhookLogTest.php +++ b/tests/Feature/Webhook/ReplayWebhookLogTest.php @@ -101,7 +101,7 @@ $this->actingAs($this->user) ->post(route('app.webhooks.replay', [$webhook, $log])) - ->assertForbidden(); + ->assertNotFound(); Queue::assertNothingPushed(); });