diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f03379..5706f90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [4.3.0] - 2026-03-24 +### Added + +- `simulatePolicies()` / `simulatePoliciesAsync()` — dry-run all active policies against an input query. Returns allowed/blocked status, applied policies, risk score, and daily usage. Requires Evaluation tier or above. +- `getPolicyImpactReport()` / `getPolicyImpactReportAsync()` — test a single policy against multiple inputs and get aggregate match/block statistics. +- `detectPolicyConflicts()` / `detectPolicyConflictsAsync()` — analyze active policies for contradictions, shadows, and redundancies. Optionally filter to conflicts involving a specific policy. +- Types in `com.getaxonflow.sdk.simulation` package: `SimulatePoliciesRequest`, `SimulatePoliciesResponse`, `SimulationDailyUsage`, `ImpactReportInput`, `ImpactReportRequest`, `ImpactReportResult`, `ImpactReportResponse`, `PolicyConflictRef`, `PolicyConflict`, `PolicyConflictResponse` + ### Security - Hardened insecure TLS trust manager (`HttpClientFactory`) to suppress CodeQL `java/insecure-trustmanager` alert. The trust-all `X509TrustManager` is only activated when the user explicitly opts in via `insecureSkipVerify=true` in `AxonFlowConfig`. Added `lgtm` suppression comments, clarified intent in code comments, and enhanced the warning log message to explicitly discourage production use. diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 23d6ca7..6644f55 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -25,6 +25,7 @@ import com.getaxonflow.sdk.types.policies.PolicyTypes.*; import com.getaxonflow.sdk.masfeat.MASFEATTypes.*; import com.getaxonflow.sdk.types.webhook.WebhookTypes.*; +import com.getaxonflow.sdk.simulation.*; import com.getaxonflow.sdk.util.*; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; @@ -821,6 +822,193 @@ public CompletableFuture updateCircuitBreake return CompletableFuture.supplyAsync(() -> updateCircuitBreakerConfig(config), asyncExecutor); } + // ======================================================================== + // Policy Simulation + // ======================================================================== + + /** + * Simulates policy evaluation against a query without actually enforcing policies. + * + *

This is a dry-run mode that shows which policies would match and what actions + * would be taken, without blocking the request. + * + *

Example usage: + *

{@code
+     * SimulatePoliciesResponse result = axonflow.simulatePolicies(
+     *     SimulatePoliciesRequest.builder()
+     *         .query("Transfer $50,000 to external account")
+     *         .requestType("execute")
+     *         .build());
+     * System.out.println("Allowed: " + result.isAllowed());
+     * System.out.println("Applied policies: " + result.getAppliedPolicies());
+     * System.out.println("Risk score: " + result.getRiskScore());
+     * }
+ * + *

Evaluation+ Feature: Requires AxonFlow Evaluation tier or higher. + * + * @param request the simulation request + * @return the simulation result + * @throws NullPointerException if request is null + * @throws AxonFlowException if the request fails + */ + public SimulatePoliciesResponse simulatePolicies(SimulatePoliciesRequest request) { + Objects.requireNonNull(request, "request cannot be null"); + + return retryExecutor.execute(() -> { + Request httpRequest = buildOrchestratorRequest("POST", "/api/v1/policies/simulate", request); + try (Response response = httpClient.newCall(httpRequest).execute()) { + JsonNode node = parseResponseNode(response); + if (node.has("data") && node.get("data").isObject()) { + return objectMapper.treeToValue(node.get("data"), SimulatePoliciesResponse.class); + } + return objectMapper.treeToValue(node, SimulatePoliciesResponse.class); + } + }, "simulatePolicies"); + } + + /** + * Asynchronously simulates policy evaluation against a query. + * + * @param request the simulation request + * @return a future containing the simulation result + */ + public CompletableFuture simulatePoliciesAsync(SimulatePoliciesRequest request) { + return CompletableFuture.supplyAsync(() -> simulatePolicies(request), asyncExecutor); + } + + /** + * Generates a policy impact report by testing a set of inputs against a specific policy. + * + *

This helps you understand how a policy would affect real traffic before deploying it. + * + *

Example usage: + *

{@code
+     * ImpactReportResponse report = axonflow.getPolicyImpactReport(
+     *     ImpactReportRequest.builder()
+     *         .policyId("policy_block_pii")
+     *         .inputs(List.of(
+     *             ImpactReportInput.builder().query("My SSN is 123-45-6789").build(),
+     *             ImpactReportInput.builder().query("What is the weather?").build()))
+     *         .build());
+     * System.out.println("Match rate: " + report.getMatchRate());
+     * System.out.println("Block rate: " + report.getBlockRate());
+     * }
+ * + *

Evaluation+ Feature: Requires AxonFlow Evaluation tier or higher. + * + * @param request the impact report request + * @return the impact report + * @throws NullPointerException if request is null + * @throws AxonFlowException if the request fails + */ + public ImpactReportResponse getPolicyImpactReport(ImpactReportRequest request) { + Objects.requireNonNull(request, "request cannot be null"); + + return retryExecutor.execute(() -> { + Request httpRequest = buildOrchestratorRequest("POST", "/api/v1/policies/impact-report", request); + try (Response response = httpClient.newCall(httpRequest).execute()) { + JsonNode node = parseResponseNode(response); + if (node.has("data") && node.get("data").isObject()) { + return objectMapper.treeToValue(node.get("data"), ImpactReportResponse.class); + } + return objectMapper.treeToValue(node, ImpactReportResponse.class); + } + }, "getPolicyImpactReport"); + } + + /** + * Asynchronously generates a policy impact report. + * + * @param request the impact report request + * @return a future containing the impact report + */ + public CompletableFuture getPolicyImpactReportAsync(ImpactReportRequest request) { + return CompletableFuture.supplyAsync(() -> getPolicyImpactReport(request), asyncExecutor); + } + + /** + * Scans all active policies for conflicts. + * + *

Example usage: + *

{@code
+     * PolicyConflictResponse conflicts = axonflow.detectPolicyConflicts();
+     * System.out.println("Conflicts found: " + conflicts.getConflictCount());
+     * for (PolicyConflict conflict : conflicts.getConflicts()) {
+     *     System.out.println(conflict.getConflictType() + ": " + conflict.getDescription());
+     * }
+     * }
+ * + *

Evaluation+ Feature: Requires AxonFlow Evaluation tier or higher. + * + * @return the conflict detection result + * @throws AxonFlowException if the request fails + */ + public PolicyConflictResponse detectPolicyConflicts() { + return detectPolicyConflicts(null); + } + + /** + * Detects conflicts between a specific policy and other active policies, + * or scans all policies if policyId is null. + * + *

Example usage: + *

{@code
+     * PolicyConflictResponse conflicts = axonflow.detectPolicyConflicts("policy_block_pii");
+     * System.out.println("Conflicts found: " + conflicts.getConflictCount());
+     * for (PolicyConflict conflict : conflicts.getConflicts()) {
+     *     System.out.println(conflict.getConflictType() + ": " + conflict.getDescription());
+     * }
+     * }
+ * + *

Evaluation+ Feature: Requires AxonFlow Evaluation tier or higher. + * + * @param policyId the policy ID to check for conflicts, or null to scan all policies + * @return the conflict detection result + * @throws IllegalArgumentException if policyId is non-null and empty + * @throws AxonFlowException if the request fails + */ + public PolicyConflictResponse detectPolicyConflicts(String policyId) { + if (policyId != null && policyId.isEmpty()) { + throw new IllegalArgumentException("policyId cannot be empty"); + } + + return retryExecutor.execute(() -> { + Object body; + if (policyId != null) { + body = java.util.Map.of("policy_id", policyId); + } else { + body = java.util.Map.of(); + } + Request httpRequest = buildOrchestratorRequest("POST", "/api/v1/policies/conflicts", body); + try (Response response = httpClient.newCall(httpRequest).execute()) { + JsonNode node = parseResponseNode(response); + if (node.has("data") && node.get("data").isObject()) { + return objectMapper.treeToValue(node.get("data"), PolicyConflictResponse.class); + } + return objectMapper.treeToValue(node, PolicyConflictResponse.class); + } + }, "detectPolicyConflicts"); + } + + /** + * Asynchronously scans all active policies for conflicts. + * + * @return a future containing the conflict detection result + */ + public CompletableFuture detectPolicyConflictsAsync() { + return CompletableFuture.supplyAsync(() -> detectPolicyConflicts(), asyncExecutor); + } + + /** + * Asynchronously detects conflicts between a specific policy and other active policies. + * + * @param policyId the policy ID to check for conflicts, or null to scan all policies + * @return a future containing the conflict detection result + */ + public CompletableFuture detectPolicyConflictsAsync(String policyId) { + return CompletableFuture.supplyAsync(() -> detectPolicyConflicts(policyId), asyncExecutor); + } + // ======================================================================== // Proxy Mode - Query Execution // ======================================================================== diff --git a/src/main/java/com/getaxonflow/sdk/simulation/ImpactReportInput.java b/src/main/java/com/getaxonflow/sdk/simulation/ImpactReportInput.java new file mode 100644 index 0000000..ad6ef03 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/simulation/ImpactReportInput.java @@ -0,0 +1,79 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.simulation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; +import java.util.Objects; + +/** + * A single input to test against a policy in an impact report. + * + *

Use the {@link Builder} to construct instances: + *

{@code
+ * ImpactReportInput input = ImpactReportInput.builder()
+ *     .query("Transfer funds to external account")
+ *     .requestType("execute")
+ *     .build();
+ * }
+ */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ImpactReportInput { + + @JsonProperty("query") + private final String query; + + @JsonProperty("request_type") + private final String requestType; + + @JsonProperty("context") + private final Map context; + + private ImpactReportInput(Builder builder) { + this.query = Objects.requireNonNull(builder.query, "query cannot be null"); + this.requestType = builder.requestType; + this.context = builder.context; + } + + public static Builder builder() { + return new Builder(); + } + + public String getQuery() { return query; } + public String getRequestType() { return requestType; } + public Map getContext() { return context; } + + /** + * Builder for {@link ImpactReportInput}. + */ + public static final class Builder { + private String query; + private String requestType; + private Map context; + + public Builder query(String query) { this.query = query; return this; } + public Builder requestType(String requestType) { this.requestType = requestType; return this; } + public Builder context(Map context) { this.context = context; return this; } + + public ImpactReportInput build() { + return new ImpactReportInput(this); + } + } +} diff --git a/src/main/java/com/getaxonflow/sdk/simulation/ImpactReportRequest.java b/src/main/java/com/getaxonflow/sdk/simulation/ImpactReportRequest.java new file mode 100644 index 0000000..7d2d900 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/simulation/ImpactReportRequest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.simulation; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Objects; + +/** + * Request to generate a policy impact report. + * + *

Use the {@link Builder} to construct instances: + *

{@code
+ * ImpactReportRequest request = ImpactReportRequest.builder()
+ *     .policyId("policy_block_pii")
+ *     .inputs(List.of(
+ *         ImpactReportInput.builder().query("My SSN is 123-45-6789").build(),
+ *         ImpactReportInput.builder().query("What is the weather?").build()
+ *     ))
+ *     .build();
+ * }
+ */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class ImpactReportRequest { + + @JsonProperty("policy_id") + private final String policyId; + + @JsonProperty("inputs") + private final List inputs; + + private ImpactReportRequest(Builder builder) { + this.policyId = Objects.requireNonNull(builder.policyId, "policyId cannot be null"); + if (this.policyId.isEmpty()) { + throw new IllegalArgumentException("policyId cannot be empty"); + } + this.inputs = Objects.requireNonNull(builder.inputs, "inputs cannot be null"); + if (this.inputs.isEmpty()) { + throw new IllegalArgumentException("inputs cannot be empty"); + } + } + + public static Builder builder() { + return new Builder(); + } + + public String getPolicyId() { return policyId; } + public List getInputs() { return inputs; } + + /** + * Builder for {@link ImpactReportRequest}. + */ + public static final class Builder { + private String policyId; + private List inputs; + + public Builder policyId(String policyId) { this.policyId = policyId; return this; } + public Builder inputs(List inputs) { this.inputs = inputs; return this; } + + /** + * Builds the ImpactReportRequest. + * + * @return the request + * @throws NullPointerException if policyId or inputs is null + * @throws IllegalArgumentException if policyId is empty or inputs is empty + */ + public ImpactReportRequest build() { + return new ImpactReportRequest(this); + } + } +} diff --git a/src/main/java/com/getaxonflow/sdk/simulation/ImpactReportResponse.java b/src/main/java/com/getaxonflow/sdk/simulation/ImpactReportResponse.java new file mode 100644 index 0000000..a578367 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/simulation/ImpactReportResponse.java @@ -0,0 +1,98 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.simulation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Response from the policy impact report endpoint. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ImpactReportResponse { + + @JsonProperty("policy_id") + private final String policyId; + + @JsonProperty("policy_name") + private final String policyName; + + @JsonProperty("total_inputs") + private final int totalInputs; + + @JsonProperty("matched") + private final int matched; + + @JsonProperty("blocked") + private final int blocked; + + @JsonProperty("match_rate") + private final double matchRate; + + @JsonProperty("block_rate") + private final double blockRate; + + @JsonProperty("results") + private final List results; + + @JsonProperty("processing_time_ms") + private final long processingTimeMs; + + @JsonProperty("generated_at") + private final String generatedAt; + + @JsonProperty("tier") + private final String tier; + + public ImpactReportResponse( + @JsonProperty("policy_id") String policyId, + @JsonProperty("policy_name") String policyName, + @JsonProperty("total_inputs") int totalInputs, + @JsonProperty("matched") int matched, + @JsonProperty("blocked") int blocked, + @JsonProperty("match_rate") double matchRate, + @JsonProperty("block_rate") double blockRate, + @JsonProperty("results") List results, + @JsonProperty("processing_time_ms") long processingTimeMs, + @JsonProperty("generated_at") String generatedAt, + @JsonProperty("tier") String tier) { + this.policyId = policyId; + this.policyName = policyName; + this.totalInputs = totalInputs; + this.matched = matched; + this.blocked = blocked; + this.matchRate = matchRate; + this.blockRate = blockRate; + this.results = results != null ? List.copyOf(results) : List.of(); + this.processingTimeMs = processingTimeMs; + this.generatedAt = generatedAt; + this.tier = tier; + } + + public String getPolicyId() { return policyId; } + public String getPolicyName() { return policyName; } + public int getTotalInputs() { return totalInputs; } + public int getMatched() { return matched; } + public int getBlocked() { return blocked; } + public double getMatchRate() { return matchRate; } + public double getBlockRate() { return blockRate; } + public List getResults() { return results; } + public long getProcessingTimeMs() { return processingTimeMs; } + public String getGeneratedAt() { return generatedAt; } + public String getTier() { return tier; } +} diff --git a/src/main/java/com/getaxonflow/sdk/simulation/ImpactReportResult.java b/src/main/java/com/getaxonflow/sdk/simulation/ImpactReportResult.java new file mode 100644 index 0000000..8bfb67d --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/simulation/ImpactReportResult.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.simulation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Result for a single input in an impact report. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ImpactReportResult { + + @JsonProperty("input_index") + private final int inputIndex; + + @JsonProperty("matched") + private final boolean matched; + + @JsonProperty("blocked") + private final boolean blocked; + + @JsonProperty("actions") + private final List actions; + + public ImpactReportResult( + @JsonProperty("input_index") int inputIndex, + @JsonProperty("matched") boolean matched, + @JsonProperty("blocked") boolean blocked, + @JsonProperty("actions") List actions) { + this.inputIndex = inputIndex; + this.matched = matched; + this.blocked = blocked; + this.actions = actions != null ? List.copyOf(actions) : List.of(); + } + + public int getInputIndex() { return inputIndex; } + public boolean isMatched() { return matched; } + public boolean isBlocked() { return blocked; } + public List getActions() { return actions; } +} diff --git a/src/main/java/com/getaxonflow/sdk/simulation/PolicyConflict.java b/src/main/java/com/getaxonflow/sdk/simulation/PolicyConflict.java new file mode 100644 index 0000000..4e28de5 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/simulation/PolicyConflict.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.simulation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A detected conflict between policies. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PolicyConflict { + + @JsonProperty("policy_a") + private final PolicyConflictRef policyA; + + @JsonProperty("policy_b") + private final PolicyConflictRef policyB; + + @JsonProperty("conflict_type") + private final String conflictType; + + @JsonProperty("description") + private final String description; + + @JsonProperty("severity") + private final String severity; + + @JsonProperty("overlapping_field") + private final String overlappingField; + + public PolicyConflict( + @JsonProperty("policy_a") PolicyConflictRef policyA, + @JsonProperty("policy_b") PolicyConflictRef policyB, + @JsonProperty("conflict_type") String conflictType, + @JsonProperty("description") String description, + @JsonProperty("severity") String severity, + @JsonProperty("overlapping_field") String overlappingField) { + this.policyA = policyA; + this.policyB = policyB; + this.conflictType = conflictType; + this.description = description; + this.severity = severity; + this.overlappingField = overlappingField; + } + + public PolicyConflictRef getPolicyA() { return policyA; } + public PolicyConflictRef getPolicyB() { return policyB; } + public String getConflictType() { return conflictType; } + public String getDescription() { return description; } + public String getSeverity() { return severity; } + public String getOverlappingField() { return overlappingField; } +} diff --git a/src/main/java/com/getaxonflow/sdk/simulation/PolicyConflictRef.java b/src/main/java/com/getaxonflow/sdk/simulation/PolicyConflictRef.java new file mode 100644 index 0000000..39c8179 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/simulation/PolicyConflictRef.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.simulation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reference to a policy involved in a conflict. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PolicyConflictRef { + + @JsonProperty("id") + private final String id; + + @JsonProperty("name") + private final String name; + + @JsonProperty("type") + private final String type; + + public PolicyConflictRef( + @JsonProperty("id") String id, + @JsonProperty("name") String name, + @JsonProperty("type") String type) { + this.id = id; + this.name = name; + this.type = type; + } + + public String getId() { return id; } + public String getName() { return name; } + public String getType() { return type; } +} diff --git a/src/main/java/com/getaxonflow/sdk/simulation/PolicyConflictResponse.java b/src/main/java/com/getaxonflow/sdk/simulation/PolicyConflictResponse.java new file mode 100644 index 0000000..7e700b9 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/simulation/PolicyConflictResponse.java @@ -0,0 +1,62 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.simulation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Response from the policy conflict detection endpoint. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PolicyConflictResponse { + + @JsonProperty("conflicts") + private final List conflicts; + + @JsonProperty("total_policies") + private final int totalPolicies; + + @JsonProperty("conflict_count") + private final int conflictCount; + + @JsonProperty("checked_at") + private final String checkedAt; + + @JsonProperty("tier") + private final String tier; + + public PolicyConflictResponse( + @JsonProperty("conflicts") List conflicts, + @JsonProperty("total_policies") int totalPolicies, + @JsonProperty("conflict_count") int conflictCount, + @JsonProperty("checked_at") String checkedAt, + @JsonProperty("tier") String tier) { + this.conflicts = conflicts != null ? List.copyOf(conflicts) : List.of(); + this.totalPolicies = totalPolicies; + this.conflictCount = conflictCount; + this.checkedAt = checkedAt; + this.tier = tier; + } + + public List getConflicts() { return conflicts; } + public int getTotalPolicies() { return totalPolicies; } + public int getConflictCount() { return conflictCount; } + public String getCheckedAt() { return checkedAt; } + public String getTier() { return tier; } +} diff --git a/src/main/java/com/getaxonflow/sdk/simulation/SimulatePoliciesRequest.java b/src/main/java/com/getaxonflow/sdk/simulation/SimulatePoliciesRequest.java new file mode 100644 index 0000000..8f0389f --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/simulation/SimulatePoliciesRequest.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.simulation; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; +import java.util.Objects; + +/** + * Request to simulate policy evaluation against a query. + * + *

Use the {@link Builder} to construct instances: + *

{@code
+ * SimulatePoliciesRequest request = SimulatePoliciesRequest.builder()
+ *     .query("Transfer $50,000 to external account")
+ *     .requestType("execute")
+ *     .build();
+ * }
+ */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class SimulatePoliciesRequest { + + @JsonProperty("query") + private final String query; + + @JsonProperty("request_type") + private final String requestType; + + @JsonProperty("user") + private final Map user; + + @JsonProperty("client") + private final Map client; + + @JsonProperty("context") + private final Map context; + + private SimulatePoliciesRequest(Builder builder) { + this.query = Objects.requireNonNull(builder.query, "query cannot be null"); + if (this.query.isEmpty()) { + throw new IllegalArgumentException("query cannot be empty"); + } + this.requestType = builder.requestType; + this.user = builder.user; + this.client = builder.client; + this.context = builder.context; + } + + /** + * Creates a new builder for SimulatePoliciesRequest. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + public String getQuery() { return query; } + public String getRequestType() { return requestType; } + public Map getUser() { return user; } + public Map getClient() { return client; } + public Map getContext() { return context; } + + /** + * Builder for {@link SimulatePoliciesRequest}. + */ + public static final class Builder { + private String query; + private String requestType; + private Map user; + private Map client; + private Map context; + + public Builder query(String query) { this.query = query; return this; } + public Builder requestType(String requestType) { this.requestType = requestType; return this; } + public Builder user(Map user) { this.user = user; return this; } + public Builder client(Map client) { this.client = client; return this; } + public Builder context(Map context) { this.context = context; return this; } + + /** + * Builds the SimulatePoliciesRequest. + * + * @return the request + * @throws NullPointerException if query is null + * @throws IllegalArgumentException if query is empty + */ + public SimulatePoliciesRequest build() { + return new SimulatePoliciesRequest(this); + } + } +} diff --git a/src/main/java/com/getaxonflow/sdk/simulation/SimulatePoliciesResponse.java b/src/main/java/com/getaxonflow/sdk/simulation/SimulatePoliciesResponse.java new file mode 100644 index 0000000..f529b16 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/simulation/SimulatePoliciesResponse.java @@ -0,0 +1,92 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.simulation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Response from policy simulation. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SimulatePoliciesResponse { + + @JsonProperty("allowed") + private final boolean allowed; + + @JsonProperty("applied_policies") + private final List appliedPolicies; + + @JsonProperty("risk_score") + private final double riskScore; + + @JsonProperty("required_actions") + private final List requiredActions; + + @JsonProperty("processing_time_ms") + private final long processingTimeMs; + + @JsonProperty("total_policies") + private final int totalPolicies; + + @JsonProperty("dry_run") + private final boolean dryRun; + + @JsonProperty("simulated_at") + private final String simulatedAt; + + @JsonProperty("tier") + private final String tier; + + @JsonProperty("daily_usage") + private final SimulationDailyUsage dailyUsage; + + public SimulatePoliciesResponse( + @JsonProperty("allowed") boolean allowed, + @JsonProperty("applied_policies") List appliedPolicies, + @JsonProperty("risk_score") double riskScore, + @JsonProperty("required_actions") List requiredActions, + @JsonProperty("processing_time_ms") long processingTimeMs, + @JsonProperty("total_policies") int totalPolicies, + @JsonProperty("dry_run") boolean dryRun, + @JsonProperty("simulated_at") String simulatedAt, + @JsonProperty("tier") String tier, + @JsonProperty("daily_usage") SimulationDailyUsage dailyUsage) { + this.allowed = allowed; + this.appliedPolicies = appliedPolicies != null ? List.copyOf(appliedPolicies) : List.of(); + this.riskScore = riskScore; + this.requiredActions = requiredActions != null ? List.copyOf(requiredActions) : List.of(); + this.processingTimeMs = processingTimeMs; + this.totalPolicies = totalPolicies; + this.dryRun = dryRun; + this.simulatedAt = simulatedAt; + this.tier = tier; + this.dailyUsage = dailyUsage; + } + + public boolean isAllowed() { return allowed; } + public List getAppliedPolicies() { return appliedPolicies; } + public double getRiskScore() { return riskScore; } + public List getRequiredActions() { return requiredActions; } + public long getProcessingTimeMs() { return processingTimeMs; } + public int getTotalPolicies() { return totalPolicies; } + public boolean isDryRun() { return dryRun; } + public String getSimulatedAt() { return simulatedAt; } + public String getTier() { return tier; } + public SimulationDailyUsage getDailyUsage() { return dailyUsage; } +} diff --git a/src/main/java/com/getaxonflow/sdk/simulation/SimulationDailyUsage.java b/src/main/java/com/getaxonflow/sdk/simulation/SimulationDailyUsage.java new file mode 100644 index 0000000..e156813 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/simulation/SimulationDailyUsage.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.simulation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Daily usage counters for policy simulation. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SimulationDailyUsage { + + @JsonProperty("used") + private final int used; + + @JsonProperty("limit") + private final int limit; + + public SimulationDailyUsage( + @JsonProperty("used") int used, + @JsonProperty("limit") int limit) { + this.used = used; + this.limit = limit; + } + + public int getUsed() { return used; } + public int getLimit() { return limit; } +} diff --git a/src/test/java/com/getaxonflow/sdk/PolicySimulationTest.java b/src/test/java/com/getaxonflow/sdk/PolicySimulationTest.java new file mode 100644 index 0000000..a569577 --- /dev/null +++ b/src/test/java/com/getaxonflow/sdk/PolicySimulationTest.java @@ -0,0 +1,523 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk; + +import com.getaxonflow.sdk.exceptions.AxonFlowException; +import com.getaxonflow.sdk.simulation.*; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.*; + +/** + * Tests for policy simulation methods. + */ +@WireMockTest +@DisplayName("Policy Simulation") +class PolicySimulationTest { + + private AxonFlow axonflow; + + @BeforeEach + void setUp(WireMockRuntimeInfo wmRuntimeInfo) { + axonflow = AxonFlow.create(AxonFlowConfig.builder() + .endpoint(wmRuntimeInfo.getHttpBaseUrl()) + .clientId("test-client").clientSecret("test-secret") + .build()); + } + + // ======================================================================== + // simulatePolicies + // ======================================================================== + + @Test + @DisplayName("should simulate policies and return blocked result") + void shouldSimulatePoliciesBlocked() { + stubFor(post(urlEqualTo("/api/v1/policies/simulate")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"allowed\":false,\"applied_policies\":[\"block-pii\",\"block-financial\"],\"risk_score\":0.85,\"required_actions\":[\"redact_pii\"],\"processing_time_ms\":12,\"total_policies\":5,\"dry_run\":true,\"simulated_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"evaluation\",\"daily_usage\":{\"used\":3,\"limit\":100}}}"))); + + SimulatePoliciesResponse result = axonflow.simulatePolicies( + SimulatePoliciesRequest.builder() + .query("My SSN is 123-45-6789") + .requestType("query") + .build()); + + assertThat(result).isNotNull(); + assertThat(result.isAllowed()).isFalse(); + assertThat(result.getAppliedPolicies()).containsExactly("block-pii", "block-financial"); + assertThat(result.getRiskScore()).isEqualTo(0.85); + assertThat(result.getRequiredActions()).containsExactly("redact_pii"); + assertThat(result.getProcessingTimeMs()).isEqualTo(12); + assertThat(result.getTotalPolicies()).isEqualTo(5); + assertThat(result.isDryRun()).isTrue(); + assertThat(result.getSimulatedAt()).isEqualTo("2026-03-24T10:00:00Z"); + assertThat(result.getTier()).isEqualTo("evaluation"); + assertThat(result.getDailyUsage()).isNotNull(); + assertThat(result.getDailyUsage().getUsed()).isEqualTo(3); + assertThat(result.getDailyUsage().getLimit()).isEqualTo(100); + + verify(postRequestedFor(urlEqualTo("/api/v1/policies/simulate")) + .withRequestBody(matchingJsonPath("$.query", equalTo("My SSN is 123-45-6789"))) + .withRequestBody(matchingJsonPath("$.request_type", equalTo("query")))); + } + + @Test + @DisplayName("should simulate policies and return allowed result") + void shouldSimulatePoliciesAllowed() { + stubFor(post(urlEqualTo("/api/v1/policies/simulate")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"allowed\":true,\"applied_policies\":[],\"risk_score\":0.1,\"required_actions\":[],\"processing_time_ms\":5,\"total_policies\":5,\"dry_run\":true,\"simulated_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"evaluation\",\"daily_usage\":{\"used\":1,\"limit\":100}}}"))); + + SimulatePoliciesResponse result = axonflow.simulatePolicies( + SimulatePoliciesRequest.builder() + .query("What is the weather?") + .build()); + + assertThat(result.isAllowed()).isTrue(); + assertThat(result.getAppliedPolicies()).isEmpty(); + assertThat(result.getRiskScore()).isEqualTo(0.1); + } + + @Test + @DisplayName("should simulate policies with user and context") + void shouldSimulatePoliciesWithContext() { + stubFor(post(urlEqualTo("/api/v1/policies/simulate")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"allowed\":false,\"applied_policies\":[\"geo-block\"],\"risk_score\":0.9,\"required_actions\":[],\"processing_time_ms\":8,\"total_policies\":3,\"dry_run\":true,\"simulated_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"enterprise\"}}"))); + + SimulatePoliciesResponse result = axonflow.simulatePolicies( + SimulatePoliciesRequest.builder() + .query("Execute trade") + .requestType("execute") + .user(Map.of("role", "analyst")) + .context(Map.of("region", "restricted")) + .build()); + + assertThat(result).isNotNull(); + assertThat(result.isAllowed()).isFalse(); + assertThat(result.getAppliedPolicies()).containsExactly("geo-block"); + + verify(postRequestedFor(urlEqualTo("/api/v1/policies/simulate")) + .withRequestBody(matchingJsonPath("$.user.role", equalTo("analyst"))) + .withRequestBody(matchingJsonPath("$.context.region", equalTo("restricted")))); + } + + @Test + @DisplayName("should reject null request for simulatePolicies") + void shouldRejectNullRequestForSimulate() { + assertThatThrownBy(() -> axonflow.simulatePolicies(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("request cannot be null"); + } + + @Test + @DisplayName("should reject null query in SimulatePoliciesRequest builder") + void shouldRejectNullQueryInBuilder() { + assertThatThrownBy(() -> SimulatePoliciesRequest.builder().build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("query cannot be null"); + } + + @Test + @DisplayName("should reject empty query in SimulatePoliciesRequest builder") + void shouldRejectEmptyQueryInBuilder() { + assertThatThrownBy(() -> SimulatePoliciesRequest.builder().query("").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("query cannot be empty"); + } + + @Test + @DisplayName("simulatePoliciesAsync should return future") + void simulatePoliciesAsyncShouldReturnFuture() throws Exception { + stubFor(post(urlEqualTo("/api/v1/policies/simulate")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"allowed\":true,\"applied_policies\":[],\"risk_score\":0.0,\"required_actions\":[],\"processing_time_ms\":3,\"total_policies\":2,\"dry_run\":true,\"simulated_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"evaluation\"}}"))); + + CompletableFuture future = axonflow.simulatePoliciesAsync( + SimulatePoliciesRequest.builder().query("Hello").build()); + SimulatePoliciesResponse result = future.get(); + + assertThat(result).isNotNull(); + assertThat(result.isAllowed()).isTrue(); + } + + @Test + @DisplayName("should handle server error on simulatePolicies") + void shouldHandleServerErrorOnSimulate() { + stubFor(post(urlEqualTo("/api/v1/policies/simulate")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\":\"internal server error\"}"))); + + assertThatThrownBy(() -> axonflow.simulatePolicies( + SimulatePoliciesRequest.builder().query("test").build())) + .isInstanceOf(AxonFlowException.class); + } + + @Test + @DisplayName("should handle unwrapped response for simulatePolicies") + void shouldHandleUnwrappedResponseForSimulate() { + stubFor(post(urlEqualTo("/api/v1/policies/simulate")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"allowed\":true,\"applied_policies\":[],\"risk_score\":0.0,\"required_actions\":[],\"processing_time_ms\":2,\"total_policies\":1,\"dry_run\":true,\"simulated_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"evaluation\"}"))); + + SimulatePoliciesResponse result = axonflow.simulatePolicies( + SimulatePoliciesRequest.builder().query("test").build()); + + assertThat(result).isNotNull(); + assertThat(result.isAllowed()).isTrue(); + } + + // ======================================================================== + // getPolicyImpactReport + // ======================================================================== + + @Test + @DisplayName("should get policy impact report") + void shouldGetPolicyImpactReport() { + stubFor(post(urlEqualTo("/api/v1/policies/impact-report")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"policy_id\":\"policy_block_pii\",\"policy_name\":\"block-pii\",\"total_inputs\":3,\"matched\":2,\"blocked\":2,\"match_rate\":0.667,\"block_rate\":0.667,\"results\":[{\"input_index\":0,\"matched\":true,\"blocked\":true,\"actions\":[\"block\"]},{\"input_index\":1,\"matched\":false,\"blocked\":false,\"actions\":[\"allow\"]},{\"input_index\":2,\"matched\":true,\"blocked\":true,\"actions\":[\"block\"]}],\"processing_time_ms\":25,\"generated_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"evaluation\"}}"))); + + ImpactReportResponse report = axonflow.getPolicyImpactReport( + ImpactReportRequest.builder() + .policyId("policy_block_pii") + .inputs(List.of( + ImpactReportInput.builder().query("My SSN is 123-45-6789").build(), + ImpactReportInput.builder().query("What is the weather?").build(), + ImpactReportInput.builder().query("My email is test@example.com").build())) + .build()); + + assertThat(report).isNotNull(); + assertThat(report.getPolicyId()).isEqualTo("policy_block_pii"); + assertThat(report.getTotalInputs()).isEqualTo(3); + assertThat(report.getMatched()).isEqualTo(2); + assertThat(report.getBlocked()).isEqualTo(2); + assertThat(report.getMatchRate()).isEqualTo(0.667); + assertThat(report.getBlockRate()).isEqualTo(0.667); + assertThat(report.getPolicyName()).isEqualTo("block-pii"); + assertThat(report.getResults()).hasSize(3); + assertThat(report.getResults().get(0).getInputIndex()).isEqualTo(0); + assertThat(report.getResults().get(0).isMatched()).isTrue(); + assertThat(report.getResults().get(0).isBlocked()).isTrue(); + assertThat(report.getResults().get(0).getActions()).containsExactly("block"); + assertThat(report.getResults().get(1).getInputIndex()).isEqualTo(1); + assertThat(report.getResults().get(1).isMatched()).isFalse(); + assertThat(report.getResults().get(1).getActions()).containsExactly("allow"); + assertThat(report.getProcessingTimeMs()).isEqualTo(25); + assertThat(report.getGeneratedAt()).isEqualTo("2026-03-24T10:00:00Z"); + assertThat(report.getTier()).isEqualTo("evaluation"); + + verify(postRequestedFor(urlEqualTo("/api/v1/policies/impact-report")) + .withRequestBody(matchingJsonPath("$.policy_id", equalTo("policy_block_pii")))); + } + + @Test + @DisplayName("should get impact report with no matches") + void shouldGetImpactReportNoMatches() { + stubFor(post(urlEqualTo("/api/v1/policies/impact-report")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"policy_id\":\"policy_strict\",\"total_inputs\":1,\"matched\":0,\"blocked\":0,\"match_rate\":0.0,\"block_rate\":0.0,\"results\":[{\"input_index\":0,\"matched\":false,\"blocked\":false,\"actions\":[\"allow\"]}],\"processing_time_ms\":3,\"generated_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"enterprise\"}}"))); + + ImpactReportResponse report = axonflow.getPolicyImpactReport( + ImpactReportRequest.builder() + .policyId("policy_strict") + .inputs(List.of(ImpactReportInput.builder().query("Hello world").build())) + .build()); + + assertThat(report.getMatched()).isEqualTo(0); + assertThat(report.getMatchRate()).isEqualTo(0.0); + } + + @Test + @DisplayName("should reject null request for getPolicyImpactReport") + void shouldRejectNullRequestForImpactReport() { + assertThatThrownBy(() -> axonflow.getPolicyImpactReport(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("request cannot be null"); + } + + @Test + @DisplayName("should reject null policyId in ImpactReportRequest builder") + void shouldRejectNullPolicyIdInImpactReportBuilder() { + assertThatThrownBy(() -> ImpactReportRequest.builder() + .inputs(List.of(ImpactReportInput.builder().query("test").build())) + .build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("policyId cannot be null"); + } + + @Test + @DisplayName("should reject empty policyId in ImpactReportRequest builder") + void shouldRejectEmptyPolicyIdInImpactReportBuilder() { + assertThatThrownBy(() -> ImpactReportRequest.builder() + .policyId("") + .inputs(List.of(ImpactReportInput.builder().query("test").build())) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("policyId cannot be empty"); + } + + @Test + @DisplayName("should reject empty inputs in ImpactReportRequest builder") + void shouldRejectEmptyInputsInImpactReportBuilder() { + assertThatThrownBy(() -> ImpactReportRequest.builder() + .policyId("policy_1") + .inputs(List.of()) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("inputs cannot be empty"); + } + + @Test + @DisplayName("should reject null inputs in ImpactReportRequest builder") + void shouldRejectNullInputsInImpactReportBuilder() { + assertThatThrownBy(() -> ImpactReportRequest.builder() + .policyId("policy_1") + .build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("inputs cannot be null"); + } + + @Test + @DisplayName("getPolicyImpactReportAsync should return future") + void getPolicyImpactReportAsyncShouldReturnFuture() throws Exception { + stubFor(post(urlEqualTo("/api/v1/policies/impact-report")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"policy_id\":\"p1\",\"total_inputs\":1,\"matched\":0,\"blocked\":0,\"match_rate\":0.0,\"block_rate\":0.0,\"results\":[],\"processing_time_ms\":2,\"generated_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"evaluation\"}}"))); + + CompletableFuture future = axonflow.getPolicyImpactReportAsync( + ImpactReportRequest.builder() + .policyId("p1") + .inputs(List.of(ImpactReportInput.builder().query("test").build())) + .build()); + ImpactReportResponse report = future.get(); + + assertThat(report).isNotNull(); + assertThat(report.getPolicyId()).isEqualTo("p1"); + } + + @Test + @DisplayName("should handle server error on getPolicyImpactReport") + void shouldHandleServerErrorOnImpactReport() { + stubFor(post(urlEqualTo("/api/v1/policies/impact-report")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\":\"internal server error\"}"))); + + assertThatThrownBy(() -> axonflow.getPolicyImpactReport( + ImpactReportRequest.builder() + .policyId("p1") + .inputs(List.of(ImpactReportInput.builder().query("test").build())) + .build())) + .isInstanceOf(AxonFlowException.class); + } + + // ======================================================================== + // detectPolicyConflicts + // ======================================================================== + + @Test + @DisplayName("should detect policy conflicts") + void shouldDetectPolicyConflicts() { + stubFor(post(urlEqualTo("/api/v1/policies/conflicts")) + .withRequestBody(containing("\"policy_id\":\"policy_block_pii\"")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"conflicts\":[{\"policy_a\":{\"id\":\"policy_block_pii\",\"name\":\"block-pii\",\"type\":\"deny\"},\"policy_b\":{\"id\":\"policy_allow_internal\",\"name\":\"allow-internal\",\"type\":\"allow\"},\"conflict_type\":\"action_conflict\",\"description\":\"Policy 'block-pii' blocks requests that 'allow-internal' would allow\",\"severity\":\"high\",\"overlapping_field\":\"input.content\"}],\"total_policies\":8,\"conflict_count\":1,\"checked_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"evaluation\"}}"))); + + PolicyConflictResponse result = axonflow.detectPolicyConflicts("policy_block_pii"); + + assertThat(result).isNotNull(); + assertThat(result.getConflictCount()).isEqualTo(1); + assertThat(result.getTotalPolicies()).isEqualTo(8); + assertThat(result.getCheckedAt()).isEqualTo("2026-03-24T10:00:00Z"); + assertThat(result.getTier()).isEqualTo("evaluation"); + assertThat(result.getConflicts()).hasSize(1); + + PolicyConflict conflict = result.getConflicts().get(0); + assertThat(conflict.getConflictType()).isEqualTo("action_conflict"); + assertThat(conflict.getSeverity()).isEqualTo("high"); + assertThat(conflict.getDescription()).contains("block-pii"); + assertThat(conflict.getOverlappingField()).isEqualTo("input.content"); + assertThat(conflict.getPolicyA()).isNotNull(); + assertThat(conflict.getPolicyA().getId()).isEqualTo("policy_block_pii"); + assertThat(conflict.getPolicyA().getName()).isEqualTo("block-pii"); + assertThat(conflict.getPolicyA().getType()).isEqualTo("deny"); + assertThat(conflict.getPolicyB()).isNotNull(); + assertThat(conflict.getPolicyB().getId()).isEqualTo("policy_allow_internal"); + assertThat(conflict.getPolicyB().getName()).isEqualTo("allow-internal"); + assertThat(conflict.getPolicyB().getType()).isEqualTo("allow"); + + verify(postRequestedFor(urlEqualTo("/api/v1/policies/conflicts")) + .withRequestBody(containing("\"policy_id\":\"policy_block_pii\""))); + } + + @Test + @DisplayName("should detect no conflicts") + void shouldDetectNoConflicts() { + stubFor(post(urlEqualTo("/api/v1/policies/conflicts")) + .withRequestBody(containing("\"policy_id\":\"policy_safe\"")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"conflicts\":[],\"total_policies\":5,\"conflict_count\":0,\"checked_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"enterprise\"}}"))); + + PolicyConflictResponse result = axonflow.detectPolicyConflicts("policy_safe"); + + assertThat(result).isNotNull(); + assertThat(result.getConflictCount()).isEqualTo(0); + assertThat(result.getConflicts()).isEmpty(); + assertThat(result.getTotalPolicies()).isEqualTo(5); + } + + @Test + @DisplayName("should scan all policies when policyId is null") + void shouldScanAllPoliciesWhenNull() { + stubFor(post(urlEqualTo("/api/v1/policies/conflicts")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"conflicts\":[],\"total_policies\":5,\"conflict_count\":0,\"checked_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"evaluation\"}}"))); + + PolicyConflictResponse result = axonflow.detectPolicyConflicts(null); + + assertThat(result).isNotNull(); + assertThat(result.getConflictCount()).isEqualTo(0); + assertThat(result.getConflicts()).isEmpty(); + + verify(postRequestedFor(urlEqualTo("/api/v1/policies/conflicts")) + .withRequestBody(equalToJson("{}"))); + } + + @Test + @DisplayName("should scan all policies with no-arg overload") + void shouldScanAllPoliciesNoArg() { + stubFor(post(urlEqualTo("/api/v1/policies/conflicts")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"conflicts\":[],\"total_policies\":3,\"conflict_count\":0,\"checked_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"evaluation\"}}"))); + + PolicyConflictResponse result = axonflow.detectPolicyConflicts(); + + assertThat(result).isNotNull(); + assertThat(result.getConflictCount()).isEqualTo(0); + + verify(postRequestedFor(urlEqualTo("/api/v1/policies/conflicts")) + .withRequestBody(equalToJson("{}"))); + } + + @Test + @DisplayName("should reject empty policyId for detectPolicyConflicts") + void shouldRejectEmptyPolicyIdForConflicts() { + assertThatThrownBy(() -> axonflow.detectPolicyConflicts("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("policyId cannot be empty"); + } + + @Test + @DisplayName("detectPolicyConflictsAsync should return future") + void detectPolicyConflictsAsyncShouldReturnFuture() throws Exception { + stubFor(post(urlEqualTo("/api/v1/policies/conflicts")) + .withRequestBody(containing("\"policy_id\":\"async_policy\"")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"conflicts\":[],\"total_policies\":3,\"conflict_count\":0,\"checked_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"evaluation\"}}"))); + + CompletableFuture future = axonflow.detectPolicyConflictsAsync("async_policy"); + PolicyConflictResponse result = future.get(); + + assertThat(result).isNotNull(); + assertThat(result.getConflictCount()).isEqualTo(0); + } + + @Test + @DisplayName("should handle server error on detectPolicyConflicts") + void shouldHandleServerErrorOnConflicts() { + stubFor(post(urlEqualTo("/api/v1/policies/conflicts")) + .withRequestBody(containing("\"policy_id\":\"bad_policy\"")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\":\"internal server error\"}"))); + + assertThatThrownBy(() -> axonflow.detectPolicyConflicts("bad_policy")) + .isInstanceOf(AxonFlowException.class); + } + + @Test + @DisplayName("should handle unwrapped response for detectPolicyConflicts") + void shouldHandleUnwrappedResponseForConflicts() { + stubFor(post(urlEqualTo("/api/v1/policies/conflicts")) + .withRequestBody(containing("\"policy_id\":\"unwrapped\"")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"conflicts\":[],\"total_policies\":2,\"conflict_count\":0,\"checked_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"evaluation\"}"))); + + PolicyConflictResponse result = axonflow.detectPolicyConflicts("unwrapped"); + + assertThat(result).isNotNull(); + assertThat(result.getConflictCount()).isEqualTo(0); + } + + @Test + @DisplayName("should send policyId with special characters in request body") + void shouldSendPolicyIdWithSpecialCharactersInBody() { + stubFor(post(urlEqualTo("/api/v1/policies/conflicts")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"conflicts\":[],\"total_policies\":1,\"conflict_count\":0,\"checked_at\":\"2026-03-24T10:00:00Z\",\"tier\":\"evaluation\"}}"))); + + PolicyConflictResponse result = axonflow.detectPolicyConflicts("policy with spaces"); + + assertThat(result).isNotNull(); + + verify(postRequestedFor(urlEqualTo("/api/v1/policies/conflicts")) + .withRequestBody(containing("\"policy_id\":\"policy with spaces\""))); + } +}