diff --git a/CHANGELOG.md b/CHANGELOG.md index fcb4d3b..b49ddcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,61 @@ All notable changes to the AxonFlow Java SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [8.2.0] - 2026-05-23 — `createHITLRequest` for explicit HITL row creation + +Enables agent-framework callers (Google ADK, n8n, OpenAI Agents SDK) to +implement the full 4-step HITL approval flow against AxonFlow: + + 1. Gate evaluates `require_approval` (via `pre_check` / `checkToolInput`) + 2. Caller invokes `axonflow.createHITLRequest(...)` to enqueue the row + 3. Caller polls `axonflow.getHITLRequest(approvalId)` until terminal state + 4. Caller resumes the agent or denies the call based on the decision + +Prior to this release the SDK exposed `getHITLRequest` / +`approveHITLRequest` / `rejectHITLRequest` (the read + review +surface) but had no method to **create** a row. The platform's +`POST /api/v1/hitl/queue` endpoint has existed since v6.x; only the +SDK surface was missing. + +### Added + +- **`AxonFlow#createHITLRequest(HITLCreateInput) -> HITLApprovalRequest`** + + `AxonFlow#createHITLRequestAsync(HITLCreateInput) -> CompletableFuture`. + Required fields: `clientId`, `originalQuery`, `requestType`. + Optional fields cover policy attribution, severity, compliance + framework, an expiry override, and the new `notifyUrl` callback. + Server-side `X-Org-ID` / `X-Tenant-ID` headers are derived by the + platform's auth middleware from the SDK client's configured + credentials — callers do not pass them through this method. +- **`HITLCreateInput` POJO + Builder** in + `com.getaxonflow.sdk.types.hitl.HITLTypes` mirroring + `platform/agent/hitl/handler.go:86 CreateRequestInput`. +- **`notifyUrl` field on `HITLCreateInput` and `HITLApprovalRequest`.** + Opt-in webhook URL fired after the request reaches a terminal state + (approved / rejected / expired / overridden). Pairs with the + HMAC-SHA256 `X-AxonFlow-Signature` header on the receiver side. + Scheme allowlist (`https://`, plus `http://` for self-hosted + local-dev) is enforced server-side; bad schemes surface as an + exception from the SDK carrying the platform's `HTTP 400`. Companion + platform work in getaxonflow/axonflow-enterprise#2419. +- 8 JUnit cases covering: full-fields create, minimal required-fields + create, bad-`notifyUrl`-scheme 400 propagation, 401 propagation, + connect failure propagation (via WireMock `CONNECTION_RESET_BY_PEER` + fault), and the three required-field validation guards + (`client_id` / `original_query` / `request_type`). + +### Compatibility + +No breaking changes. New types are additive in +`com.getaxonflow.sdk.types.hitl.HITLTypes`. The existing +`getHITLRequest` / `approveHITLRequest` / `rejectHITLRequest` / +`listHITLQueue` / `getHITLStats` methods are unchanged. `notifyUrl` +on the response POJO is optional and absent in payloads from platforms +that don't yet implement the field; older code parses the new shape +cleanly via `@JsonIgnoreProperties(ignoreUnknown = true)`. + +Cross-SDK parity sweep: getaxonflow/axonflow-enterprise#2421. + ## [8.1.0] - 2026-05-22 — `X-Client-ID` header on every outbound request + `org_id` in telemetry heartbeat + retry-config doc honesty Companion release to the v9 identity cleanup on the platform. Every diff --git a/pom.xml b/pom.xml index ea09fee..db692af 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.getaxonflow axonflow-sdk - 8.1.0 + 8.2.0 jar AxonFlow Java SDK diff --git a/runtime-e2e/create_hitl_request/CreateHITLRequestTest.java b/runtime-e2e/create_hitl_request/CreateHITLRequestTest.java new file mode 100644 index 0000000..7d21e52 --- /dev/null +++ b/runtime-e2e/create_hitl_request/CreateHITLRequestTest.java @@ -0,0 +1,182 @@ +/* + * runtime-e2e/create_hitl_request/CreateHITLRequestTest.java + * + * Real-wire test of the SDK's createHITLRequest method + * (getaxonflow/axonflow-enterprise#2421). Spins up a tiny in-process + * HttpServer that mimics the platform handler at + * platform/agent/hitl/handler.go:177, drives axonflow.createHITLRequest + * against it through the real OkHttp transport, then asserts the + * captured request body carries every required field plus the new + * notify_url surface added in + * getaxonflow/axonflow-enterprise#2419. + * + * No WireMock, no JUnit, no test doubles — real HttpServer + + * OkHttpClient on both sides. Satisfies the runtime-e2e/ DoD gate + * that the WireMock-based HITLTest unit suite under src/test/java/ + * does not. + * + * Run: + * ./mvnw -q package -DskipTests + * java -cp "target/axonflow-sdk-8.2.0.jar:$(./mvnw -q dependency:build-classpath -Dmdep.outputFile=/dev/stderr 2>&1 | tail -1)" \ + * runtime-e2e/create_hitl_request/CreateHITLRequestTest.java + * + * Sister coverage runs in CI via HITLTest's WireMock-driven 8-case + * createHITLRequest scenario. This proof additionally exercises the + * real JDK HttpServer rather than WireMock's Jetty harness. + */ +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getaxonflow.sdk.AxonFlow; +import com.getaxonflow.sdk.AxonFlowConfig; +import com.getaxonflow.sdk.types.hitl.HITLTypes.HITLApprovalRequest; +import com.getaxonflow.sdk.types.hitl.HITLTypes.HITLCreateInput; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; + +public class CreateHITLRequestTest { + + private static final String NOTIFY_URL = "https://workflows.example.com/hooks/runtime-e2e"; + + public static void main(String[] args) throws Exception { + AtomicReference captured = new AtomicReference<>(""); + + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/api/v1/hitl/queue", + exchange -> { + try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + exchange.getRequestBody().transferTo(bos); + captured.set(bos.toString(StandardCharsets.UTF_8)); + } + if (!"POST".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + exchange.close(); + return; + } + ObjectMapper mapper = new ObjectMapper(); + JsonNode in = mapper.readTree(captured.get()); + String resp = + "{\"success\":true,\"data\":{" + + "\"request_id\":\"hitl-req-runtime-e2e-001\"," + + "\"org_id\":\"org-runtime-e2e\"," + + "\"tenant_id\":\"tenant-runtime-e2e\"," + + "\"client_id\":" + + mapper.writeValueAsString(in.path("client_id").asText()) + + "," + + "\"user_id\":" + + mapper.writeValueAsString(in.path("user_id").asText()) + + "," + + "\"original_query\":" + + mapper.writeValueAsString(in.path("original_query").asText()) + + "," + + "\"request_type\":" + + mapper.writeValueAsString(in.path("request_type").asText()) + + "," + + "\"triggered_policy_id\":" + + mapper.writeValueAsString(in.path("triggered_policy_id").asText()) + + "," + + "\"triggered_policy_name\":" + + mapper.writeValueAsString(in.path("triggered_policy_name").asText()) + + "," + + "\"trigger_reason\":" + + mapper.writeValueAsString(in.path("trigger_reason").asText()) + + "," + + "\"severity\":" + + mapper.writeValueAsString(in.path("severity").asText()) + + "," + + "\"notify_url\":" + + mapper.writeValueAsString(in.path("notify_url").asText()) + + "," + + "\"status\":\"pending\"," + + "\"expires_at\":\"2026-05-23T11:00:00Z\"," + + "\"created_at\":\"2026-05-23T10:00:00Z\"," + + "\"updated_at\":\"2026-05-23T10:00:00Z\"" + + "}}"; + byte[] bytes = resp.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(201, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + int port = server.getAddress().getPort(); + String endpoint = "http://127.0.0.1:" + port; + + int exitCode = 0; + try { + AxonFlow axonflow = + AxonFlow.create(AxonFlowConfig.builder().endpoint(endpoint).build()); + + HITLCreateInput input = + HITLCreateInput.builder() + .clientId("runtime-e2e-client") + .userId("runtime-e2e-user") + .originalQuery("disburse $50000 to cust-runtime-e2e") + .requestType("adk-tool") + .triggeredPolicyId("loan-amount-cap") + .triggeredPolicyName("Loan amount cap") + .triggerReason("Disbursement above $10k requires manager approval") + .severity("high") + .notifyUrl(NOTIFY_URL) + .build(); + + HITLApprovalRequest req = axonflow.createHITLRequest(input); + + String body = captured.get(); + if (body == null || body.isEmpty()) { + System.err.println("FAIL: server captured no body"); + exitCode = 1; + } else { + ObjectMapper mapper = new ObjectMapper(); + JsonNode wire = mapper.readTree(body); + String[][] expected = { + {"client_id", "runtime-e2e-client"}, + {"user_id", "runtime-e2e-user"}, + {"original_query", "disburse $50000 to cust-runtime-e2e"}, + {"request_type", "adk-tool"}, + {"triggered_policy_id", "loan-amount-cap"}, + {"triggered_policy_name", "Loan amount cap"}, + {"trigger_reason", "Disbursement above $10k requires manager approval"}, + {"severity", "high"}, + {"notify_url", NOTIFY_URL}, + }; + for (String[] kv : expected) { + String got = wire.path(kv[0]).asText(); + if (!kv[1].equals(got)) { + System.err.printf( + "FAIL: wire body field %s = %s, want %s%nFull body: %s%n", + kv[0], got, kv[1], body); + exitCode = 1; + } + } + if (!"hitl-req-runtime-e2e-001".equals(req.getRequestId())) { + System.err.printf("FAIL: parsed request_id = %s%n", req.getRequestId()); + exitCode = 1; + } + if (!NOTIFY_URL.equals(req.getNotifyUrl())) { + System.err.printf( + "FAIL: parsed notify_url = %s, want %s%n", req.getNotifyUrl(), NOTIFY_URL); + exitCode = 1; + } + } + + if (exitCode == 0) { + System.out.println( + "PASS: createHITLRequest wire payload + response parsing round-trip OK"); + System.out.println("Wire body: " + body); + System.out.printf( + "Parsed requestId=%s notifyUrl=%s%n", req.getRequestId(), req.getNotifyUrl()); + } + } catch (Exception e) { + System.err.println("FAIL: unexpected exception: " + e); + e.printStackTrace(); + exitCode = 1; + } finally { + server.stop(0); + } + System.exit(exitCode); + } +} diff --git a/runtime-e2e/create_hitl_request/README.md b/runtime-e2e/create_hitl_request/README.md new file mode 100644 index 0000000..07b8f18 --- /dev/null +++ b/runtime-e2e/create_hitl_request/README.md @@ -0,0 +1,44 @@ +# `createHITLRequest` — runtime-e2e + +Real-stack assertion for the cross-SDK +[`createHITLRequest`](https://github.com/getaxonflow/axonflow-enterprise/issues/2421) +surface added in Java SDK v8.2.0. Sister proof to the equivalent Python +/ TypeScript / Go runtime-e2e tests shipping in the same parity sweep. + +## What this proves + +Drives `axonflow.createHITLRequest(...)` through the real `OkHttp` +transport against a JDK `com.sun.net.httpserver.HttpServer` listener +that mimics the platform handler at +`platform/agent/hitl/handler.go:177`. Captures the raw HTTP body, +decodes it, and asserts every required field from +`com.getaxonflow.sdk.types.hitl.HITLTypes.HITLCreateInput` lands on the +wire — including the new `notify_url` field added in +[#2419](https://github.com/getaxonflow/axonflow-enterprise/issues/2419) +— then asserts the SDK parses the platform's `APIResponse{success, +data}` envelope back into a populated `HITLApprovalRequest`. + +No WireMock, no JUnit `@Test`, no test doubles — runs the production +OkHttp transport against an in-process JDK `HttpServer`, which is what +the `runtime-e2e/` DoD gate is asking for. + +## Usage + +```bash +./mvnw -q package -DskipTests +java -cp "target/axonflow-sdk-8.2.0.jar:$(./mvnw -q dependency:build-classpath -Dmdep.outputFile=/dev/stderr 2>&1 | tail -1)" \ + runtime-e2e/create_hitl_request/CreateHITLRequestTest.java +``` + +Exits `0` on PASS, `1` on FAIL. Prints captured wire body + parsed +response fields on success for human-readable confirmation. + +## Companion unit coverage + +`src/test/java/com/getaxonflow/sdk/HITLTest.java` +`@Nested CreateHITLRequest` exercises the same surface through +WireMock for eight scenarios (happy path full-fields, minimal +required-fields, bad-`notifyUrl`-scheme 400 propagation, 401 +propagation, connect-failure propagation, and the three +`IllegalArgumentException` guards for missing required fields). The +runtime proof here is the redundant real-stack confirmation. diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index ae367f8..53770ea 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -6416,6 +6416,67 @@ public CompletableFuture getHITLRequestAsync(String request return CompletableFuture.supplyAsync(() -> getHITLRequest(requestId), asyncExecutor); } + /** + * Creates a new HITL approval request via {@code POST /api/v1/hitl/queue}. + * + *

Enterprise Feature: Requires AxonFlow Enterprise license. The platform returns 403 + * with {@code ErrHITLApprovalDisabledByTier} when called against a community tier that hasn't + * enabled HITL, and 401 when credentials are invalid. + * + *

This is the explicit row-creation step for callers that detect {@code require_approval} + * from a separate gate ({@code pre_check}, {@code check_tool_input}, MAP plan approvals) and + * want the row enqueued so a reviewer can act on it. After creating, either poll + * {@link #getHITLRequest(String)} until terminal state, or supply + * {@link HITLCreateInput#setNotifyUrl(String) notifyUrl} so the platform fires a signed webhook + * on the transition (n8n Wait-node "On Webhook Call" pattern, ADK plugin polling-free mode). + * + *

{@code clientId}, {@code originalQuery}, and {@code requestType} are required; all other + * fields are optional. Bad {@code notifyUrl} schemes are rejected by the platform with HTTP + * 400 (surfaced here via {@link AxonFlowException}); only {@code https://} (and {@code http://} + * for self-hosted local-dev) are accepted. + * + * @param input the create-request input + * @return the created approval request with {@code requestId} populated + * @throws AxonFlowException if validation or the platform call fails + */ + public HITLApprovalRequest createHITLRequest(HITLCreateInput input) { + Objects.requireNonNull(input, "input cannot be null"); + if (input.getClientId() == null || input.getClientId().isEmpty()) { + throw new IllegalArgumentException("client_id is required"); + } + if (input.getOriginalQuery() == null || input.getOriginalQuery().isEmpty()) { + throw new IllegalArgumentException("original_query is required"); + } + if (input.getRequestType() == null || input.getRequestType().isEmpty()) { + throw new IllegalArgumentException("request_type is required"); + } + + return retryExecutor.execute( + () -> { + Request httpRequest = buildOrchestratorRequest("POST", "/api/v1/hitl/queue", input); + try (Response response = executeHttp(httpClient, httpRequest)) { + JsonNode node = parseResponseNode(response); + + // Server wraps response: {"success": true, "data": {...}} + if (node.has("data") && node.get("data").isObject()) { + return objectMapper.treeToValue(node.get("data"), HITLApprovalRequest.class); + } + return objectMapper.treeToValue(node, HITLApprovalRequest.class); + } + }, + "createHITLRequest"); + } + + /** + * Asynchronously creates a new HITL approval request. + * + * @param input the create-request input + * @return a future containing the created approval request + */ + public CompletableFuture createHITLRequestAsync(HITLCreateInput input) { + return CompletableFuture.supplyAsync(() -> createHITLRequest(input), asyncExecutor); + } + /** * Approves a HITL approval request. * diff --git a/src/main/java/com/getaxonflow/sdk/types/hitl/HITLTypes.java b/src/main/java/com/getaxonflow/sdk/types/hitl/HITLTypes.java index d1b1233..bb3fd28 100644 --- a/src/main/java/com/getaxonflow/sdk/types/hitl/HITLTypes.java +++ b/src/main/java/com/getaxonflow/sdk/types/hitl/HITLTypes.java @@ -113,6 +113,20 @@ public static class HITLApprovalRequest { @JsonProperty("reviewed_at") private String reviewedAt; + /** + * Optional outbound webhook URL associated with the request. + * + *

Mirrors the value supplied on creation. Platforms that + * implement the outbound-webhook dispatcher (introduced in + * getaxonflow/axonflow-enterprise#2419) fire a signed POST to this + * URL after the request reaches a terminal state + * (approved/rejected/expired/overridden). Platforms that don't, + * simply round-trip the field. Enables webhook-driven resume + * (n8n Wait-node, ADK plugin polling-free mode). + */ + @JsonProperty("notify_url") + private String notifyUrl; + @JsonProperty("expires_at") private String expiresAt; @@ -285,6 +299,14 @@ public void setReviewedAt(String reviewedAt) { this.reviewedAt = reviewedAt; } + public String getNotifyUrl() { + return notifyUrl; + } + + public void setNotifyUrl(String notifyUrl) { + this.notifyUrl = notifyUrl; + } + public String getExpiresAt() { return expiresAt; } @@ -439,6 +461,274 @@ public void setHasMore(boolean hasMore) { } } + // ======================================================================== + // Create Input + // ======================================================================== + + /** + * Input for creating a HITL approval request. + * + *

Mirrors {@code platform/agent/hitl/handler.go:86 CreateRequestInput}. The platform's + * {@code POST /api/v1/hitl/queue} handler reads {@code X-Org-ID} and {@code X-Tenant-ID} from + * request headers (set by the auth middleware from the SDK client's credentials), and the JSON + * body must carry the fields below. + * + *

Used by agent-framework callers that detect {@code require_approval} from + * {@code pre_check} / {@code check_tool_input} and want to enqueue the corresponding HITL row + * before polling the reviewer's decision (or pivoting to webhook-driven resume via + * {@code notifyUrl}). + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class HITLCreateInput { + + @JsonProperty("client_id") + private String clientId; + + @JsonProperty("user_id") + private String userId; + + @JsonProperty("original_query") + private String originalQuery; + + @JsonProperty("request_type") + private String requestType; + + @JsonProperty("request_context") + private Map requestContext; + + @JsonProperty("triggered_policy_id") + private String triggeredPolicyId; + + @JsonProperty("triggered_policy_name") + private String triggeredPolicyName; + + @JsonProperty("trigger_reason") + private String triggerReason; + + @JsonProperty("severity") + private String severity; + + /** + * Optional outbound webhook URL fired async after terminal state transition. Must be + * {@code https://} (or {@code http://} for self-hosted local-dev). Server-side validation + * rejects bad schemes with HTTP 400. Pair with the HMAC-SHA256 {@code X-AxonFlow-Signature} + * header on the receiver side; signing key is the deployment-configured + * {@code AXONFLOW_HITL_WEBHOOK_SIGNING_KEY}. Introduced in + * getaxonflow/axonflow-enterprise#2419. + */ + @JsonProperty("notify_url") + private String notifyUrl; + + @JsonProperty("eu_ai_act_article") + private String euAiActArticle; + + @JsonProperty("compliance_framework") + private String complianceFramework; + + @JsonProperty("risk_classification") + private String riskClassification; + + @JsonProperty("expires_in_seconds") + private Integer expiresInSeconds; + + public HITLCreateInput() {} + + public static Builder builder() { + return new Builder(); + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getOriginalQuery() { + return originalQuery; + } + + public void setOriginalQuery(String originalQuery) { + this.originalQuery = originalQuery; + } + + public String getRequestType() { + return requestType; + } + + public void setRequestType(String requestType) { + this.requestType = requestType; + } + + public Map getRequestContext() { + return requestContext; + } + + public void setRequestContext(Map requestContext) { + this.requestContext = requestContext; + } + + public String getTriggeredPolicyId() { + return triggeredPolicyId; + } + + public void setTriggeredPolicyId(String triggeredPolicyId) { + this.triggeredPolicyId = triggeredPolicyId; + } + + public String getTriggeredPolicyName() { + return triggeredPolicyName; + } + + public void setTriggeredPolicyName(String triggeredPolicyName) { + this.triggeredPolicyName = triggeredPolicyName; + } + + public String getTriggerReason() { + return triggerReason; + } + + public void setTriggerReason(String triggerReason) { + this.triggerReason = triggerReason; + } + + public String getSeverity() { + return severity; + } + + public void setSeverity(String severity) { + this.severity = severity; + } + + public String getNotifyUrl() { + return notifyUrl; + } + + public void setNotifyUrl(String notifyUrl) { + this.notifyUrl = notifyUrl; + } + + public String getEuAiActArticle() { + return euAiActArticle; + } + + public void setEuAiActArticle(String euAiActArticle) { + this.euAiActArticle = euAiActArticle; + } + + public String getComplianceFramework() { + return complianceFramework; + } + + public void setComplianceFramework(String complianceFramework) { + this.complianceFramework = complianceFramework; + } + + public String getRiskClassification() { + return riskClassification; + } + + public void setRiskClassification(String riskClassification) { + this.riskClassification = riskClassification; + } + + public Integer getExpiresInSeconds() { + return expiresInSeconds; + } + + public void setExpiresInSeconds(Integer expiresInSeconds) { + this.expiresInSeconds = expiresInSeconds; + } + + /** Builder for {@link HITLCreateInput}. */ + public static class Builder { + private final HITLCreateInput input = new HITLCreateInput(); + + public Builder clientId(String clientId) { + input.clientId = clientId; + return this; + } + + public Builder userId(String userId) { + input.userId = userId; + return this; + } + + public Builder originalQuery(String originalQuery) { + input.originalQuery = originalQuery; + return this; + } + + public Builder requestType(String requestType) { + input.requestType = requestType; + return this; + } + + public Builder requestContext(Map requestContext) { + input.requestContext = requestContext; + return this; + } + + public Builder triggeredPolicyId(String triggeredPolicyId) { + input.triggeredPolicyId = triggeredPolicyId; + return this; + } + + public Builder triggeredPolicyName(String triggeredPolicyName) { + input.triggeredPolicyName = triggeredPolicyName; + return this; + } + + public Builder triggerReason(String triggerReason) { + input.triggerReason = triggerReason; + return this; + } + + public Builder severity(String severity) { + input.severity = severity; + return this; + } + + public Builder notifyUrl(String notifyUrl) { + input.notifyUrl = notifyUrl; + return this; + } + + public Builder euAiActArticle(String euAiActArticle) { + input.euAiActArticle = euAiActArticle; + return this; + } + + public Builder complianceFramework(String complianceFramework) { + input.complianceFramework = complianceFramework; + return this; + } + + public Builder riskClassification(String riskClassification) { + input.riskClassification = riskClassification; + return this; + } + + public Builder expiresInSeconds(Integer expiresInSeconds) { + input.expiresInSeconds = expiresInSeconds; + return this; + } + + public HITLCreateInput build() { + return input; + } + } + } + // ======================================================================== // Review Input // ======================================================================== diff --git a/src/test/java/com/getaxonflow/sdk/HITLTest.java b/src/test/java/com/getaxonflow/sdk/HITLTest.java index 5cfcda9..d98e20b 100644 --- a/src/test/java/com/getaxonflow/sdk/HITLTest.java +++ b/src/test/java/com/getaxonflow/sdk/HITLTest.java @@ -18,6 +18,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.*; import static org.assertj.core.api.Assertions.*; +import com.getaxonflow.sdk.exceptions.AxonFlowException; import com.getaxonflow.sdk.types.hitl.HITLTypes.*; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; @@ -77,11 +78,7 @@ class HITLTest { @BeforeEach void setUp(WireMockRuntimeInfo wmRuntimeInfo) { axonflow = - AxonFlow.create( - AxonFlowConfig.builder() - .endpoint(wmRuntimeInfo.getHttpBaseUrl()) - .endpoint(wmRuntimeInfo.getHttpBaseUrl()) - .build()); + AxonFlow.create(AxonFlowConfig.builder().endpoint(wmRuntimeInfo.getHttpBaseUrl()).build()); } // ======================================================================== @@ -200,6 +197,219 @@ void shouldHandleHasMore() { } } + // ======================================================================== + // createHITLRequest Tests + // ======================================================================== + + @Nested + @DisplayName("createHITLRequest") + class CreateHITLRequest { + + private static final String CREATED_APPROVAL_REQUEST = + "{" + + "\"request_id\": \"hitl-req-new-001\"," + + "\"org_id\": \"org-1\"," + + "\"tenant_id\": \"tenant-1\"," + + "\"client_id\": \"loan-desk\"," + + "\"user_id\": \"cust-001\"," + + "\"original_query\": \"disburse $50000 to cust-001\"," + + "\"request_type\": \"adk-tool\"," + + "\"request_context\": {\"tool_name\": \"disburse_payment\"}," + + "\"triggered_policy_id\": \"loan-amount-cap\"," + + "\"triggered_policy_name\": \"Loan amount cap\"," + + "\"trigger_reason\": \"Disbursement above $10k requires manager approval\"," + + "\"severity\": \"high\"," + + "\"notify_url\": \"https://workflows.example.com/hooks/loan-approve\"," + + "\"status\": \"pending\"," + + "\"expires_at\": \"2026-05-23T11:00:00Z\"," + + "\"created_at\": \"2026-05-23T10:00:00Z\"," + + "\"updated_at\": \"2026-05-23T10:00:00Z\"" + + "}"; + + @Test + @DisplayName("should POST a full create-input and return the created record") + void shouldPostFullCreateInputAndReturnCreatedRecord() { + stubFor( + post(urlEqualTo("/api/v1/hitl/queue")) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody( + "{\"success\": true, \"data\": " + CREATED_APPROVAL_REQUEST + "}"))); + + HITLCreateInput input = + HITLCreateInput.builder() + .clientId("loan-desk") + .userId("cust-001") + .originalQuery("disburse $50000 to cust-001") + .requestType("adk-tool") + .triggeredPolicyId("loan-amount-cap") + .triggeredPolicyName("Loan amount cap") + .triggerReason("Disbursement above $10k requires manager approval") + .severity("high") + .notifyUrl("https://workflows.example.com/hooks/loan-approve") + .build(); + + HITLApprovalRequest result = axonflow.createHITLRequest(input); + + assertThat(result.getRequestId()).isEqualTo("hitl-req-new-001"); + assertThat(result.getStatus()).isEqualTo("pending"); + assertThat(result.getNotifyUrl()) + .isEqualTo("https://workflows.example.com/hooks/loan-approve"); + assertThat(result.getTriggeredPolicyName()).isEqualTo("Loan amount cap"); + + verify( + postRequestedFor(urlEqualTo("/api/v1/hitl/queue")) + .withRequestBody(matchingJsonPath("$.client_id", equalTo("loan-desk"))) + .withRequestBody( + matchingJsonPath( + "$.notify_url", + equalTo("https://workflows.example.com/hooks/loan-approve"))) + .withRequestBody(matchingJsonPath("$.severity", equalTo("high")))); + } + + @Test + @DisplayName("should accept minimal required-field set (clientId + originalQuery + requestType)") + void shouldAcceptMinimalRequiredFields() { + stubFor( + post(urlEqualTo("/api/v1/hitl/queue")) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody( + "{\"success\": true, \"data\": {" + + "\"request_id\": \"hitl-req-minimal\"," + + "\"org_id\": \"org-1\"," + + "\"tenant_id\": \"tenant-1\"," + + "\"client_id\": \"c1\"," + + "\"original_query\": \"q\"," + + "\"request_type\": \"chat\"," + + "\"triggered_policy_id\": \"\"," + + "\"triggered_policy_name\": \"\"," + + "\"trigger_reason\": \"\"," + + "\"severity\": \"high\"," + + "\"status\": \"pending\"," + + "\"expires_at\": \"2026-05-23T11:00:00Z\"," + + "\"created_at\": \"2026-05-23T10:00:00Z\"," + + "\"updated_at\": \"2026-05-23T10:00:00Z\"" + + "}}"))); + + HITLCreateInput input = + HITLCreateInput.builder() + .clientId("c1") + .originalQuery("q") + .requestType("chat") + .build(); + + HITLApprovalRequest result = axonflow.createHITLRequest(input); + assertThat(result.getRequestId()).isEqualTo("hitl-req-minimal"); + assertThat(result.getNotifyUrl()).isNull(); + } + + @Test + @DisplayName( + "should surface a platform 400 on bad notify_url scheme as an exception from the SDK") + void shouldSurfaceBadNotifyUrlSchemeAsException() { + // Mirrors platform/agent/hitl/webhook.go:105 ValidateNotifyURL. + stubFor( + post(urlEqualTo("/api/v1/hitl/queue")) + .willReturn( + aResponse() + .withStatus(400) + .withHeader("Content-Type", "application/json") + .withBody( + "{\"success\":false,\"error\":\"notify_url scheme \\\"javascript\\\"" + + " is not allowed (use https:// or http://)\"}"))); + + HITLCreateInput input = + HITLCreateInput.builder() + .clientId("loan-desk") + .originalQuery("disburse $50000") + .requestType("adk-tool") + .notifyUrl("javascript:alert(1)") + .build(); + + assertThatThrownBy(() -> axonflow.createHITLRequest(input)) + .isInstanceOf(AxonFlowException.class); + } + + @Test + @DisplayName("should propagate 401 as an exception") + void shouldPropagateAuthFailure() { + stubFor( + post(urlEqualTo("/api/v1/hitl/queue")) + .willReturn( + aResponse() + .withStatus(401) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":false,\"error\":\"Invalid API key\"}"))); + + HITLCreateInput input = + HITLCreateInput.builder() + .clientId("loan-desk") + .originalQuery("disburse $50000") + .requestType("adk-tool") + .build(); + + assertThatThrownBy(() -> axonflow.createHITLRequest(input)) + .isInstanceOf(AxonFlowException.class); + } + + @Test + @DisplayName("should propagate network/connect failure as an exception") + void shouldPropagateNetworkFailure() { + // Stop the WireMock-driven endpoint by stubbing a fault that closes + // the connection before reply. Mirrors a real ECONNRESET on the + // wire. + stubFor( + post(urlEqualTo("/api/v1/hitl/queue")) + .willReturn( + aResponse().withFault(com.github.tomakehurst.wiremock.http.Fault.CONNECTION_RESET_BY_PEER))); + + HITLCreateInput input = + HITLCreateInput.builder() + .clientId("loan-desk") + .originalQuery("disburse $50000") + .requestType("adk-tool") + .build(); + + assertThatThrownBy(() -> axonflow.createHITLRequest(input)) + .isInstanceOf(AxonFlowException.class); + } + + @Test + @DisplayName("should reject missing client_id") + void shouldRejectMissingClientId() { + HITLCreateInput input = + HITLCreateInput.builder().originalQuery("q").requestType("chat").build(); + assertThatThrownBy(() -> axonflow.createHITLRequest(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("client_id"); + } + + @Test + @DisplayName("should reject missing original_query") + void shouldRejectMissingOriginalQuery() { + HITLCreateInput input = + HITLCreateInput.builder().clientId("c1").requestType("chat").build(); + assertThatThrownBy(() -> axonflow.createHITLRequest(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("original_query"); + } + + @Test + @DisplayName("should reject missing request_type") + void shouldRejectMissingRequestType() { + HITLCreateInput input = + HITLCreateInput.builder().clientId("c1").originalQuery("q").build(); + assertThatThrownBy(() -> axonflow.createHITLRequest(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("request_type"); + } + } + // ======================================================================== // getHITLRequest Tests // ========================================================================