diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeProperties.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeProperties.java index 889bc702..0e95158b 100644 --- a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeProperties.java +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeProperties.java @@ -8,6 +8,8 @@ @ConfigurationProperties(prefix = "app.ai-runtime") public final class AiRuntimeProperties implements AiRuntimeDeadlinePolicy { + private static final String ANALYSIS_ENDPOINT_PATH = "/internal/v1/analyses"; + private static final String RENEWAL_ENDPOINT_PATH = "/internal/v1/workflows/renewal/run"; private static final int MIN_RESPONSE_BYTES = 1_024; private static final int MAX_RESPONSE_BYTES = 10 * 1_024 * 1_024; private static final Duration MAX_OVERALL_TIMEOUT = Duration.ofMinutes(5); @@ -180,6 +182,8 @@ String authorizationHeader() { void validateEnabledConfiguration() { requireHttpEndpoint(endpoint); requireHttpEndpoint(renewalEndpoint); + requireEndpointPath(endpoint, ANALYSIS_ENDPOINT_PATH, "AI_RUNTIME_ENDPOINT"); + requireEndpointPath(renewalEndpoint, RENEWAL_ENDPOINT_PATH, "AI_RUNTIME_RENEWAL_ENDPOINT"); requireHttpEndpoint(documentGenerationEndpoint); requireHttpEndpoint(documentConversionEndpoint); authorizationHeader(); @@ -188,6 +192,16 @@ void validateEnabledConfiguration() { requirePositive(documentConversionTimeout, "documentConversionTimeout"); } + private static void requireEndpointPath(URI endpoint, String expectedSuffix, String propertyName) { + String path = endpoint.getPath(); + String normalizedPath = path != null && path.endsWith("/") + ? path.substring(0, path.length() - 1) + : path; + if (normalizedPath == null || !normalizedPath.endsWith(expectedSuffix)) { + throw new IllegalStateException(propertyName + " must end with " + expectedSuffix); + } + } + private static URI requireHttpEndpoint(URI value) { if (value == null || !value.isAbsolute() diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteRenewalRuntimeClient.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteRenewalRuntimeClient.java index dfe0e29e..55136eed 100644 --- a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteRenewalRuntimeClient.java +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteRenewalRuntimeClient.java @@ -12,20 +12,28 @@ import java.net.http.HttpResponse; import java.net.http.HttpTimeoutException; import java.time.Duration; +import java.util.ArrayList; import java.util.EnumSet; +import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; /** One transport attempt against the Agent-owned Renewal endpoint. */ public final class RemoteRenewalRuntimeClient implements RenewalRuntimeClient { + private static final Logger log = LoggerFactory.getLogger(RemoteRenewalRuntimeClient.class); + private static final int MAX_VALIDATION_FIELDS = 10; private static final Set CIRCUIT_FAILURES = EnumSet.of( AiRuntimeFailureCode.DEADLINE_EXCEEDED, AiRuntimeFailureCode.RATE_LIMITED, @@ -89,7 +97,7 @@ public RenewalRunResponse run(RenewalRunRequest request, AiRuntimeCallContext co if (context.traceParent() != null) { builder.header("traceparent", context.traceParent()); } - RenewalRunResponse response = decode(execute(builder.build())); + RenewalRunResponse response = decode(execute(builder.build()), request.requestId()); circuitBreaker.recordSuccess(); return response; } catch (AiRuntimeCallException exception) { @@ -134,10 +142,10 @@ private HttpResponse execute(HttpRequest request) { } } - private RenewalRunResponse decode(HttpResponse response) { + private RenewalRunResponse decode(HttpResponse response, UUID requestId) { int status = response.statusCode(); if (status < 200 || status >= 300) { - throw classifyStatus(status); + throw classifyStatus(response, requestId); } try { return objectMapper.readValue(response.body(), RenewalRunResponse.class); @@ -173,7 +181,8 @@ private AiRuntimeCallException classifyExecutionFailure(Throwable cause) { return failure(AiRuntimeFailureCode.TRANSPORT_FAILURE, "AI Renewal transport failed.", failure); } - private AiRuntimeCallException classifyStatus(int status) { + private AiRuntimeCallException classifyStatus(HttpResponse response, UUID requestId) { + int status = response.statusCode(); if (status == 408) { return failure(AiRuntimeFailureCode.DEADLINE_EXCEEDED, "AI Renewal deadline was exceeded."); } @@ -183,12 +192,63 @@ private AiRuntimeCallException classifyStatus(int status) { if (status == 429) { return failure(AiRuntimeFailureCode.RATE_LIMITED, "AI Renewal rate limit was reached."); } - if (status >= 500) { + if (status == 404 || status == 405 || status >= 500) { return failure(AiRuntimeFailureCode.RUNTIME_UNAVAILABLE, "AI Renewal is unavailable."); } + if (status == 400 || status == 422) { + List validationFields = safeValidationFields(response.body()); + log.warn( + "event=ai_renewal_request_rejected request_id={} upstream_status={} validation_fields={}", + requestId, + status, + validationFields + ); + } return failure(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Renewal rejected the request contract."); } + /** Extracts only schema locations and validation types; values and Provider messages are never logged. */ + private List safeValidationFields(byte[] responseBody) { + try { + JsonNode detail = objectMapper.readTree(responseBody).path("detail"); + if (!detail.isArray()) { + return List.of(); + } + List fields = new ArrayList<>(); + for (JsonNode validation : detail) { + if (fields.size() >= MAX_VALIDATION_FIELDS) { + break; + } + JsonNode location = validation.path("loc"); + if (!location.isArray()) { + continue; + } + List segments = new ArrayList<>(); + for (JsonNode segment : location) { + String value = safeToken(segment.asString("")); + if (!value.isBlank() && !"body".equals(value)) { + segments.add(value); + } + } + if (!segments.isEmpty()) { + String type = safeToken(validation.path("type").asString("invalid")); + fields.add(String.join(".", segments) + ":" + type); + } + } + return List.copyOf(fields); + } catch (JacksonException exception) { + return List.of(); + } + } + + private String safeToken(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String sanitized = value.replaceAll("[^A-Za-z0-9_-]", ""); + return sanitized.substring(0, Math.min(sanitized.length(), 64)); + } + private Throwable unwrap(Throwable throwable) { Throwable current = throwable; while ((current instanceof ExecutionException diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimePropertiesTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimePropertiesTest.java index 9d996737..547554ab 100644 --- a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimePropertiesTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimePropertiesTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.net.URI; import java.time.Duration; import org.junit.jupiter.api.Test; @@ -35,4 +36,29 @@ void rejectsOverallTimeoutLongerThanContractMaximum() { .isInstanceOf(IllegalArgumentException.class) .hasMessage("overallTimeout must not exceed 5m"); } + + @Test + void rejectsAnalysisEndpointConfiguredAsRenewalEndpoint() { + AiRuntimeProperties properties = new AiRuntimeProperties(); + properties.setServiceCredential("test-token"); + properties.setRenewalEndpoint(URI.create("http://ai:8000/internal/v1/analyses")); + + assertThatThrownBy(properties::validateEnabledConfiguration) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "AI_RUNTIME_RENEWAL_ENDPOINT must end with /internal/v1/workflows/renewal/run" + ); + } + + @Test + void acceptsDeploymentEndpointsWithAProxyPrefix() { + AiRuntimeProperties properties = new AiRuntimeProperties(); + properties.setServiceCredential("test-token"); + properties.setEndpoint(URI.create("https://agent.example.com/fowoco/internal/v1/analyses")); + properties.setRenewalEndpoint(URI.create( + "https://agent.example.com/fowoco/internal/v1/workflows/renewal/run/" + )); + + properties.validateEnabledConfiguration(); + } } diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteRenewalRuntimeClientWireMockTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteRenewalRuntimeClientWireMockTest.java index cf989d5c..5cdfcd56 100644 --- a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteRenewalRuntimeClientWireMockTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteRenewalRuntimeClientWireMockTest.java @@ -6,7 +6,13 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext; import com.fowoco.server.aiintegration.application.renewal.RenewalCompanySnapshot; import com.fowoco.server.aiintegration.application.renewal.RenewalRunRequest; @@ -25,6 +31,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.PropertyNamingStrategies; @@ -59,6 +66,9 @@ void sendsTheAgentOwnedContractWithBearerAuthentication() { .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( "$.worker.stayExpiryDate", equalTo("2027-08-31") )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.worker.createdAt", equalTo("2026-08-10T00:00:00Z") + )) .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( "$.task.workflowId", equalTo("WF-CON-001") )) @@ -114,6 +124,66 @@ void decodesTheWorkerGuideReviewContract() { assertThat(response.caseSignals()).containsExactly("REVIEW_WORKER_GUIDE"); } + @Test + void logsOnlySafeValidationLocationsWhenTheAgentRejectsTheRequest() { + RenewalRunRequest request = request(); + wireMock.stubFor(post(urlEqualTo(PATH)) + .willReturn(aResponse() + .withStatus(422) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "detail":[ + { + "type":"missing", + "loc":["body","phase"], + "msg":"Field required", + "input":{"passport_number":"M12345678"} + }, + { + "type":"missing", + "loc":["body","analysisInput"], + "msg":"Field required" + } + ] + } + """))); + Logger logger = (Logger) LoggerFactory.getLogger(RemoteRenewalRuntimeClient.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + assertThatThrownBy(() -> client().run(request, AiRuntimeCallContext.withoutTrace())) + .isInstanceOfSatisfying(AiRuntimeCallException.class, exception -> + assertThat(exception.failureCode()) + .isEqualTo(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT)); + + assertThat(appender.list).hasSize(1); + assertThat(appender.list.get(0).getFormattedMessage()) + .contains( + "request_id=" + request.requestId(), + "upstream_status=422", + "phase:missing", + "analysisInput:missing" + ) + .doesNotContain("passport_number", "M12345678", "Field required"); + } finally { + logger.detachAppender(appender); + appender.stop(); + } + } + + @Test + void treatsMissingRenewalEndpointAsRuntimeUnavailable() { + RenewalRunRequest request = request(); + wireMock.stubFor(post(urlEqualTo(PATH)).willReturn(aResponse().withStatus(404))); + + assertThatThrownBy(() -> client().run(request, AiRuntimeCallContext.withoutTrace())) + .isInstanceOfSatisfying(AiRuntimeCallException.class, exception -> + assertThat(exception.failureCode()) + .isEqualTo(AiRuntimeFailureCode.RUNTIME_UNAVAILABLE)); + } + private RemoteRenewalRuntimeClient client() { return new RemoteRenewalRuntimeClient( URI.create(wireMock.baseUrl() + PATH),