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.
This commit is contained in:
Paulo Castellano 2026-09-04 12:00:32 -03:00 committed by GitHub
parent 91a1d5ca1e
commit a932c51bbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2349 additions and 57 deletions

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Actions\Webhook;
use App\Enums\Webhook\Status;
use App\Models\Webhook;
use App\Models\Workspace;
use App\Services\WebhookService;
class CreateWebhook
{
/**
* @param array<string, mixed> $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(),
]);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Actions\Webhook;
use App\Models\Webhook;
class DeleteWebhook
{
public static function execute(Webhook $webhook): void
{
$webhook->delete();
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Actions\Webhook;
use App\Jobs\DispatchWebhook;
use App\Models\Webhook;
use App\Models\WebhookLog;
class ReplayWebhookLog
{
public static function execute(Webhook $webhook, WebhookLog $webhookLog): void
{
DispatchWebhook::dispatch(
$webhook,
$webhookLog->event_type,
data_get($webhookLog->payload, 'data') ?? [],
force: true,
);
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Actions\Webhook;
use App\Models\Webhook;
class RotateWebhookSecret
{
public static function execute(Webhook $webhook): Webhook
{
$webhook->update([
'signing_secret' => Webhook::generateSigningSecret(),
]);
return $webhook->refresh();
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\Webhook;
use App\Models\Webhook;
use App\Services\WebhookService;
class SendWebhookTest
{
public static function execute(Webhook $webhook, WebhookService $webhooks): void
{
$webhooks->ping($webhook->endpoint, $webhook->signing_secret);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Actions\Webhook;
use App\Enums\Webhook\Status;
use App\Models\Webhook;
use App\Services\WebhookService;
use Illuminate\Support\Arr;
class UpdateWebhook
{
/**
* @param array<string, mixed> $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();
}
}

View file

@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
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\Api\Webhook\StoreWebhookRequest;
use App\Http\Requests\Api\Webhook\UpdateWebhookRequest;
use App\Http\Resources\Api\WebhookLogResource;
use App\Http\Resources\Api\WebhookResource;
use App\Models\Webhook;
use App\Models\WebhookLog;
use App\Services\WebhookService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
class WebhookController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$this->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);
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\Webhook;
use App\Enums\Webhook\EventType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreWebhookRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'endpoint' => ['required', 'url', 'max:255'],
'events' => ['required', 'array', 'min:1'],
'events.*' => ['string', Rule::enum(EventType::class)],
];
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\Webhook;
use App\Enums\Webhook\EventType;
use App\Enums\Webhook\Status;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateWebhookRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
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])],
];
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WebhookLogResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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(),
];
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WebhookResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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(),
];
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Concerns;
use App\Models\Webhook;
use App\Models\WebhookLog;
use App\Models\Workspace;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
trait ResolvesWorkspaceWebhook
{
protected function webhookInWorkspace(Workspace $workspace, mixed $id): Webhook|Response|ResponseFactory
{
$webhook = Webhook::query()
->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;
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Requests\Webhook;
use App\Enums\Webhook\EventType;
use Illuminate\Validation\Rule;
class CreateWebhookRequest
{
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'endpoint' => ['required', 'url', 'max:255'],
'events' => ['required', 'array', 'min:1'],
'events.*' => ['string', Rule::enum(EventType::class)],
];
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Requests\Webhook;
class ListWebhookLogsRequest
{
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'webhook_id' => ['required', 'string'],
'limit' => ['sometimes', 'integer', 'min:1', 'max:100'],
];
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Requests\Webhook;
class ReplayWebhookLogRequest
{
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'webhook_id' => ['required', 'string'],
'log_id' => ['required', 'string'],
];
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Requests\Webhook;
use App\Enums\Webhook\EventType;
use App\Enums\Webhook\Status;
use Illuminate\Validation\Rule;
class UpdateWebhookRequest
{
/**
* @return array<string, mixed>
*/
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])],
];
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Requests\Webhook;
class WebhookIdRequest
{
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'webhook_id' => ['required', 'string'],
];
}
}

View file

@ -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,

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Webhook;
use App\Actions\Webhook\CreateWebhook;
use App\Enums\Webhook\EventType;
use App\Http\Resources\Api\WebhookResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Requests\Webhook\CreateWebhookRequest;
use App\Models\Workspace;
use App\Services\WebhookService;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use RuntimeException;
#[Description('Create an outgoing webhook for the current workspace. Returns the signing secret — it can be read later with get-webhook-tool or rotated with rotate-webhook-secret-tool. Private or local endpoints are rejected. Creating does not send a test request.')]
class CreateWebhookTool extends Tool
{
use AuthorizesMcpTool;
public function __construct(private WebhookService $webhooks) {}
public function handle(Request $request): Response|ResponseFactory
{
$workspace = $this->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<string, mixed>
*/
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.'),
];
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Webhook;
use App\Actions\Webhook\DeleteWebhook;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Concerns\ResolvesWorkspaceWebhook;
use App\Mcp\Requests\Webhook\WebhookIdRequest;
use App\Models\Webhook;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Delete a webhook and its delivery logs permanently. This cannot be undone.')]
class DeleteWebhookTool extends Tool
{
use AuthorizesMcpTool;
use ResolvesWorkspaceWebhook;
public function handle(Request $request): Response|ResponseFactory
{
$workspace = $this->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<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'webhook_id' => $schema->string()->required()->description('The webhook ID to delete.'),
];
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Webhook;
use App\Http\Resources\Api\WebhookResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Concerns\ResolvesWorkspaceWebhook;
use App\Mcp\Requests\Webhook\WebhookIdRequest;
use App\Models\Webhook;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('Get a webhook by ID, including the signing secret. Verify deliveries with HMAC-SHA256 of the JSON body; the hex digest is sent as X-Webhook-Signature.')]
class GetWebhookTool extends Tool
{
use AuthorizesMcpTool;
use ResolvesWorkspaceWebhook;
public function handle(Request $request): Response|ResponseFactory
{
$workspace = $this->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<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'webhook_id' => $schema->string()->required()->description('The webhook ID.'),
];
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Webhook;
use App\Http\Resources\Api\WebhookLogResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Concerns\ResolvesWorkspaceWebhook;
use App\Mcp\Requests\Webhook\ListWebhookLogsRequest;
use App\Models\Webhook;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List recent delivery logs for a webhook, newest first, including payload and response body. Use replay-webhook-log-tool with a log id to resend a delivery.')]
class ListWebhookLogsTool extends Tool
{
use AuthorizesMcpTool;
use ResolvesWorkspaceWebhook;
public function handle(Request $request): Response|ResponseFactory
{
$workspace = $this->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<string, mixed>
*/
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).'),
];
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Webhook;
use App\Http\Resources\Api\WebhookResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Models\Webhook;
use App\Models\Workspace;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List outgoing webhooks for the current workspace. Signing secrets are omitted — use get-webhook-tool to read a secret.')]
class ListWebhooksTool extends Tool
{
use AuthorizesMcpTool;
public function handle(Request $request): Response|ResponseFactory
{
$workspace = $this->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(),
]);
}
}

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Webhook;
use App\Actions\Webhook\ReplayWebhookLog;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Concerns\ResolvesWorkspaceWebhook;
use App\Mcp\Requests\Webhook\ReplayWebhookLogRequest;
use App\Models\Webhook;
use App\Models\WebhookLog;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsOpenWorld;
#[IsOpenWorld]
#[Description('Replay a webhook delivery log. Dispatches a new signed delivery with a new log ID using the original payload data, even if the webhook is paused or disabled.')]
class ReplayWebhookLogTool extends Tool
{
use AuthorizesMcpTool;
use ResolvesWorkspaceWebhook;
public function handle(Request $request): Response|ResponseFactory
{
$workspace = $this->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<string, mixed>
*/
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.'),
];
}
}

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Webhook;
use App\Actions\Webhook\RotateWebhookSecret;
use App\Http\Resources\Api\WebhookResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Concerns\ResolvesWorkspaceWebhook;
use App\Mcp\Requests\Webhook\WebhookIdRequest;
use App\Models\Webhook;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Rotate the webhook signing secret. The previous secret stops working immediately. The new secret is returned in the response.')]
class RotateWebhookSecretTool extends Tool
{
use AuthorizesMcpTool;
use ResolvesWorkspaceWebhook;
public function handle(Request $request): Response|ResponseFactory
{
$workspace = $this->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<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'webhook_id' => $schema->string()->required()->description('The webhook ID whose secret should be rotated.'),
];
}
}

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Webhook;
use App\Actions\Webhook\SendWebhookTest;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Concerns\ResolvesWorkspaceWebhook;
use App\Mcp\Requests\Webhook\WebhookIdRequest;
use App\Models\Webhook;
use App\Models\Workspace;
use App\Services\WebhookService;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsOpenWorld;
use RuntimeException;
#[IsOpenWorld]
#[Description('Send a signed webhook.test ping to the webhook endpoint. Use this to verify the receiver is reachable. Does not create a delivery log.')]
class SendWebhookTestTool extends Tool
{
use AuthorizesMcpTool;
use ResolvesWorkspaceWebhook;
public function __construct(private WebhookService $webhooks) {}
public function handle(Request $request): Response|ResponseFactory
{
$workspace = $this->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<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'webhook_id' => $schema->string()->required()->description('The webhook ID to ping.'),
];
}
}

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Webhook;
use App\Actions\Webhook\UpdateWebhook;
use App\Enums\Webhook\EventType;
use App\Enums\Webhook\Status;
use App\Http\Resources\Api\WebhookResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Concerns\ResolvesWorkspaceWebhook;
use App\Mcp\Requests\Webhook\UpdateWebhookRequest;
use App\Models\Webhook;
use App\Models\Workspace;
use App\Services\WebhookService;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Arr;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use RuntimeException;
#[Description('Update a webhook endpoint, subscribed events, or status (enabled or disabled). Re-enabling a paused webhook resets consecutive failures. Private or local endpoints are rejected. Updating does not send a test request.')]
class UpdateWebhookTool extends Tool
{
use AuthorizesMcpTool;
use ResolvesWorkspaceWebhook;
public function __construct(private WebhookService $webhooks) {}
public function handle(Request $request): Response|ResponseFactory
{
$workspace = $this->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<string, mixed>
*/
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.'),
];
}
}

View file

@ -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');

View file

@ -0,0 +1,599 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Enums\Webhook\EventType;
use App\Enums\Webhook\Status;
use App\Jobs\DispatchWebhook;
use App\Models\User;
use App\Models\Webhook;
use App\Models\WebhookLog;
use App\Services\WebhookService;
use Illuminate\Support\Facades\Queue;
use Symfony\Component\HttpFoundation\Response;
beforeEach(function () {
$result = createApiTestToken();
$this->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,
]);

View file

@ -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'), [

View file

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

View file

@ -0,0 +1,501 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Enums\Webhook\EventType;
use App\Enums\Webhook\Status;
use App\Jobs\DispatchWebhook;
use App\Mcp\Servers\TryPostServer;
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\Models\User;
use App\Models\Webhook;
use App\Models\WebhookLog;
use App\Models\Workspace;
use App\Services\WebhookService;
use Illuminate\Support\Facades\Queue;
use Illuminate\Testing\Fluent\AssertableJson;
beforeEach(function () {
$this->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,
]);

View file

@ -101,7 +101,7 @@
$this->actingAs($this->user)
->post(route('app.webhooks.replay', [$webhook, $log]))
->assertForbidden();
->assertNotFound();
Queue::assertNothingPushed();
});