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:
+36
-18
@@ -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");
|
||||
|
||||
+203
@@ -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; }
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user