diff --git a/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java b/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java index 46d2c3b2..dd4c23d4 100644 --- a/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java @@ -9,6 +9,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindException; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.async.AsyncRequestTimeoutException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @@ -121,6 +122,21 @@ public class GlobalExceptionHandler { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(R.fail(404, "Resource not found")); } + /** + * Spring's default lets this escape to the catch-all below, surfacing as a + * 500 with a stack trace. Return a clean 405 so the client gets a + * structured body and the log stays at WARN. Doubles as a defence when a + * malformed path segment makes a reverse proxy strip the trailing path + * (e.g. a conversationId ending in ":" lands a GET on a @DeleteMapping). + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethodNotSupported(HttpRequestMethodNotSupportedException e, + HttpServletRequest request) { + log.warn("Method not supported: {} {} (supported: {})", + request.getMethod(), request.getRequestURI(), e.getSupportedHttpMethods()); + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(R.fail(405, "Method not allowed")); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception e, HttpServletRequest request, diff --git a/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerMethodNotSupportedTest.java b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerMethodNotSupportedTest.java new file mode 100644 index 00000000..f03ea77d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerMethodNotSupportedTest.java @@ -0,0 +1,67 @@ +package vip.mate.exception; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.i18n.I18nService; + +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies that a path matched but the HTTP method did not surfaces as a clean + * HTTP 405 (handled by {@link GlobalExceptionHandler}) instead of leaking a 500 + * with a full stack trace from the catch-all handler. + * + *

This is the second line of defence for malformed path segments that make + * a reverse proxy strip the trailing path — e.g. a conversationId ending in + * ":" landing a GET on a @DeleteMapping route (upstream issue #369). + */ +class GlobalExceptionHandlerMethodNotSupportedTest { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + I18nService i18n = mock(I18nService.class); + mockMvc = MockMvcBuilders.standaloneSetup(new ProbeController()) + .setControllerAdvice(new GlobalExceptionHandler(i18n)) + .build(); + } + + @Test + @DisplayName("GET on a @DeleteMapping-only route returns 405, not 500.") + void getOnDeleteOnlyRouteReturns405() throws Exception { + mockMvc.perform(get("/probe/abc")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(jsonPath("$.code").value(405)) + .andExpect(jsonPath("$.msg").value("Method not allowed")); + } + + @Test + @DisplayName("DELETE on the same route still resolves the handler normally.") + void deleteOnDeleteRouteReturns200() throws Exception { + mockMvc.perform(delete("/probe/abc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("abc")); + } + + /** Minimal stand-in for a controller whose path is mapped to DELETE only. */ + @RestController + static class ProbeController { + @DeleteMapping("/probe/{id}") + R probe(@PathVariable String id) { + return R.ok(id); + } + } +}