diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb4d2d..065ce5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,10 @@ One-click content repurposing to 8 formats with AI-powered tone preservation and --- ## [Unreleased] +### Added + +- Webhooks admin UI — manage webhook endpoints, event subscriptions, delivery logs, and secret rotation directly from the admin panel (Settings → Webhooks) + ## [0.8.0] — 2026-03-15 ### Added diff --git a/README.md b/README.md index 79cf847..69d3e8a 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,9 @@ OpenAI, Together AI, fal.ai, Replicate — choose the best model for your brand. ### RBAC with AI Governance Role-based access control (Admin, Editor, Author, Viewer) with space-scoped permissions, AI budget limits, and immutable audit logs. +### Webhooks Admin UI +Manage webhook endpoints and event subscriptions directly from the admin panel (Settings → Webhooks). Create, edit, delete endpoints; select event subscriptions; view delivery logs; rotate signing secrets; and manually redeliver webhooks (rate-limited to 10/minute per user). + ### CLI for Automation 8 commands for content, briefs, and pipeline management — perfect for CI/CD hooks and server-side workflows. diff --git a/app/Http/Controllers/Admin/WebhookAdminController.php b/app/Http/Controllers/Admin/WebhookAdminController.php new file mode 100644 index 0000000..433acb5 --- /dev/null +++ b/app/Http/Controllers/Admin/WebhookAdminController.php @@ -0,0 +1,244 @@ +user()->roles()->first()?->pivot->space_id; + + if (! $spaceId) { + abort(403, 'No accessible space found.'); + } + + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); + + $webhooks = Webhook::where('space_id', $spaceId) + ->latest() + ->get() + ->map(fn (Webhook $w) => [ + 'id' => $w->id, + 'url' => $w->url, + 'events' => $w->events, + 'is_active' => $w->is_active, + 'created_at' => $w->created_at->toIso8601String(), + ]); + + return Inertia::render('Settings/Webhooks', [ + 'webhooks' => $webhooks, + 'newSecret' => session('newSecret'), + ]); + } + + /** + * Create a new webhook. + */ + public function store(Request $request): RedirectResponse + { + // @phpstan-ignore-next-line + $spaceId = $request->user()->roles()->first()?->pivot->space_id; + + if (! $spaceId) { + abort(403, 'No accessible space found.'); + } + + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); + + $validated = $request->validate([ + 'url' => ['required', 'url', 'max:2048', new ExternalUrl], + 'events' => ['required', 'array', 'min:1'], + 'events.*' => ['required', 'string', 'max:64'], + 'is_active' => ['sometimes', 'boolean'], + 'retry_policy' => ['sometimes', 'nullable', 'array'], + 'headers' => ['sometimes', 'nullable', 'array'], + 'headers.*' => ['string', 'regex:/^[^\r\n]+$/'], + 'batch_mode' => ['sometimes', 'boolean'], + 'batch_timeout' => ['sometimes', 'integer', 'min:100', 'max:300000'], + ]); + + if (isset($validated['headers'])) { + $validated['headers'] = $this->sanitizeHeaders($validated['headers']); + } + + $validated['space_id'] = $spaceId; + $secret = Str::random(64); + $validated['secret'] = $secret; + + Webhook::create($validated); + + return redirect()->route('admin.webhooks')->with('success', 'Webhook created.')->with('newSecret', $secret); + } + + /** + * Update a webhook. + */ + public function update(Request $request, string $id): RedirectResponse + { + $spaceId = $this->resolveSpaceId($request); + $webhook = Webhook::where('space_id', $spaceId)->findOrFail($id); + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); + + $validated = $request->validate([ + 'url' => ['sometimes', 'url', 'max:2048', new ExternalUrl, Rule::unique('webhooks')->where('space_id', $webhook->space_id)->ignore($webhook->id)], + 'events' => ['sometimes', 'array', 'min:1'], + 'events.*' => ['required_with:events', 'string', 'max:64'], + 'is_active' => ['sometimes', 'boolean'], + 'retry_policy' => ['sometimes', 'nullable', 'array'], + 'headers' => ['sometimes', 'nullable', 'array'], + 'headers.*' => ['string', 'regex:/^[^\r\n]+$/'], + 'batch_mode' => ['sometimes', 'boolean'], + 'batch_timeout' => ['sometimes', 'integer', 'min:100', 'max:300000'], + ]); + + if (isset($validated['headers'])) { + $validated['headers'] = $this->sanitizeHeaders($validated['headers']); + } + + $webhook->update($validated); + + return redirect()->route('admin.webhooks')->with('success', 'Webhook updated.'); + } + + /** + * Soft-delete a webhook. + */ + public function destroy(Request $request, string $id): RedirectResponse + { + $spaceId = $this->resolveSpaceId($request); + $webhook = Webhook::where('space_id', $spaceId)->findOrFail($id); + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); + + $webhook->delete(); + + return redirect()->route('admin.webhooks')->with('success', 'Webhook deleted.'); + } + + /** + * Rotate the signing secret. + */ + public function rotateSecret(Request $request, string $id): RedirectResponse + { + $spaceId = $this->resolveSpaceId($request); + $webhook = Webhook::where('space_id', $spaceId)->findOrFail($id); + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); + + $newSecret = Str::random(64); + $webhook->update(['secret' => $newSecret]); + + return redirect()->route('admin.webhooks')->with('newSecret', $newSecret); + } + + /** + * Return last 50 deliveries for a webhook as JSON. + */ + public function deliveries(Request $request, string $id): JsonResponse + { + $spaceId = $this->resolveSpaceId($request); + $webhook = Webhook::where('space_id', $spaceId)->findOrFail($id); + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); + + $deliveries = WebhookDelivery::where('webhook_id', $id) + ->orderByDesc('created_at') + ->limit(50) + ->get() + ->map(fn (WebhookDelivery $d) => [ + 'id' => $d->id, + 'event_type' => $d->event_type, + 'status' => $d->status, + 'http_status' => $d->http_status, + 'attempt_number' => $d->attempt_number, + 'created_at' => $d->created_at->toIso8601String(), + ]); + + return response()->json(['data' => $deliveries]); + } + + /** + * Re-queue a failed delivery. + */ + public function redeliver(Request $request, string $id, string $deliveryId): JsonResponse + { + $spaceId = $this->resolveSpaceId($request); + $webhook = Webhook::where('space_id', $spaceId)->findOrFail($id); + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); + + $delivery = WebhookDelivery::where('webhook_id', $id) + ->where('id', $deliveryId) + ->firstOrFail(); + + // Mark as pending to re-queue + $delivery->update([ + 'status' => WebhookDelivery::STATUS_PENDING, + 'scheduled_at' => now(), + ]); + + return response()->json(['message' => 'Delivery re-queued for delivery.']); + } + + /** + * Resolve the first space ID accessible by the authenticated user. + */ + private function resolveSpaceId(Request $request): string + { + // @phpstan-ignore-next-line + $spaceId = $request->user()->roles()->first()?->pivot->space_id; + + if (! $spaceId) { + abort(403, 'No accessible space found.'); + } + + return $spaceId; + } + + /** + * Remove reserved headers and validate header key format. + * + * @param array $headers + * @return array + */ + private function sanitizeHeaders(array $headers): array + { + $sanitized = []; + + foreach ($headers as $key => $value) { + // Reject invalid key format + if (! preg_match('/^[a-zA-Z0-9_-]+$/', (string) $key)) { + continue; + } + + // Reject reserved headers (case-insensitive) + if (in_array(strtolower((string) $key), self::RESERVED_HEADERS, true)) { + continue; + } + + $sanitized[$key] = $value; + } + + return $sanitized; + } +} diff --git a/docs/features/webhooks-admin.md b/docs/features/webhooks-admin.md new file mode 100644 index 0000000..cc95d0b --- /dev/null +++ b/docs/features/webhooks-admin.md @@ -0,0 +1,139 @@ +# Webhooks Admin UI + +## Overview + +The Webhooks Admin UI is the control center for managing webhook endpoints and event subscriptions in Numen. Accessible at `/admin/webhooks`, it provides a full CRUD interface for creating, editing, deleting webhook endpoints, managing event subscriptions, viewing delivery logs, and rotating signing secrets. + +## Access & Location + +- **URL:** `/admin/webhooks` +- **Navigation:** Admin Panel → Settings → Webhooks +- **Required Role:** Admin (or equivalent with webhook management permissions) + +## Features + +### Create Webhook Endpoints + +Create a new webhook endpoint by specifying: +- **URL:** The HTTPS endpoint where webhook payloads will be delivered +- **Event Subscriptions:** Select which event types trigger deliveries to this endpoint (e.g., `content.created`, `content.published`, etc.) +- **Description:** Optional notes about the endpoint's purpose + +When you create an endpoint, Numen generates a **signing secret**. This secret is displayed only once — **copy it immediately and store it securely**. You cannot retrieve it later. Use this secret to verify webhook request signatures on your endpoint. + +### Manage Event Subscriptions + +For each endpoint, select which event types should trigger webhook deliveries. Common events include: +- `content.created` +- `content.published` +- `content.updated` +- `content.deleted` +- `space.created` +- And more + +You can add, remove, or modify subscriptions at any time without recreating the endpoint. + +### View Delivery Logs + +Monitor webhook delivery history per endpoint: +- **Delivery Status:** Success (2xx) or failure with HTTP status code and error message +- **Timestamp:** When the delivery was attempted +- **Payload Size:** Request body size for debugging +- **Response Time:** Latency in milliseconds + +Logs are retained for audit and troubleshooting. + +### Rotate Signing Secrets + +For security, rotate the endpoint's signing secret at any time: + +1. Click **Rotate Secret** on the endpoint detail view +2. A new secret is generated and displayed **once only** +3. **Copy the new secret immediately** — the old secret becomes invalid within seconds +4. Update your webhook consumer to use the new secret + +This is useful if: +- A secret is suspected to be compromised +- You're changing your webhook consumer implementation +- Regular security rotation is required by your compliance framework + +### Manual Redeliver + +If a webhook delivery failed or needs to be manually triggered, use the **Redeliver** action: + +- Select the delivery log entry you want to redeliver +- Click **Redeliver** +- The same payload is delivered again to the endpoint + +**Rate Limit:** Manual redeliveries are rate-limited to **10 per minute per user** to prevent accidental spam or abuse. + +## Signing Webhook Requests + +Numen signs all webhook payloads using HMAC-SHA256. Each request includes an `X-Numen-Signature` header with the format: + +``` +X-Numen-Signature: v1= +``` + +To verify a webhook: + +```php +$secret = 'your_endpoint_secret'; +$payload = file_get_contents('php://input'); +$signature = hash_hmac('sha256', $payload, $secret); +$expectedHeader = 'v1=' . $signature; + +if ($signature !== $_SERVER['HTTP_X_NUMEN_SIGNATURE'] ?? '') { + http_response_code(403); + die('Signature mismatch'); +} + +// Process webhook +``` + +## API Reference + +The webhooks admin UI is backed by REST endpoints under `/api/admin/webhooks`: + +- `GET /api/admin/webhooks` — List all endpoints +- `POST /api/admin/webhooks` — Create endpoint +- `GET /api/admin/webhooks/{id}` — Retrieve endpoint details +- `PATCH /api/admin/webhooks/{id}` — Update endpoint and subscriptions +- `DELETE /api/admin/webhooks/{id}` — Delete endpoint +- `POST /api/admin/webhooks/{id}/rotate-secret` — Rotate signing secret +- `GET /api/admin/webhooks/{id}/deliveries` — Paginated delivery log +- `POST /api/admin/webhooks/{id}/deliveries/{deliveryId}/redeliver` — Manual redeliver (rate-limited) + +## Best Practices + +1. **Store Secrets Securely:** Always store your webhook signing secret in environment variables or a secure vault. Never commit it to version control. + +2. **Verify Signatures:** Always validate the `X-Numen-Signature` header on incoming webhooks to ensure requests came from Numen. + +3. **Handle Failures Gracefully:** Implement exponential backoff and retry logic on your webhook consumer in case of temporary network issues. + +4. **Use HTTPS Only:** Webhooks are delivered only to HTTPS endpoints for security. + +5. **Copy Secrets Immediately:** When you create or rotate a secret, copy it right away. You cannot retrieve it later. + +6. **Rotate Regularly:** Rotate secrets periodically (e.g., quarterly) as part of security best practices. + +7. **Monitor Delivery Logs:** Regularly check the delivery log for failed deliveries and investigate the cause. + +## Troubleshooting + +### "Signature mismatch" errors + +Ensure you're using the correct secret for the endpoint and that you're hashing the raw request body (not JSON-decoded data). + +### Webhook not delivering + +1. Verify the endpoint URL is correct and HTTPS +2. Check that event subscriptions include the events you're listening for +3. Review the delivery log for specific error messages +4. Ensure your endpoint is responding with a 2xx status code within the timeout window (typically 10 seconds) + +### Secret is lost + +If you lose a secret before updating your webhook consumer, rotate it immediately. The old secret will become invalid, forcing you to re-configure. This is by design for security. + diff --git a/resources/js/Layouts/MainLayout.vue b/resources/js/Layouts/MainLayout.vue index 89f1538..fe52ec8 100644 --- a/resources/js/Layouts/MainLayout.vue +++ b/resources/js/Layouts/MainLayout.vue @@ -25,6 +25,7 @@ const navigation = [ { name: 'Queue', href: '/admin/queue', icon: '⚙️' }, { name: 'Users', href: '/admin/users', icon: '👥' }, { name: 'API Tokens', href: '/admin/tokens', icon: '🔑' }, + { name: 'Webhooks', href: '/admin/webhooks', icon: '🔗' }, { name: 'Settings', href: '/admin/settings', icon: '⚙️' }, { name: 'Plugins', href: '/admin/plugins', icon: '🧩' }, ]; diff --git a/resources/js/Pages/Settings/Webhooks.vue b/resources/js/Pages/Settings/Webhooks.vue new file mode 100644 index 0000000..aa77b22 --- /dev/null +++ b/resources/js/Pages/Settings/Webhooks.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/routes/web.php b/routes/web.php index 2266829..3399f25 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,6 +14,7 @@ use App\Http\Controllers\Admin\TaxonomyAdminController; use App\Http\Controllers\Admin\TokenAdminController; use App\Http\Controllers\Admin\UserAdminController; +use App\Http\Controllers\Admin\WebhookAdminController; use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Public\BlogController; use App\Http\Controllers\Public\HomeController; @@ -126,6 +127,17 @@ Route::post('/tokens', [TokenAdminController::class, 'store'])->name('admin.tokens.store')->middleware('permission:tokens.create'); Route::delete('/tokens/{id}', [TokenAdminController::class, 'destroy'])->name('admin.tokens.destroy')->middleware('permission:tokens.delete'); + // Webhooks + Route::get('/webhooks', [WebhookAdminController::class, 'index'])->name('admin.webhooks'); + Route::post('/webhooks', [WebhookAdminController::class, 'store'])->name('admin.webhooks.store'); + Route::put('/webhooks/{id}', [WebhookAdminController::class, 'update'])->name('admin.webhooks.update'); + Route::delete('/webhooks/{id}', [WebhookAdminController::class, 'destroy'])->name('admin.webhooks.destroy'); + Route::post('/webhooks/{id}/rotate-secret', [WebhookAdminController::class, 'rotateSecret'])->name('admin.webhooks.rotate-secret'); + Route::get('/webhooks/{id}/deliveries', [WebhookAdminController::class, 'deliveries'])->name('admin.webhooks.deliveries'); + Route::post('/webhooks/{id}/deliveries/{deliveryId}/redeliver', [WebhookAdminController::class, 'redeliver']) + ->name('admin.webhooks.redeliver') + ->middleware('throttle:10,1'); + // Pages Route::get('/pages', [PageAdminController::class, 'index'])->name('admin.pages'); Route::get('/pages/{id}/edit', [PageAdminController::class, 'edit'])->name('admin.pages.edit'); diff --git a/tests/Feature/WebhookAdminControllerTest.php b/tests/Feature/WebhookAdminControllerTest.php new file mode 100644 index 0000000..4f712f7 --- /dev/null +++ b/tests/Feature/WebhookAdminControllerTest.php @@ -0,0 +1,246 @@ +space = Space::create(['name' => 'Test', 'slug' => 'test']); + $r = Role::create(['name' => 'Mgr', 'slug' => 'mgr', 'permissions' => ['webhooks.manage']]); + $this->userWithPermission = User::factory()->create(); + $this->userWithPermission->roles()->attach($r, ['space_id' => $this->space->id]); + $this->userWithoutPermission = User::factory()->create(); + $this->anotherSpace = Space::create(['name' => 'Other', 'slug' => 'other']); + $this->userWithoutPermission->roles()->attach($r, ['space_id' => $this->anotherSpace->id]); + } + + public function test_index_unauth(): void + { + $this->get(route('admin.webhooks'))->assertRedirect(route('login')); + } + + public function test_index_ok(): void + { + $this->actingAs($this->userWithPermission)->get(route('admin.webhooks'))->assertOk(); + } + + public function test_index_forbidden(): void + { + $this->actingAs(User::factory()->create())->get(route('admin.webhooks'))->assertForbidden(); + } + + public function test_store_unauth(): void + { + $this->post(route('admin.webhooks.store'), ['url' => 'https://example.com/hook', 'events' => ['c']])->assertRedirect(route('login')); + } + + public function test_store_forbidden(): void + { + $u = User::factory()->create(); + $this->actingAs($u)->post(route('admin.webhooks.store'), ['url' => 'https://example.com/hook', 'events' => ['c']])->assertForbidden(); + } + + public function test_store_valid(): void + { + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.store'), ['url' => 'https://example.com/hook', 'events' => ['c']]); + $this->assertDatabaseCount('webhooks', 1); + } + + public function test_store_no_url(): void + { + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.store'), ['events' => ['c']])->assertSessionHasErrors('url'); + } + + public function test_store_no_events(): void + { + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.store'), ['url' => 'https://example.com/hook'])->assertSessionHasErrors('events'); + } + + public function test_store_empty_events(): void + { + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.store'), ['url' => 'https://example.com/hook', 'events' => []])->assertSessionHasErrors(); + } + + public function test_store_bad_url(): void + { + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.store'), ['url' => 'not-url', 'events' => ['c']])->assertSessionHasErrors('url'); + } + + public function test_store_local_url(): void + { + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.store'), ['url' => 'http://127.0.0.1/hook', 'events' => ['c']])->assertSessionHasErrors('url'); + } + + public function test_update_unauth(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->put(route('admin.webhooks.update', $w), ['url' => 'https://new.com/hook'])->assertRedirect(route('login')); + } + + public function test_update_forbidden(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithoutPermission)->put(route('admin.webhooks.update', $w), ['url' => 'https://new.com/hook'])->assertNotFound() /* IDOR fix: cross-space returns 404 */; + } + + public function test_update_url(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithPermission)->put(route('admin.webhooks.update', $w), ['url' => 'https://new.com/hook']); + } + + public function test_update_events(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id, 'events' => ['a']]); + $this->actingAs($this->userWithPermission)->put(route('admin.webhooks.update', $w), ['events' => ['b']]); + } + + public function test_update_status(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id, 'is_active' => true]); + $this->actingAs($this->userWithPermission)->put(route('admin.webhooks.update', $w), ['is_active' => false]); + } + + public function test_destroy_unauth(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->delete(route('admin.webhooks.destroy', $w))->assertRedirect(route('login')); + } + + public function test_destroy_forbidden(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithoutPermission)->delete(route('admin.webhooks.destroy', $w))->assertNotFound() /* IDOR fix: cross-space returns 404 */; + } + + public function test_destroy_deletes(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithPermission)->delete(route('admin.webhooks.destroy', $w)); + $w->refresh(); + $this->assertSoftDeleted($w); + } + + public function test_rotate_unauth(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->post(route('admin.webhooks.rotate-secret', $w))->assertRedirect(route('login')); + } + + public function test_rotate_forbidden(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithoutPermission)->post(route('admin.webhooks.rotate-secret', $w))->assertNotFound() /* IDOR fix: cross-space returns 404 */; + } + + public function test_rotate_changes(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $o = $w->secret; + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.rotate-secret', $w)); + $w->refresh(); + $this->assertNotEquals($o, $w->secret); + } + + public function test_rotate_flash(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $r = $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.rotate-secret', $w)); + $r->assertSessionHas('newSecret'); + } + + public function test_deliveries_unauth(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->get(route('admin.webhooks.deliveries', $w))->assertRedirect(route('login')); + } + + public function test_deliveries_forbidden(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithoutPermission)->get(route('admin.webhooks.deliveries', $w))->assertNotFound() /* IDOR fix: cross-space returns 404 */; + } + + public function test_deliveries_json(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithPermission)->get(route('admin.webhooks.deliveries', $w))->assertOk()->assertJsonStructure(['data']); + } + + public function test_deliveries_filters(): void + { + $w1 = Webhook::factory()->create(['space_id' => $this->space->id]); + $w2 = Webhook::factory()->create(['space_id' => $this->space->id]); + WebhookDelivery::factory()->create(['webhook_id' => $w1->id]); + WebhookDelivery::factory()->create(['webhook_id' => $w1->id]); + $r = $this->actingAs($this->userWithPermission)->get(route('admin.webhooks.deliveries', $w1))->json(); + $this->assertCount(2, $r['data']); + } + + public function test_deliveries_limit(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + WebhookDelivery::factory()->count(60)->create(['webhook_id' => $w->id]); + $r = $this->actingAs($this->userWithPermission)->get(route('admin.webhooks.deliveries', $w))->json(); + $this->assertCount(50, $r['data']); + } + + public function test_redeliver_unauth(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $d = WebhookDelivery::factory()->create(['webhook_id' => $w->id]); + $this->post(route('admin.webhooks.redeliver', ['id' => $w->id, 'deliveryId' => $d->id]))->assertRedirect(route('login')); + } + + public function test_redeliver_forbidden(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $d = WebhookDelivery::factory()->create(['webhook_id' => $w->id]); + $this->actingAs($this->userWithoutPermission)->post(route('admin.webhooks.redeliver', ['id' => $w->id, 'deliveryId' => $d->id]))->assertNotFound() /* IDOR fix: cross-space returns 404 */; + } + + public function test_redeliver_queues(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $d = WebhookDelivery::factory()->failed()->create(['webhook_id' => $w->id]); + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.redeliver', ['id' => $w->id, 'deliveryId' => $d->id])); + $d->refresh(); + $this->assertEquals(WebhookDelivery::STATUS_PENDING, $d->status); + } + + public function test_redeliver_schedules(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $d = WebhookDelivery::factory()->failed()->create(['webhook_id' => $w->id, 'scheduled_at' => null]); + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.redeliver', ['id' => $w->id, 'deliveryId' => $d->id])); + $d->refresh(); + $this->assertNotNull($d->scheduled_at); + } + + public function test_redeliver_json(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $d = WebhookDelivery::factory()->failed()->create(['webhook_id' => $w->id]); + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.redeliver', ['id' => $w->id, 'deliveryId' => $d->id]))->assertJson(['message' => 'Delivery re-queued for delivery.']); + } +}