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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user