Slim webhook log broadcasts to fix Reverb payload-too-large (#329)
* Stop broadcasting webhook payloads so Reverb does not reject live log updates. The Echo event only needs list metadata; Inertia reloads the full log after the status lands. * Type webhook Echo payloads separately and cover delivered, failed, and pending broadcasts. The show page still receives payload and body over HTTP; Echo only carries list metadata.
This commit is contained in:
parent
58d8e066b5
commit
91a1d5ca1e
7 changed files with 181 additions and 20 deletions
|
|
@ -10,6 +10,10 @@
|
|||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Lightweight signal that a delivery log changed. Carries only list
|
||||
* metadata — the show page reloads `logs` over HTTP for payload and body.
|
||||
*/
|
||||
class LogUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
|
@ -44,16 +48,22 @@ public function broadcastQueue(): string
|
|||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* @return array{
|
||||
* id: string,
|
||||
* event_type: string,
|
||||
* response_status: int|null,
|
||||
* delivered_at: string|null,
|
||||
* failed_at: string|null,
|
||||
* attempts: int,
|
||||
* created_at: string
|
||||
* }
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->log->id,
|
||||
'event_type' => $this->log->event_type,
|
||||
'payload' => $this->log->payload,
|
||||
'response_status' => $this->log->response_status,
|
||||
'response_body' => $this->log->response_body,
|
||||
'delivered_at' => $this->log->delivered_at?->toIso8601String(),
|
||||
'failed_at' => $this->log->failed_at?->toIso8601String(),
|
||||
'attempts' => $this->log->attempts,
|
||||
|
|
|
|||
|
|
@ -44,4 +44,15 @@ public function failed(): static
|
|||
'failed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'response_status' => null,
|
||||
'response_body' => null,
|
||||
'delivered_at' => null,
|
||||
'failed_at' => null,
|
||||
'attempts' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { router } from '@inertiajs/vue3';
|
||||
import { ref, toValue, watch, type MaybeRefOrGetter } from 'vue';
|
||||
|
||||
import { useWebhookEcho } from '@/composables/echo/useWebhookEcho';
|
||||
import dayjs from '@/dayjs';
|
||||
import type { WebhookLog } from '@/types/webhook';
|
||||
import {
|
||||
webhookLogFromBroadcast,
|
||||
type WebhookLog,
|
||||
type WebhookLogBroadcast,
|
||||
} from '@/types/webhook';
|
||||
|
||||
type LiveFields = Pick<
|
||||
WebhookLog,
|
||||
'response_status' | 'response_body' | 'delivered_at' | 'failed_at' | 'attempts'
|
||||
>;
|
||||
|
||||
const liveFields = (log: WebhookLog): LiveFields => ({
|
||||
const liveFields = (log: WebhookLogBroadcast): Pick<
|
||||
WebhookLogBroadcast,
|
||||
'response_status' | 'delivered_at' | 'failed_at' | 'attempts'
|
||||
> => ({
|
||||
response_status: log.response_status,
|
||||
response_body: log.response_body,
|
||||
delivered_at: log.delivered_at,
|
||||
failed_at: log.failed_at,
|
||||
attempts: log.attempts,
|
||||
|
|
@ -62,9 +64,11 @@ export const useWebhookLogs = (
|
|||
const liveLogs = ref<WebhookLog[]>([...toValue(incomingLogs)]);
|
||||
const selectedLog = ref<WebhookLog | null>(liveLogs.value[0] ?? null);
|
||||
|
||||
const applyLogUpdate = (incoming: WebhookLog): void => {
|
||||
const applyLogUpdate = (incoming: WebhookLogBroadcast): void => {
|
||||
const existing = liveLogs.value.find((log) => log.id === incoming.id);
|
||||
const next = existing ? { ...existing, ...liveFields(incoming) } : incoming;
|
||||
const next = existing
|
||||
? { ...existing, ...liveFields(incoming) }
|
||||
: webhookLogFromBroadcast(incoming);
|
||||
|
||||
liveLogs.value = existing
|
||||
? liveLogs.value.map((log) => (log.id === next.id ? next : log))
|
||||
|
|
@ -77,6 +81,8 @@ export const useWebhookLogs = (
|
|||
if (!selectedLog.value || selectedLog.value.id === next.id) {
|
||||
selectedLog.value = next;
|
||||
}
|
||||
|
||||
router.reload({ only: ['logs'], preserveScroll: true });
|
||||
};
|
||||
|
||||
useWebhookEcho(toValue(webhookId), '.webhook.log.updated', applyLogUpdate);
|
||||
|
|
|
|||
|
|
@ -12,14 +12,23 @@ export interface WebhookWithSecret extends Webhook {
|
|||
signing_secret: string;
|
||||
}
|
||||
|
||||
export interface WebhookLog {
|
||||
export interface WebhookLogBroadcast {
|
||||
id: string;
|
||||
event_type: string;
|
||||
payload: Record<string, unknown> | null;
|
||||
response_status: number | null;
|
||||
response_body: string | null;
|
||||
delivered_at: string | null;
|
||||
failed_at: string | null;
|
||||
attempts: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WebhookLog extends WebhookLogBroadcast {
|
||||
payload: Record<string, unknown> | null;
|
||||
response_body: string | null;
|
||||
}
|
||||
|
||||
export const webhookLogFromBroadcast = (broadcast: WebhookLogBroadcast): WebhookLog => ({
|
||||
...broadcast,
|
||||
payload: null,
|
||||
response_body: null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -221,6 +221,33 @@
|
|||
->assertSessionHasErrors('events.0');
|
||||
});
|
||||
|
||||
test('webhook show includes full log payload and response body', function () {
|
||||
$webhook = Webhook::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
$log = WebhookLog::factory()->create([
|
||||
'webhook_id' => $webhook->id,
|
||||
'payload' => [
|
||||
'id' => 'post-1',
|
||||
'type' => EventType::PostPublished->value,
|
||||
'data' => ['content' => 'Hello'],
|
||||
],
|
||||
'response_body' => 'OK',
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.webhooks.show', $webhook))
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('webhooks/Show')
|
||||
->has('logs.data', 1)
|
||||
->where('logs.data.0.id', $log->id)
|
||||
->where('logs.data.0.payload.id', 'post-1')
|
||||
->where('logs.data.0.payload.data.content', 'Hello')
|
||||
->where('logs.data.0.response_body', 'OK')
|
||||
);
|
||||
});
|
||||
|
||||
test('authenticated users can view a webhook with signing_secret exposed', function () {
|
||||
$webhook = Webhook::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
|
|||
|
|
@ -618,11 +618,11 @@
|
|||
});
|
||||
});
|
||||
|
||||
test('dispatch webhook job broadcasts log updates', function () {
|
||||
test('dispatch webhook job broadcasts a slim log update', function (int $status) {
|
||||
Event::fake([LogUpdated::class]);
|
||||
|
||||
Http::fake([
|
||||
'example.com/webhook' => Http::response('OK', 200),
|
||||
'example.com/webhook' => Http::response($status === 200 ? 'OK' : 'Nope', $status),
|
||||
]);
|
||||
|
||||
$job = new DispatchWebhook(
|
||||
|
|
@ -631,7 +631,51 @@
|
|||
['id' => 'test-123'],
|
||||
);
|
||||
|
||||
app()->call([$job, 'handle']);
|
||||
try {
|
||||
app()->call([$job, 'handle']);
|
||||
} catch (RuntimeException) {
|
||||
// HTTP failures rethrow after the log is persisted.
|
||||
}
|
||||
|
||||
Event::assertDispatched(LogUpdated::class);
|
||||
Event::assertDispatched(LogUpdated::class, function (LogUpdated $event) use ($job): bool {
|
||||
$broadcast = $event->broadcastWith();
|
||||
|
||||
return $event->log->id === $job->logId
|
||||
&& array_keys($broadcast) === [
|
||||
'id',
|
||||
'event_type',
|
||||
'response_status',
|
||||
'delivered_at',
|
||||
'failed_at',
|
||||
'attempts',
|
||||
'created_at',
|
||||
];
|
||||
});
|
||||
})->with([
|
||||
'delivered' => 200,
|
||||
'rejected' => 500,
|
||||
]);
|
||||
|
||||
test('dispatch webhook job broadcasts a slim log update when the endpoint is blocked', function () {
|
||||
Event::fake([LogUpdated::class]);
|
||||
|
||||
$this->webhook->update(['endpoint' => 'http://127.0.0.1/webhook']);
|
||||
|
||||
$job = new DispatchWebhook(
|
||||
$this->webhook,
|
||||
WebhookEvent::PostPublished->value,
|
||||
['id' => 'test-123'],
|
||||
);
|
||||
|
||||
try {
|
||||
app()->call([$job, 'handle']);
|
||||
} catch (RuntimeException) {
|
||||
// SSRF guard rethrows after the log is persisted.
|
||||
}
|
||||
|
||||
Event::assertDispatched(LogUpdated::class, function (LogUpdated $event) use ($job): bool {
|
||||
return $event->log->id === $job->logId
|
||||
&& ! array_key_exists('payload', $event->broadcastWith())
|
||||
&& ! array_key_exists('response_body', $event->broadcastWith());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
54
tests/Unit/Events/LogUpdatedTest.php
Normal file
54
tests/Unit/Events/LogUpdatedTest.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Events\Webhook\LogUpdated;
|
||||
use App\Models\WebhookLog;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
|
||||
$oversizedDelivery = [
|
||||
'payload' => [
|
||||
'id' => 'huge-post-payload',
|
||||
'type' => 'post.published',
|
||||
'data' => ['content' => str_repeat('x', 20_000)],
|
||||
],
|
||||
'response_body' => str_repeat('y', 2_000),
|
||||
];
|
||||
|
||||
test('event broadcasts on the webhook logs channel', function () {
|
||||
$log = WebhookLog::factory()->create();
|
||||
|
||||
$channels = (new LogUpdated($log))->broadcastOn();
|
||||
|
||||
expect($channels)->toHaveCount(1)
|
||||
->and($channels[0])->toBeInstanceOf(PrivateChannel::class)
|
||||
->and($channels[0]->name)->toBe("private-webhook.{$log->webhook_id}.logs");
|
||||
});
|
||||
|
||||
test('event broadcasts as a stable name on the broadcasts queue', function () {
|
||||
$log = WebhookLog::factory()->create();
|
||||
$event = new LogUpdated($log);
|
||||
|
||||
expect($event->broadcastAs())->toBe('webhook.log.updated')
|
||||
->and($event->broadcastQueue())->toBe('broadcasts');
|
||||
});
|
||||
|
||||
test('event broadcasts log status without the delivery payload', function (WebhookLog $log) {
|
||||
$broadcast = (new LogUpdated($log))->broadcastWith();
|
||||
|
||||
expect($broadcast)->toBe([
|
||||
'id' => $log->id,
|
||||
'event_type' => $log->event_type,
|
||||
'response_status' => $log->response_status,
|
||||
'delivered_at' => $log->delivered_at?->toIso8601String(),
|
||||
'failed_at' => $log->failed_at?->toIso8601String(),
|
||||
'attempts' => $log->attempts,
|
||||
'created_at' => $log->created_at->toIso8601String(),
|
||||
])
|
||||
->and($broadcast)->not->toHaveKeys(['payload', 'response_body', 'webhook_id'])
|
||||
->and(strlen((string) json_encode($broadcast)))->toBeLessThan(10_000);
|
||||
})->with([
|
||||
'delivered' => fn () => WebhookLog::factory()->create($oversizedDelivery),
|
||||
'failed' => fn () => WebhookLog::factory()->failed()->create($oversizedDelivery),
|
||||
'pending' => fn () => WebhookLog::factory()->pending()->create($oversizedDelivery),
|
||||
]);
|
||||
Loading…
Reference in a new issue