Parses the JSON envelope and reads the {@code blocked} boolean. When the field is present it + * is authoritative: {@code true} means a policy block ({@link PolicyViolationException}), {@code + * false} means the 403 is an authorization rejection (e.g. tenant mismatch) even though the raw + * body contains the literal substring {@code "blocked"}. Only when the body is not parseable + * JSON, or carries no {@code blocked} boolean, does this fall back to the legacy policy-phrase + * heuristic (for older agents whose error envelopes predate the field). + */ + private boolean isPolicyBlockBody(String body) { + if (body == null || body.isEmpty()) { + return false; + } + try { + JsonNode node = objectMapper.readTree(body); + JsonNode blocked = node.get("blocked"); + if (blocked != null && blocked.isBoolean()) { + return blocked.asBoolean(); + } + } catch (JsonProcessingException e) { + // Not JSON — fall through to the phrase heuristic. + } + return body.contains("policy") || body.contains("block_reason"); + } + private String extractErrorMessage(String body, String defaultMessage) { if (body == null || body.isEmpty()) { return defaultMessage; diff --git a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java index 226b241..299669f 100644 --- a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java +++ b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java @@ -1060,6 +1060,67 @@ void shouldHandle403PolicyViolation() { assertThatThrownBy(() -> axonflow.healthCheck()).isInstanceOf(PolicyViolationException.class); } + @Test + @DisplayName("403 with blocked:false is an auth rejection, not a policy block") + void shouldHandle403BlockedFalseAsAuthRejection() { + // Exact live agent envelope for a tenant-mismatch rejection. The body + // carries a literal "blocked":false key, which the old substring + // heuristic misread as a policy block. + stubFor( + get(urlEqualTo("/health")) + .willReturn( + aResponse() + .withStatus(403) + .withBody( + "{\"success\":false,\"error\":\"Tenant mismatch\",\"blocked\":false}"))); + + assertThatThrownBy(() -> axonflow.healthCheck()) + .isInstanceOf(AuthenticationException.class) + .isNotInstanceOf(PolicyViolationException.class) + .hasMessageContaining("Tenant mismatch"); + } + + @Test + @DisplayName("403 with blocked:true is a policy block") + void shouldHandle403BlockedTrueAsPolicyViolation() { + // Real policy-block envelope shape from the agent. + stubFor( + get(urlEqualTo("/health")) + .willReturn( + aResponse() + .withStatus(403) + .withBody( + "{\"success\":false,\"blocked\":true," + + "\"block_reason\":\"Detects stacked DROP TABLE/DATABASE" + + " statement\"}"))); + + assertThatThrownBy(() -> axonflow.healthCheck()) + .isInstanceOf(PolicyViolationException.class) + .hasMessageContaining("Detects stacked DROP TABLE/DATABASE statement"); + } + + @Test + @DisplayName("403 with unparseable body falls back to the policy-phrase heuristic") + void shouldHandle403UnparseableBodyFallback() { + stubFor( + get(urlEqualTo("/health")) + .willReturn(aResponse().withStatus(403).withBody("policy violation upstream"))); + + assertThatThrownBy(() -> axonflow.healthCheck()).isInstanceOf(PolicyViolationException.class); + } + + @Test + @DisplayName("403 with unparseable non-policy body is an auth rejection") + void shouldHandle403UnparseableNonPolicyBody() { + stubFor( + get(urlEqualTo("/health")) + .willReturn(aResponse().withStatus(403).withBody("forbidden by proxy"))); + + assertThatThrownBy(() -> axonflow.healthCheck()) + .isInstanceOf(AuthenticationException.class) + .isNotInstanceOf(PolicyViolationException.class); + } + @Test @DisplayName("should handle 429 Rate Limit") void shouldHandle429() {