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:
@@ -229,3 +229,11 @@ export type WebhookDeliveryPage = {
|
|||||||
number: number
|
number: number
|
||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WebhookTestPingResult = {
|
||||||
|
/** success | failure | rejected */
|
||||||
|
status: 'success' | 'failure' | 'rejected'
|
||||||
|
httpStatus?: number | null
|
||||||
|
latencyMs?: number | null
|
||||||
|
message?: string | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type RecordResponse,
|
type RecordResponse,
|
||||||
type WebhookDelivery,
|
type WebhookDelivery,
|
||||||
type WebhookSubscription,
|
type WebhookSubscription,
|
||||||
|
type WebhookTestPingResult,
|
||||||
} from './client'
|
} from './client'
|
||||||
|
|
||||||
const idempotencyKey = () =>
|
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,
|
* Replay delivery из DLQ или retrying — backend сбрасывает attemptCount=0,
|
||||||
* dlqAt=null, status=retrying. Dispatcher подхватит на следующем sendTick.
|
* dlqAt=null, status=retrying. Dispatcher подхватит на следующем sendTick.
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ i18n
|
|||||||
'webhooks.action.create': 'Создать',
|
'webhooks.action.create': 'Создать',
|
||||||
'webhooks.action.delete': 'Удалить',
|
'webhooks.action.delete': 'Удалить',
|
||||||
'webhooks.action.rotateSecret': 'Сменить secret',
|
'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.confirmDelete': 'Удалить подписку «{{name}}»? Все pending deliveries удалятся каскадом.',
|
||||||
'webhooks.confirmRotate': 'Сменить HMAC secret? Старый сразу перестанет работать — receiver надо обновить заранее.',
|
'webhooks.confirmRotate': 'Сменить HMAC secret? Старый сразу перестанет работать — receiver надо обновить заранее.',
|
||||||
'webhooks.field.name': 'Имя подписки',
|
'webhooks.field.name': 'Имя подписки',
|
||||||
@@ -285,6 +290,11 @@ i18n
|
|||||||
'webhooks.action.create': 'Create',
|
'webhooks.action.create': 'Create',
|
||||||
'webhooks.action.delete': 'Delete',
|
'webhooks.action.delete': 'Delete',
|
||||||
'webhooks.action.rotateSecret': 'Rotate secret',
|
'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.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.confirmRotate': 'Rotate HMAC secret? The old one stops working immediately — update the receiver first.',
|
||||||
'webhooks.field.name': 'Subscription name',
|
'webhooks.field.name': 'Subscription name',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ArrowClockwiseIcon,
|
ArrowClockwiseIcon,
|
||||||
ArrowsClockwiseIcon,
|
ArrowsClockwiseIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
|
PaperPlaneTiltIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@phosphor-icons/react'
|
} from '@phosphor-icons/react'
|
||||||
import {
|
import {
|
||||||
@@ -31,7 +32,9 @@ import {
|
|||||||
useDeleteWebhook,
|
useDeleteWebhook,
|
||||||
useRetryWebhookDelivery,
|
useRetryWebhookDelivery,
|
||||||
useRotateWebhookSecret,
|
useRotateWebhookSecret,
|
||||||
|
useTestWebhook,
|
||||||
} from '@/api/mutations'
|
} from '@/api/mutations'
|
||||||
|
import type { WebhookTestPingResult } from '@/api/client'
|
||||||
import { SCOPE_DOT } from '@/lib/scope-style'
|
import { SCOPE_DOT } from '@/lib/scope-style'
|
||||||
|
|
||||||
export const Route = createFileRoute('/webhooks/$id')({
|
export const Route = createFileRoute('/webhooks/$id')({
|
||||||
@@ -54,6 +57,20 @@ function WebhookDetailPage() {
|
|||||||
const rotateMut = useRotateWebhookSecret()
|
const rotateMut = useRotateWebhookSecret()
|
||||||
const deleteMut = useDeleteWebhook()
|
const deleteMut = useDeleteWebhook()
|
||||||
const retryMut = useRetryWebhookDelivery()
|
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) => {
|
const handleRetry = (deliveryId: number) => {
|
||||||
if (!window.confirm(t('webhooks.deliveries.confirmRetry'))) return
|
if (!window.confirm(t('webhooks.deliveries.confirmRetry'))) return
|
||||||
@@ -101,6 +118,14 @@ function WebhookDetailPage() {
|
|||||||
description={data.description ?? data.url}
|
description={data.description ?? data.url}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
leftIcon={<KeyIcon weight="bold" size={16} />}
|
leftIcon={<KeyIcon weight="bold" size={16} />}
|
||||||
@@ -121,6 +146,8 @@ function WebhookDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{testResult && <TestResultAlert result={testResult} onDismiss={() => setTestResult(null)} />}
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4">
|
||||||
<InfoRow label={t('webhooks.field.url')}>
|
<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 }) {
|
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.service.webhook;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscription;
|
||||||
|
import cloud.nstart.terravault.ordinis.outbox.webhook.HmacSigner;
|
||||||
|
import cloud.nstart.terravault.ordinis.outbox.webhook.SsrfGuard;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpRequest.BodyPublishers;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.net.http.HttpResponse.BodyHandlers;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous test ping для webhook subscription. Admin кликает кнопку в UI →
|
||||||
|
* receiver получает synthetic event ровно с теми же headers/HMAC что и
|
||||||
|
* production delivery, ответ возвращается сразу для inline feedback.
|
||||||
|
*
|
||||||
|
* <p>Use case: после создания subscription verify что:
|
||||||
|
* <ul>
|
||||||
|
* <li>URL достижим (DNS, network, TLS)
|
||||||
|
* <li>Receiver не отвергает HMAC signature (admin раскопировал secret правильно)
|
||||||
|
* <li>Receiver обрабатывает payload без 5xx
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Bypass'ит outbox/dispatcher — sync request, immediate response. Не
|
||||||
|
* порождает webhook_deliveries row (test event, не реальный business event).
|
||||||
|
*
|
||||||
|
* <p>SSRF guard validate'ит URL host против allowed-hosts list (override) +
|
||||||
|
* private CIDR check. Тот же что у dispatcher — единая security policy.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class WebhookTestPingService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(WebhookTestPingService.class);
|
||||||
|
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
|
||||||
|
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
@Value("${ordinis.webhook.allowed-hosts:}")
|
||||||
|
private String allowedHostsCsv;
|
||||||
|
|
||||||
|
private SsrfGuard ssrfGuard;
|
||||||
|
|
||||||
|
public WebhookTestPingService(ObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(CONNECT_TIMEOUT)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void init() {
|
||||||
|
List<String> allowed = allowedHostsCsv == null || allowedHostsCsv.isBlank()
|
||||||
|
? List.of()
|
||||||
|
: Arrays.stream(allowedHostsCsv.split(",")).map(String::trim).toList();
|
||||||
|
this.ssrfGuard = new SsrfGuard(allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result testPing(WebhookSubscription sub) {
|
||||||
|
String ssrfReason = ssrfGuard.validate(sub.getUrl());
|
||||||
|
if (ssrfReason != null) {
|
||||||
|
return Result.rejected("SSRF guard rejected URL: " + ssrfReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> payload = buildSyntheticPayload(sub);
|
||||||
|
byte[] body;
|
||||||
|
try {
|
||||||
|
body = objectMapper.writeValueAsBytes(payload);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
return Result.rejected("Failed to serialize test payload: " + e.getMessage());
|
||||||
|
}
|
||||||
|
String signature = HmacSigner.sign(sub.getHmacSecret(), body);
|
||||||
|
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(sub.getUrl()))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header(HmacSigner.HEADER_NAME, signature)
|
||||||
|
.header("X-Ordinis-Event-Id", "test-ping")
|
||||||
|
.header("X-Ordinis-Event-Type", "test.ping")
|
||||||
|
.header("X-Ordinis-Scope", "PUBLIC")
|
||||||
|
.header("X-Ordinis-Test", "true")
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.POST(BodyPublishers.ofByteArray(body))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
long start = System.nanoTime();
|
||||||
|
try {
|
||||||
|
HttpResponse<String> resp = httpClient.send(req, BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
long ms = (System.nanoTime() - start) / 1_000_000L;
|
||||||
|
int status = resp.statusCode();
|
||||||
|
String preview = trim(resp.body(), 200);
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
return Result.success(status, ms, preview);
|
||||||
|
}
|
||||||
|
return Result.failure(status, ms, "HTTP " + status + " — " + preview);
|
||||||
|
} catch (Exception e) {
|
||||||
|
long ms = (System.nanoTime() - start) / 1_000_000L;
|
||||||
|
log.warn("Test ping to {} failed", sub.getUrl(), e);
|
||||||
|
return Result.failure(null, ms, e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildSyntheticPayload(WebhookSubscription sub) {
|
||||||
|
Map<String, Object> p = new LinkedHashMap<>();
|
||||||
|
p.put("eventId", "test-ping");
|
||||||
|
p.put("eventType", "test.ping");
|
||||||
|
p.put("scope", "PUBLIC");
|
||||||
|
p.put("subscriptionId", sub.getId().toString());
|
||||||
|
p.put("subscriptionName", sub.getName());
|
||||||
|
p.put("timestamp", OffsetDateTime.now().toString());
|
||||||
|
p.put("message", "Test event from Ordinis admin. Если receiver получил — он живой и валидирует HMAC.");
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String s, int max) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.length() <= max ? s : s.substring(0, max) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result test ping'а. status:
|
||||||
|
* <ul>
|
||||||
|
* <li>success — HTTP 2xx
|
||||||
|
* <li>failure — HTTP non-2xx ИЛИ network exception (timeout, connection refused)
|
||||||
|
* <li>rejected — SSRF guard / serialization error, request даже не отправлен
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public record Result(
|
||||||
|
String status,
|
||||||
|
Integer httpStatus,
|
||||||
|
Long latencyMs,
|
||||||
|
String message) {
|
||||||
|
|
||||||
|
public static Result success(int httpStatus, long latencyMs, String body) {
|
||||||
|
return new Result("success", httpStatus, latencyMs, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result failure(Integer httpStatus, long latencyMs, String error) {
|
||||||
|
return new Result("failure", httpStatus, latencyMs, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result rejected(String reason) {
|
||||||
|
return new Result("rejected", null, null, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -11,6 +11,7 @@ import cloud.nstart.terravault.ordinis.restapi.dto.webhook.WebhookDeliveryRespon
|
|||||||
import cloud.nstart.terravault.ordinis.restapi.dto.webhook.WebhookSubscriptionResponse;
|
import cloud.nstart.terravault.ordinis.restapi.dto.webhook.WebhookSubscriptionResponse;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.service.webhook.WebhookSubscriptionService;
|
import cloud.nstart.terravault.ordinis.restapi.service.webhook.WebhookSubscriptionService;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.webhook.WebhookTestPingService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -55,16 +56,19 @@ public class WebhookSubscriptionController {
|
|||||||
private final WebhookSubscriptionService service;
|
private final WebhookSubscriptionService service;
|
||||||
private final WebhookSubscriptionRepository subscriptionRepository;
|
private final WebhookSubscriptionRepository subscriptionRepository;
|
||||||
private final WebhookDeliveryRepository deliveryRepository;
|
private final WebhookDeliveryRepository deliveryRepository;
|
||||||
|
private final WebhookTestPingService testPingService;
|
||||||
private final ScopeContext scopeContext;
|
private final ScopeContext scopeContext;
|
||||||
|
|
||||||
public WebhookSubscriptionController(
|
public WebhookSubscriptionController(
|
||||||
WebhookSubscriptionService service,
|
WebhookSubscriptionService service,
|
||||||
WebhookSubscriptionRepository subscriptionRepository,
|
WebhookSubscriptionRepository subscriptionRepository,
|
||||||
WebhookDeliveryRepository deliveryRepository,
|
WebhookDeliveryRepository deliveryRepository,
|
||||||
|
WebhookTestPingService testPingService,
|
||||||
ScopeContext scopeContext) {
|
ScopeContext scopeContext) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
this.subscriptionRepository = subscriptionRepository;
|
this.subscriptionRepository = subscriptionRepository;
|
||||||
this.deliveryRepository = deliveryRepository;
|
this.deliveryRepository = deliveryRepository;
|
||||||
|
this.testPingService = testPingService;
|
||||||
this.scopeContext = scopeContext;
|
this.scopeContext = scopeContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +156,22 @@ public class WebhookSubscriptionController {
|
|||||||
*
|
*
|
||||||
* <p>RBAC: RESTRICTED — это destructive op (можно spam'ить receiver).
|
* <p>RBAC: RESTRICTED — это destructive op (можно spam'ить receiver).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Synchronous test ping — admin кликает кнопку в UI, receiver получает
|
||||||
|
* synthetic event с теми же headers/HMAC что у production delivery.
|
||||||
|
* Возвращает result inline для immediate feedback (latency, http status,
|
||||||
|
* body preview / error).
|
||||||
|
*
|
||||||
|
* <p>Не порождает webhook_deliveries row — это test, не business event.
|
||||||
|
* RBAC: RESTRICTED — потому что отправляет HTTP request на user-controlled URL.
|
||||||
|
*/
|
||||||
|
@PostMapping("/subscriptions/{id}/test")
|
||||||
|
public WebhookTestPingService.Result testSubscription(@PathVariable UUID id) {
|
||||||
|
requireAdmin();
|
||||||
|
WebhookSubscription sub = service.findById(id);
|
||||||
|
return testPingService.testPing(sub);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/deliveries/{id}/retry")
|
@PostMapping("/deliveries/{id}/retry")
|
||||||
public WebhookDeliveryResponse retryDelivery(@PathVariable Long id) {
|
public WebhookDeliveryResponse retryDelivery(@PathVariable Long id) {
|
||||||
requireAdmin();
|
requireAdmin();
|
||||||
|
|||||||
Reference in New Issue
Block a user