feat(webhook): test ping — admin верифицирует receiver одной кнопкой

После создания subscription / rotate secret admin не имел способа
проверить что receiver жив и валидирует HMAC без ожидания реального
business event. Теперь:

- POST /api/v1/admin/webhooks/subscriptions/{id}/test
- Synchronous: receiver получает synthetic event с теми же headers
  что у production delivery (X-Ordinis-Signature, X-Ordinis-Event-Type,
  X-Ordinis-Scope) + дополнительный X-Ordinis-Test=true чтобы
  receiver мог логировать/филтровать как тест.
- HMAC sign'ится через тот же HmacSigner — admin раскопировал secret
  правильно если receiver валидирует. Иначе receiver вернёт 4xx
  и UI покажет ошибку.
- SSRF guard validate URL host (private CIDR + allowed-hosts override)
  — same policy как у dispatcher.

Bypass'ит outbox/dispatcher — нет webhook_deliveries row, нет attempt
count, нет следов в аудите доставок (это test, не business event).

UI:
- Кнопка 'Тест ping' с PaperPlaneTilt иконкой рядом с 'Сменить secret'
  на webhook detail page.
- Inline Alert после ответа: success/failure/rejected с HTTP status,
  latency, body preview / error message. Dismiss '✕'.

RBAC: RESTRICTED — endpoint отправляет HTTP request на user-controlled
URL (potential abuse vector если бы был INTERNAL).

Tests: rest-api compiles, 76 admin-ui passed.
This commit is contained in:
Zimin A.N.
2026-05-06 20:59:47 +03:00
parent a2d5df2430
commit 66e89ce707
6 changed files with 304 additions and 0 deletions
+8
View File
@@ -229,3 +229,11 @@ export type WebhookDeliveryPage = {
number: number
size: number
}
export type WebhookTestPingResult = {
/** success | failure | rejected */
status: 'success' | 'failure' | 'rejected'
httpStatus?: number | null
latencyMs?: number | null
message?: string | null
}
+19
View File
@@ -8,6 +8,7 @@ import {
type RecordResponse,
type WebhookDelivery,
type WebhookSubscription,
type WebhookTestPingResult,
} from './client'
const idempotencyKey = () =>
@@ -166,6 +167,24 @@ export const useRotateWebhookSecret = () => {
})
}
/**
* Test ping — receiver получает synthetic event с тем же HMAC и headers
* что у production delivery. Возвращает result sync для inline feedback.
*
* Use case: после создания subscription / rotate secret — verify что
* receiver достижим, валидирует HMAC, не возвращает 5xx.
*/
export const useTestWebhook = () => {
return useMutation({
mutationFn: async (id: string): Promise<WebhookTestPingResult> => {
const { data } = await apiClient.post<WebhookTestPingResult>(
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}/test`,
)
return data
},
})
}
/**
* Replay delivery из DLQ или retrying — backend сбрасывает attemptCount=0,
* dlqAt=null, status=retrying. Dispatcher подхватит на следующем sendTick.
+10
View File
@@ -34,6 +34,11 @@ i18n
'webhooks.action.create': 'Создать',
'webhooks.action.delete': 'Удалить',
'webhooks.action.rotateSecret': 'Сменить secret',
'webhooks.action.test': 'Тест ping',
'webhooks.test.success': 'Receiver получил test event и ответил 2xx',
'webhooks.test.failure': 'Receiver не принял test event',
'webhooks.test.rejected': 'Запрос не отправлен (SSRF guard / serialization)',
'webhooks.test.latency': 'latency',
'webhooks.confirmDelete': 'Удалить подписку «{{name}}»? Все pending deliveries удалятся каскадом.',
'webhooks.confirmRotate': 'Сменить HMAC secret? Старый сразу перестанет работать — receiver надо обновить заранее.',
'webhooks.field.name': 'Имя подписки',
@@ -285,6 +290,11 @@ i18n
'webhooks.action.create': 'Create',
'webhooks.action.delete': 'Delete',
'webhooks.action.rotateSecret': 'Rotate secret',
'webhooks.action.test': 'Test ping',
'webhooks.test.success': 'Receiver got the test event and replied 2xx',
'webhooks.test.failure': 'Receiver rejected the test event',
'webhooks.test.rejected': 'Request not sent (SSRF guard / serialization)',
'webhooks.test.latency': 'latency',
'webhooks.confirmDelete': 'Delete subscription "{{name}}"? All pending deliveries will be cascade-deleted.',
'webhooks.confirmRotate': 'Rotate HMAC secret? The old one stops working immediately — update the receiver first.',
'webhooks.field.name': 'Subscription name',
@@ -21,6 +21,7 @@ import {
ArrowClockwiseIcon,
ArrowsClockwiseIcon,
KeyIcon,
PaperPlaneTiltIcon,
TrashIcon,
} from '@phosphor-icons/react'
import {
@@ -31,7 +32,9 @@ import {
useDeleteWebhook,
useRetryWebhookDelivery,
useRotateWebhookSecret,
useTestWebhook,
} from '@/api/mutations'
import type { WebhookTestPingResult } from '@/api/client'
import { SCOPE_DOT } from '@/lib/scope-style'
export const Route = createFileRoute('/webhooks/$id')({
@@ -54,6 +57,20 @@ function WebhookDetailPage() {
const rotateMut = useRotateWebhookSecret()
const deleteMut = useDeleteWebhook()
const retryMut = useRetryWebhookDelivery()
const testMut = useTestWebhook()
const [testResult, setTestResult] = useState<WebhookTestPingResult | null>(null)
const handleTest = () => {
setTestResult(null)
testMut.mutate(id, {
onSuccess: (r) => setTestResult(r),
onError: (e) =>
setTestResult({
status: 'rejected',
message: e instanceof Error ? e.message : String(e),
}),
})
}
const handleRetry = (deliveryId: number) => {
if (!window.confirm(t('webhooks.deliveries.confirmRetry'))) return
@@ -101,6 +118,14 @@ function WebhookDetailPage() {
description={data.description ?? data.url}
actions={
<div className="flex items-center gap-2">
<Button
variant="secondary"
leftIcon={<PaperPlaneTiltIcon weight="bold" size={16} />}
loading={testMut.isPending}
onClick={handleTest}
>
{t('webhooks.action.test')}
</Button>
<Button
variant="secondary"
leftIcon={<KeyIcon weight="bold" size={16} />}
@@ -121,6 +146,8 @@ function WebhookDetailPage() {
}
/>
{testResult && <TestResultAlert result={testResult} onDismiss={() => setTestResult(null)} />}
<Panel>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4">
<InfoRow label={t('webhooks.field.url')}>
@@ -291,6 +318,63 @@ function WebhookDetailPage() {
)
}
function TestResultAlert({
result,
onDismiss,
}: {
result: WebhookTestPingResult
onDismiss: () => void
}) {
const { t } = useTranslation()
const variant =
result.status === 'success' ? 'success' : result.status === 'failure' ? 'error' : 'warning'
const titleKey =
result.status === 'success'
? 'webhooks.test.success'
: result.status === 'failure'
? 'webhooks.test.failure'
: 'webhooks.test.rejected'
return (
<Alert
variant={variant}
title={t(titleKey)}
action={
<button
type="button"
className="text-xs text-carbon/70 hover:text-carbon"
onClick={onDismiss}
>
</button>
}
>
<div className="space-y-1 text-sm">
{result.httpStatus != null && (
<div>
<span className="text-2xs uppercase tracking-label text-carbon/60 mr-2">
HTTP
</span>
<span className="font-mono">{result.httpStatus}</span>
</div>
)}
{result.latencyMs != null && (
<div>
<span className="text-2xs uppercase tracking-label text-carbon/60 mr-2">
{t('webhooks.test.latency')}
</span>
<span className="font-mono">{result.latencyMs} ms</span>
</div>
)}
{result.message && (
<div className="font-mono text-2xs text-carbon/80 whitespace-pre-wrap break-all">
{result.message}
</div>
)}
</div>
</Alert>
)
}
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>