diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java index b2fb64f1..8697f22c 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java @@ -3,14 +3,23 @@ package com.cameleer.server.app.outbound.controller; import com.cameleer.server.app.outbound.crypto.SecretCipher; import com.cameleer.server.app.outbound.dto.OutboundConnectionDto; import com.cameleer.server.app.outbound.dto.OutboundConnectionRequest; +import com.cameleer.server.app.outbound.dto.OutboundConnectionTestResult; import com.cameleer.server.core.admin.AuditCategory; import com.cameleer.server.core.admin.AuditResult; import com.cameleer.server.core.admin.AuditService; +import com.cameleer.server.core.http.OutboundHttpClientFactory; +import com.cameleer.server.core.http.OutboundHttpRequestContext; import com.cameleer.server.core.outbound.OutboundConnection; import com.cameleer.server.core.outbound.OutboundConnectionService; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -25,6 +34,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.UUID; @@ -38,11 +48,14 @@ public class OutboundConnectionAdminController { private final OutboundConnectionService service; private final SecretCipher cipher; private final AuditService audit; + private final OutboundHttpClientFactory httpClientFactory; - public OutboundConnectionAdminController(OutboundConnectionService service, SecretCipher cipher, AuditService audit) { + public OutboundConnectionAdminController(OutboundConnectionService service, SecretCipher cipher, + AuditService audit, OutboundHttpClientFactory httpClientFactory) { this.service = service; this.cipher = cipher; this.audit = audit; + this.httpClientFactory = httpClientFactory; } @GetMapping @@ -94,6 +107,36 @@ public class OutboundConnectionAdminController { return ResponseEntity.noContent().build(); } + @PostMapping("/{id}/test") + public OutboundConnectionTestResult test(@PathVariable UUID id, HttpServletRequest httpRequest) { + String userId = currentUserId(); + OutboundConnection c = service.get(id); + long t0 = System.currentTimeMillis(); + try { + var ctx = new OutboundHttpRequestContext(c.tlsTrustMode(), c.tlsCaPemPaths(), null, null); + CloseableHttpClient client = httpClientFactory.clientFor(ctx); + HttpPost request = new HttpPost(c.url()); + request.setEntity(new StringEntity("{\"probe\":true}", ContentType.APPLICATION_JSON)); + try (var resp = client.execute(request)) { + long latency = System.currentTimeMillis() - t0; + HttpEntity entity = resp.getEntity(); + String body = entity == null ? "" : EntityUtils.toString(entity, StandardCharsets.UTF_8); + String snippet = body.substring(0, Math.min(512, body.length())); + audit.log("test_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + id.toString(), Map.of("status", resp.getCode(), "latencyMs", latency), + AuditResult.SUCCESS, httpRequest); + return new OutboundConnectionTestResult(resp.getCode(), latency, snippet, + "TLS", null, null, null, null); + } + } catch (Exception e) { + long latency = System.currentTimeMillis() - t0; + audit.log("test_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + id.toString(), Map.of("error", e.getClass().getSimpleName(), "latencyMs", latency), + AuditResult.FAILURE, httpRequest); + return new OutboundConnectionTestResult(0, latency, null, null, null, null, null, e.getMessage()); + } + } + private OutboundConnection toDraft(OutboundConnectionRequest req) { String cipherSecret = (req.hmacSecret() == null || req.hmacSecret().isBlank()) ? null : cipher.encrypt(req.hmacSecret()); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java index 2a7a4381..62509b35 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java @@ -24,6 +24,12 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT { private String adminJwt; private String operatorJwt; private String viewerJwt; + private com.github.tomakehurst.wiremock.WireMockServer wireMock; + + @org.junit.jupiter.api.AfterEach + void tearDownWireMock() { + if (wireMock != null) wireMock.stop(); + } @BeforeEach void setUp() { @@ -132,4 +138,36 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT { String.class); assertThat(get.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } + + @Test + void testActionReturnsStatusAndLatency() throws Exception { + wireMock = new com.github.tomakehurst.wiremock.WireMockServer( + com.github.tomakehurst.wiremock.core.WireMockConfiguration.options() + .httpDisabled(true).dynamicHttpsPort()); + wireMock.start(); + wireMock.stubFor(com.github.tomakehurst.wiremock.client.WireMock.post("/probe") + .willReturn(com.github.tomakehurst.wiremock.client.WireMock.ok("pong"))); + + String createBody = """ + {"name":"probe-target","url":"https://localhost:%d/probe","method":"POST", + "tlsTrustMode":"TRUST_ALL","auth":{}}""".formatted(wireMock.httpsPort()); + + ResponseEntity create = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(createBody, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(create.getStatusCode()).isEqualTo(HttpStatus.CREATED); + String id = objectMapper.readTree(create.getBody()).path("id").asText(); + + ResponseEntity test = restTemplate.exchange( + "/api/v1/admin/outbound-connections/" + id + "/test", HttpMethod.POST, + new HttpEntity<>(securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(test.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(test.getBody()); + assertThat(body.path("status").asInt()).isEqualTo(200); + assertThat(body.path("latencyMs").asLong()).isGreaterThanOrEqualTo(0); + assertThat(body.path("tlsProtocol").asText()).isEqualTo("TLS"); + assertThat(body.path("error").isNull()).isTrue(); + } }