From c7a917ddad419b215d46a93fcf98a03d7b0528e0 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Thu, 7 May 2026 10:48:21 +0300 Subject: [PATCH] =?UTF-8?q?test(webhook):=20unit-test=20WebhookTestPingSer?= =?UTF-8?q?vice=20=E2=80=94=207=20=D0=BA=D0=B5=D0=B9=D1=81=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D1=81=D0=B5=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Покрытие: SSRF reject (private CIDR + non-http scheme), success 2xx, failure 5xx, network exception, body trim до 200 chars, HMAC signature header. Нет реальных HTTP запросов — anonymous subclass переопределяет protected sendRequest(). Refactor: allowedHostsCsv → @Value parameter в конструкторе вместо @PostConstruct + поле, добавлен package-private constructor для inject HttpClient/allowedHosts списка. Mockito не может мокать sealed HttpClient на Java 17+, поэтому через extension point sendRequest(). Lightweight HttpResponse stub реализует только statusCode()+body() без Mockito. --- .../webhook/WebhookTestPingService.java | 54 +++-- .../webhook/WebhookTestPingServiceTest.java | 203 ++++++++++++++++++ 2 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookTestPingServiceTest.java diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookTestPingService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookTestPingService.java index 1f9e51d..5a1e77a 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookTestPingService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookTestPingService.java @@ -5,7 +5,6 @@ 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; @@ -52,25 +51,34 @@ public class WebhookTestPingService { private final ObjectMapper objectMapper; private final HttpClient httpClient; + private final SsrfGuard ssrfGuard; - @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(); + /** + * Spring constructor — собирает HttpClient с дефолтным connect timeout, читает + * allowed-hosts CSV из config. Тестовый код использует package-private + * конструктор {@link #WebhookTestPingService(ObjectMapper, List, HttpClient)}. + */ + public WebhookTestPingService( + ObjectMapper objectMapper, + @Value("${ordinis.webhook.allowed-hosts:}") String allowedHostsCsv) { + this( + objectMapper, + parseAllowedHosts(allowedHostsCsv), + HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build()); } - @PostConstruct - void init() { - List allowed = allowedHostsCsv == null || allowedHostsCsv.isBlank() - ? List.of() - : Arrays.stream(allowedHostsCsv.split(",")).map(String::trim).toList(); - this.ssrfGuard = new SsrfGuard(allowed); + /** Package-private constructor для unit-тестов: inject mock HttpClient. */ + WebhookTestPingService(ObjectMapper objectMapper, List allowedHosts, HttpClient httpClient) { + this.objectMapper = objectMapper; + this.httpClient = httpClient; + this.ssrfGuard = new SsrfGuard(allowedHosts); + } + + private static List parseAllowedHosts(String csv) { + if (csv == null || csv.isBlank()) { + return List.of(); + } + return Arrays.stream(csv.split(",")).map(String::trim).toList(); } public Result testPing(WebhookSubscription sub) { @@ -102,7 +110,7 @@ public class WebhookTestPingService { long start = System.nanoTime(); try { - HttpResponse resp = httpClient.send(req, BodyHandlers.ofString(StandardCharsets.UTF_8)); + HttpResponse resp = sendRequest(req); long ms = (System.nanoTime() - start) / 1_000_000L; int status = resp.statusCode(); String preview = trim(resp.body(), 200); @@ -117,6 +125,16 @@ public class WebhookTestPingService { } } + /** + * Extension point для тестов. Mockito не может мокать {@link HttpClient} + * на Java 17+ (sealed/abstract class), поэтому unit-тесты переопределяют + * этот protected метод через анонимный subclass и возвращают заранее + * заготовленные ответы. + */ + protected HttpResponse sendRequest(HttpRequest req) throws Exception { + return httpClient.send(req, BodyHandlers.ofString(StandardCharsets.UTF_8)); + } + private Map buildSyntheticPayload(WebhookSubscription sub) { Map p = new LinkedHashMap<>(); p.put("eventId", "test-ping"); diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookTestPingServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookTestPingServiceTest.java new file mode 100644 index 0000000..09f2039 --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookTestPingServiceTest.java @@ -0,0 +1,203 @@ +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 com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit-тесты {@link WebhookTestPingService}: SSRF reject, success/failure + * paths, network exception, body trim, HMAC signature header. + * + *

HttpClient не мокается напрямую (Mockito inline-mock не поддерживает + * sealed класс {@link HttpClient} на Java 17+). Вместо этого тесты создают + * анонимный subclass сервиса и переопределяют {@code sendRequest()}. + */ +class WebhookTestPingServiceTest { + + private static final String SECRET = "deadbeefcafebabe1234567890abcdef00112233445566778899aabbccddeeff"; + private static final String ALLOWED_URL = "https://hooks.example.test/webhook"; + private static final String ALLOWED_HOST = "hooks.example.test"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + // Сам HttpClient не используется в тестах — все вызовы перехватываются в sendRequest(). + private final HttpClient unusedClient = HttpClient.newHttpClient(); + + @Test + void rejected_whenUrlIsPrivateCidr() { + AtomicReference sent = new AtomicReference<>(); + WebhookTestPingService service = stubbedService( + List.of(), + req -> { sent.set(req); return stubResponse(200, "should not be called"); }); + // 10.255.255.255 — IPv4 literal в RFC 1918 диапазоне. SsrfGuard.isPrivate + // вернёт true (isSiteLocalAddress), DNS lookup не нужен → не flake'ит CI. + WebhookSubscription sub = newSubscription("http://10.255.255.255/hook"); + + WebhookTestPingService.Result result = service.testPing(sub); + + assertThat(result.status()).isEqualTo("rejected"); + assertThat(result.message()).contains("SSRF").contains("private"); + assertThat(result.httpStatus()).isNull(); + assertThat(result.latencyMs()).isNull(); + assertThat(sent.get()).as("sendRequest должен НЕ вызываться при SSRF reject").isNull(); + } + + @Test + void rejected_whenSchemeIsNotHttp() { + AtomicReference sent = new AtomicReference<>(); + WebhookTestPingService service = stubbedService( + List.of(), + req -> { sent.set(req); return stubResponse(200, ""); }); + WebhookSubscription sub = newSubscription("ftp://example.com/hook"); + + WebhookTestPingService.Result result = service.testPing(sub); + + assertThat(result.status()).isEqualTo("rejected"); + assertThat(result.message()).contains("scheme").contains("ftp"); + assertThat(sent.get()).isNull(); + } + + @Test + void success_when2xxResponse() { + WebhookTestPingService service = stubbedService( + List.of(ALLOWED_HOST), + req -> stubResponse(200, "{\"ok\":true}")); + WebhookSubscription sub = newSubscription(ALLOWED_URL); + + WebhookTestPingService.Result result = service.testPing(sub); + + assertThat(result.status()).isEqualTo("success"); + assertThat(result.httpStatus()).isEqualTo(200); + assertThat(result.latencyMs()).isNotNull().isGreaterThanOrEqualTo(0L); + assertThat(result.message()).isEqualTo("{\"ok\":true}"); + } + + @Test + void failure_when5xxResponse() { + WebhookTestPingService service = stubbedService( + List.of(ALLOWED_HOST), + req -> stubResponse(503, "service unavailable")); + WebhookSubscription sub = newSubscription(ALLOWED_URL); + + WebhookTestPingService.Result result = service.testPing(sub); + + assertThat(result.status()).isEqualTo("failure"); + assertThat(result.httpStatus()).isEqualTo(503); + assertThat(result.message()).contains("HTTP 503").contains("service unavailable"); + } + + @Test + void failure_whenNetworkException() { + WebhookTestPingService service = stubbedService( + List.of(ALLOWED_HOST), + req -> { throw new IOException("connection refused"); }); + WebhookSubscription sub = newSubscription(ALLOWED_URL); + + WebhookTestPingService.Result result = service.testPing(sub); + + assertThat(result.status()).isEqualTo("failure"); + assertThat(result.httpStatus()).isNull(); + assertThat(result.latencyMs()).isNotNull(); + assertThat(result.message()).contains("IOException").contains("connection refused"); + } + + @Test + void responseBodyIsTruncated_whenLongerThan200Chars() { + WebhookTestPingService service = stubbedService( + List.of(ALLOWED_HOST), + req -> stubResponse(200, "x".repeat(500))); + WebhookSubscription sub = newSubscription(ALLOWED_URL); + + WebhookTestPingService.Result result = service.testPing(sub); + + assertThat(result.status()).isEqualTo("success"); + assertThat(result.message()).hasSize(201).endsWith("…"); + } + + @Test + void requestHasHmacSignatureAndOrdinisHeaders() { + AtomicReference sent = new AtomicReference<>(); + WebhookTestPingService service = stubbedService( + List.of(ALLOWED_HOST), + req -> { sent.set(req); return stubResponse(200, "ok"); }); + WebhookSubscription sub = newSubscription(ALLOWED_URL); + + service.testPing(sub); + + HttpRequest req = sent.get(); + assertThat(req).isNotNull(); + assertThat(req.method()).isEqualTo("POST"); + assertThat(req.uri().toString()).isEqualTo(ALLOWED_URL); + + Map> headers = req.headers().map(); + assertThat(headers).containsKeys( + HmacSigner.HEADER_NAME, + "Content-Type", + "X-Ordinis-Event-Id", + "X-Ordinis-Event-Type", + "X-Ordinis-Scope", + "X-Ordinis-Test"); + assertThat(headers.get("Content-Type")).containsExactly("application/json"); + assertThat(headers.get("X-Ordinis-Event-Id")).containsExactly("test-ping"); + assertThat(headers.get("X-Ordinis-Event-Type")).containsExactly("test.ping"); + assertThat(headers.get("X-Ordinis-Test")).containsExactly("true"); + + String signature = headers.get(HmacSigner.HEADER_NAME).get(0); + assertThat(signature).matches("sha256=[0-9a-f]{64}"); + } + + // ---------- helpers ---------- + + /** Lambda которая решает, что вернуть на захваченный HttpRequest. */ + @FunctionalInterface + private interface RequestHandler { + HttpResponse handle(HttpRequest req) throws Exception; + } + + /** + * Build subclass с inline переопределённым sendRequest() — обходит + * Mockito ограничение на mock'ание HttpClient на Java 17+. + */ + private WebhookTestPingService stubbedService(List allowed, RequestHandler handler) { + return new WebhookTestPingService(objectMapper, allowed, unusedClient) { + @Override + protected HttpResponse sendRequest(HttpRequest req) throws Exception { + return handler.handle(req); + } + }; + } + + private static WebhookSubscription newSubscription(String url) { + return new WebhookSubscription("test-sub", url, SECRET, "tester"); + } + + /** + * Lightweight {@link HttpResponse} stub без Mockito — реализуем только + * вызываемые сервисом методы ({@link HttpResponse#statusCode()} и + * {@link HttpResponse#body()}). Остальное → {@link UnsupportedOperationException}. + */ + private static HttpResponse stubResponse(int statusCode, String body) { + return new HttpResponse<>() { + @Override public int statusCode() { return statusCode; } + @Override public String body() { return body; } + @Override public HttpRequest request() { throw new UnsupportedOperationException(); } + @Override public java.util.Optional> previousResponse() { return java.util.Optional.empty(); } + @Override public java.net.http.HttpHeaders headers() { + return java.net.http.HttpHeaders.of(Map.of(), (a, b) -> true); + } + @Override public java.util.Optional sslSession() { return java.util.Optional.empty(); } + @Override public java.net.URI uri() { throw new UnsupportedOperationException(); } + @Override public HttpClient.Version version() { return HttpClient.Version.HTTP_1_1; } + }; + } +}