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:
parent
91a1d5ca1e
commit
a932c51bbb
34 changed files with 2349 additions and 57 deletions
31
app/Actions/Webhook/CreateWebhook.php
Normal file
31
app/Actions/Webhook/CreateWebhook.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
15
app/Actions/Webhook/DeleteWebhook.php
Normal file
15
app/Actions/Webhook/DeleteWebhook.php
Normal 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();
|
||||
}
|
||||
}
|
||||
22
app/Actions/Webhook/ReplayWebhookLog.php
Normal file
22
app/Actions/Webhook/ReplayWebhookLog.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
19
app/Actions/Webhook/RotateWebhookSecret.php
Normal file
19
app/Actions/Webhook/RotateWebhookSecret.php
Normal 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();
|
||||
}
|
||||
}
|
||||
16
app/Actions/Webhook/SendWebhookTest.php
Normal file
16
app/Actions/Webhook/SendWebhookTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
35
app/Actions/Webhook/UpdateWebhook.php
Normal file
35
app/Actions/Webhook/UpdateWebhook.php
Normal 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();
|
||||
}
|
||||
}
|
||||
152
app/Http/Controllers/Api/WebhookController.php
Normal file
152
app/Http/Controllers/Api/WebhookController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
app/Http/Requests/Api/Webhook/StoreWebhookRequest.php
Normal file
29
app/Http/Requests/Api/Webhook/StoreWebhookRequest.php
Normal 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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/Api/Webhook/UpdateWebhookRequest.php
Normal file
31
app/Http/Requests/Api/Webhook/UpdateWebhookRequest.php
Normal 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])],
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Http/Resources/Api/WebhookLogResource.php
Normal file
29
app/Http/Resources/Api/WebhookLogResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
33
app/Http/Resources/Api/WebhookResource.php
Normal file
33
app/Http/Resources/Api/WebhookResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
38
app/Mcp/Concerns/ResolvesWorkspaceWebhook.php
Normal file
38
app/Mcp/Concerns/ResolvesWorkspaceWebhook.php
Normal 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;
|
||||
}
|
||||
}
|
||||
23
app/Mcp/Requests/Webhook/CreateWebhookRequest.php
Normal file
23
app/Mcp/Requests/Webhook/CreateWebhookRequest.php
Normal 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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Mcp/Requests/Webhook/ListWebhookLogsRequest.php
Normal file
19
app/Mcp/Requests/Webhook/ListWebhookLogsRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Mcp/Requests/Webhook/ReplayWebhookLogRequest.php
Normal file
19
app/Mcp/Requests/Webhook/ReplayWebhookLogRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Mcp/Requests/Webhook/UpdateWebhookRequest.php
Normal file
26
app/Mcp/Requests/Webhook/UpdateWebhookRequest.php
Normal 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])],
|
||||
];
|
||||
}
|
||||
}
|
||||
18
app/Mcp/Requests/Webhook/WebhookIdRequest.php
Normal file
18
app/Mcp/Requests/Webhook/WebhookIdRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
67
app/Mcp/Tools/Webhook/CreateWebhookTool.php
Normal file
67
app/Mcp/Tools/Webhook/CreateWebhookTool.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
62
app/Mcp/Tools/Webhook/DeleteWebhookTool.php
Normal file
62
app/Mcp/Tools/Webhook/DeleteWebhookTool.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
62
app/Mcp/Tools/Webhook/GetWebhookTool.php
Normal file
62
app/Mcp/Tools/Webhook/GetWebhookTool.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
68
app/Mcp/Tools/Webhook/ListWebhookLogsTool.php
Normal file
68
app/Mcp/Tools/Webhook/ListWebhookLogsTool.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
45
app/Mcp/Tools/Webhook/ListWebhooksTool.php
Normal file
45
app/Mcp/Tools/Webhook/ListWebhooksTool.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
70
app/Mcp/Tools/Webhook/ReplayWebhookLogTool.php
Normal file
70
app/Mcp/Tools/Webhook/ReplayWebhookLogTool.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
65
app/Mcp/Tools/Webhook/RotateWebhookSecretTool.php
Normal file
65
app/Mcp/Tools/Webhook/RotateWebhookSecretTool.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
70
app/Mcp/Tools/Webhook/SendWebhookTestTool.php
Normal file
70
app/Mcp/Tools/Webhook/SendWebhookTestTool.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
83
app/Mcp/Tools/Webhook/UpdateWebhookTool.php
Normal file
83
app/Mcp/Tools/Webhook/UpdateWebhookTool.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
599
tests/Feature/Api/WebhookApiTest.php
Normal file
599
tests/Feature/Api/WebhookApiTest.php
Normal 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,
|
||||
]);
|
||||
|
|
@ -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'), [
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
|
|
|||
501
tests/Feature/Mcp/WebhookToolTest.php
Normal file
501
tests/Feature/Mcp/WebhookToolTest.php
Normal 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,
|
||||
]);
|
||||
|
|
@ -101,7 +101,7 @@
|
|||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('app.webhooks.replay', [$webhook, $log]))
|
||||
->assertForbidden();
|
||||
->assertNotFound();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue