Companion code for "Global Exception Handling with ProblemDetail (RFC 9457) in Spring Boot 4". Thirteen failures under five handling setups (Boot defaults, the Boot flag, a ResponseEntityExceptionHandler advice, advice plus an ErrorController, a catch-all ordered first), validation errors, i18n, content negotiation, Security's 401/403, silent 500s and decoding on the client. 16 tests pin the behaviour. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01C3TETMrqVUWeFkNtz3Jbo3
101 lines
4.5 KiB
Java
101 lines
4.5 KiB
Java
package com.ankurm.problems;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
import org.springframework.boot.test.context.SpringBootTest;
|
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
|
import org.springframework.test.context.ActiveProfiles;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
/**
|
|
* The contract of the recommended setup (profiles advice + errors): every failure, including the
|
|
* ones produced outside Spring MVC, is application/problem+json with the right status.
|
|
*/
|
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
|
@ActiveProfiles({"advice", "errors"})
|
|
class AdviceContractTest {
|
|
|
|
private static final String PROBLEM = "application/problem+json";
|
|
|
|
@LocalServerPort
|
|
int port;
|
|
|
|
@Test
|
|
void domainExceptionIs404WithStableType() throws Exception {
|
|
Http.Response r = Http.get(port, "/orders/999");
|
|
assertThat(r.status()).isEqualTo(404);
|
|
assertThat(r.contentType()).isEqualTo(PROBLEM);
|
|
assertThat(r.body()).contains("\"type\":\"https://ankurm.com/problems/order-not-found\"")
|
|
.contains("\"orderId\":999")
|
|
.contains("\"instance\":\"/orders/999\"");
|
|
}
|
|
|
|
@Test
|
|
void validationListsEveryViolationWithAPointer() throws Exception {
|
|
Http.Response r = Http.send(port, "POST", "/orders", "{\"sku\":\"\",\"quantity\":0,\"customerEmail\":\"nope\"}");
|
|
assertThat(r.status()).isEqualTo(400);
|
|
assertThat(r.contentType()).isEqualTo(PROBLEM);
|
|
// Order of the three is not stable between runs - assert membership, not position.
|
|
assertThat(r.body()).contains("\"pointer\":\"#/sku\"", "\"pointer\":\"#/quantity\"",
|
|
"\"pointer\":\"#/customerEmail\"");
|
|
}
|
|
|
|
@Test
|
|
void unexpectedExceptionDoesNotLeakItsMessage() throws Exception {
|
|
Http.Response r = Http.get(port, "/orders/boom");
|
|
assertThat(r.status()).isEqualTo(500);
|
|
assertThat(r.contentType()).isEqualTo(PROBLEM);
|
|
assertThat(r.body()).contains("\"errorId\":").doesNotContain("jdbc:").doesNotContain("orders_rw");
|
|
}
|
|
|
|
@Test
|
|
void filterExceptionReachesTheErrorControllerNotTheAdvice() throws Exception {
|
|
Http.Response r = Http.get(port, "/orders/1", "X-Tenant", "BAD!");
|
|
assertThat(r.status()).isEqualTo(500);
|
|
assertThat(r.contentType()).isEqualTo(PROBLEM);
|
|
assertThat(r.body()).doesNotContain("X-Tenant").doesNotContain("errorId");
|
|
}
|
|
|
|
@Test
|
|
void securityResponsesAreProblemsToo() throws Exception {
|
|
Http.Response anonymous = Http.get(port, "/admin/orders");
|
|
assertThat(anonymous.status()).isEqualTo(401);
|
|
assertThat(anonymous.contentType()).isEqualTo(PROBLEM);
|
|
assertThat(anonymous.headers().firstValue("WWW-Authenticate")).hasValue("Basic realm=\"orders\"");
|
|
|
|
Http.Response user = Http.get(port, "/admin/orders", "Authorization", Http.basic("user", "user"));
|
|
assertThat(user.status()).isEqualTo(403);
|
|
assertThat(user.contentType()).isEqualTo(PROBLEM);
|
|
}
|
|
|
|
@Test
|
|
void anAcceptOfApplicationXmlStillGetsJson() throws Exception {
|
|
// application/xml is not compatible with application/problem+xml, so the error falls back
|
|
// to problem+json - while a successful response to the same Accept header is XML.
|
|
assertThat(Http.get(port, "/orders/1", "Accept", "application/xml").contentType())
|
|
.startsWith("application/xml");
|
|
assertThat(Http.get(port, "/orders/999", "Accept", "application/xml").contentType())
|
|
.isEqualTo(PROBLEM);
|
|
assertThat(Http.get(port, "/orders/999", "Accept", "application/problem+xml").contentType())
|
|
.isEqualTo("application/problem+xml");
|
|
}
|
|
|
|
@Test
|
|
void anUnsatisfiableAcceptDoesNotTurnTheErrorInto406() throws Exception {
|
|
Http.Response r = Http.get(port, "/orders/999", "Accept", "image/png");
|
|
assertThat(r.status()).isEqualTo(404);
|
|
assertThat(r.contentType()).isEqualTo(PROBLEM);
|
|
}
|
|
|
|
@Test
|
|
void messageSourceOverridesTitleAndDetailPerLocale() throws Exception {
|
|
String body = "{\"sku\":\"SKU-2\",\"quantity\":3,\"customerEmail\":\"[email protected]\"}";
|
|
assertThat(Http.send(port, "POST", "/orders", body).body())
|
|
.contains("\"title\":\"Out of stock\"")
|
|
.contains("\"detail\":\"Only 0 unit(s) of SKU-2 are available.\"");
|
|
assertThat(Http.send(port, "POST", "/orders", body, "Accept-Language", "de").body())
|
|
.contains("\"title\":\"Nicht vorrätig\"");
|
|
}
|
|
}
|