test(webhook): unit-test WebhookTestPingService — 7 кейсов без сети

Покрытие: 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.
This commit is contained in:
Zimin A.N.
2026-05-07 10:48:21 +03:00
parent 09d2d1c325
commit c7a917ddad
2 changed files with 239 additions and 18 deletions
@@ -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<String> 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<String> allowedHosts, HttpClient httpClient) {
this.objectMapper = objectMapper;
this.httpClient = httpClient;
this.ssrfGuard = new SsrfGuard(allowedHosts);
}
private static List<String> 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<String> resp = httpClient.send(req, BodyHandlers.ofString(StandardCharsets.UTF_8));
HttpResponse<String> 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<String> sendRequest(HttpRequest req) throws Exception {
return httpClient.send(req, BodyHandlers.ofString(StandardCharsets.UTF_8));
}
private Map<String, Object> buildSyntheticPayload(WebhookSubscription sub) {
Map<String, Object> p = new LinkedHashMap<>();
p.put("eventId", "test-ping");
@@ -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.
*
* <p>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<HttpRequest> 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<HttpRequest> 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<HttpRequest> 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<String, List<String>> 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<String> handle(HttpRequest req) throws Exception;
}
/**
* Build subclass с inline переопределённым sendRequest() — обходит
* Mockito ограничение на mock'ание HttpClient на Java 17+.
*/
private WebhookTestPingService stubbedService(List<String> allowed, RequestHandler handler) {
return new WebhookTestPingService(objectMapper, allowed, unusedClient) {
@Override
protected HttpResponse<String> 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<String> 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<HttpResponse<String>> 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<javax.net.ssl.SSLSession> 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; }
};
}
}