fix: 统一 Web 异常的 HTTP 错误语义
- 支持业务异常声明 HTTP 状态、业务错误码与原始根因 - 规范参数错误、框架异常和未知异常的安全响应
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package tech.easyflow.common.web.error;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* {@link GlobalErrorResolver} HTTP 状态与安全错误体回归测试。
|
||||
*/
|
||||
public class GlobalErrorResolverTest {
|
||||
|
||||
private final GlobalErrorResolver resolver = new GlobalErrorResolver();
|
||||
|
||||
/**
|
||||
* 验证业务冲突不会被包装为 HTTP 200。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreserveBusinessHttpStatusAndErrorCode() {
|
||||
Resolution resolution = resolve(new BusinessException(409, 4091, "文件版本冲突"));
|
||||
|
||||
assertEquals(409, resolution.response.getStatus());
|
||||
assertEquals(4091, resolution.modelAndView.getModel().get("errorCode"));
|
||||
assertEquals("文件版本冲突", resolution.modelAndView.getModel().get("message"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Spring 标准状态异常保留 4xx 语义和安全原因。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreserveResponseStatusException() {
|
||||
Resolution resolution = resolve(new ResponseStatusException(HttpStatus.NOT_FOUND, "Skill 不存在"));
|
||||
|
||||
assertEquals(404, resolution.response.getStatus());
|
||||
assertEquals(404, resolution.modelAndView.getModel().get("errorCode"));
|
||||
assertEquals("Skill 不存在", resolution.modelAndView.getModel().get("message"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证框架级 5xx 异常不会向客户端泄露 reason。
|
||||
*/
|
||||
@Test
|
||||
public void shouldHideResponseStatusServerErrorReason() {
|
||||
Resolution resolution = resolve(new ResponseStatusException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR, "database-secret-detail"));
|
||||
|
||||
assertEquals(500, resolution.response.getStatus());
|
||||
assertEquals(500, resolution.modelAndView.getModel().get("errorCode"));
|
||||
assertEquals("服务暂时不可用,请稍后重试", resolution.modelAndView.getModel().get("message"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证注解声明的 HTTP 状态不会被统一异常处理覆盖。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreserveAnnotatedResponseStatus() {
|
||||
Resolution resolution = resolve(new AnnotatedConflictException());
|
||||
|
||||
assertEquals(409, resolution.response.getStatus());
|
||||
assertEquals("状态冲突", resolution.modelAndView.getModel().get("message"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未知异常返回真实 500,且不会向客户端泄露内部异常信息。
|
||||
*/
|
||||
@Test
|
||||
public void shouldHideUnexpectedExceptionDetails() {
|
||||
Resolution resolution = resolve(new IllegalStateException("database-secret-detail"));
|
||||
|
||||
assertEquals(500, resolution.response.getStatus());
|
||||
assertEquals(500, resolution.modelAndView.getModel().get("errorCode"));
|
||||
assertEquals("服务暂时不可用,请稍后重试", resolution.modelAndView.getModel().get("message"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证请求参数类型错误返回安全的 HTTP 400。
|
||||
*
|
||||
* @throws Exception 构造反射参数失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldReturnSafeBadRequestForTypeMismatch() throws Exception {
|
||||
MethodParameter parameter = new MethodParameter(
|
||||
GlobalErrorResolverTest.class.getDeclaredMethod("sampleParameter", BigInteger.class), 0);
|
||||
MethodArgumentTypeMismatchException exception = new MethodArgumentTypeMismatchException(
|
||||
"not-an-id", BigInteger.class, "id", parameter,
|
||||
new NumberFormatException("sensitive-converter-detail"));
|
||||
|
||||
Resolution resolution = resolve(exception);
|
||||
|
||||
assertEquals(400, resolution.response.getStatus());
|
||||
assertEquals(400, resolution.modelAndView.getModel().get("errorCode"));
|
||||
assertEquals("请求参数格式不正确", resolution.modelAndView.getModel().get("message"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供反射参数签名。
|
||||
*
|
||||
* @param id 示例 ID
|
||||
*/
|
||||
private static void sampleParameter(BigInteger id) {
|
||||
// 仅用于构造 Spring MethodParameter 测试数据。
|
||||
}
|
||||
|
||||
private Resolution resolve(Exception exception) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/skill/file/save");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
ModelAndView modelAndView = resolver.resolveException(request, response, this, exception);
|
||||
return new Resolution(response, modelAndView);
|
||||
}
|
||||
|
||||
@ResponseStatus(code = HttpStatus.CONFLICT, reason = "状态冲突")
|
||||
private static final class AnnotatedConflictException extends RuntimeException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
|
||||
private record Resolution(MockHttpServletResponse response, ModelAndView modelAndView) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package tech.easyflow.common.web.jsonbody;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.web.context.request.ServletWebRequest;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
/**
|
||||
* {@link JsonBodyArgumentResolver} 请求体错误语义回归测试。
|
||||
*/
|
||||
public class JsonBodyArgumentResolverTest {
|
||||
|
||||
/**
|
||||
* 验证畸形 JSON 返回安全的 HTTP 400 业务异常。
|
||||
*
|
||||
* @throws Exception 构造反射参数失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectMalformedJsonAsBadRequest() throws Exception {
|
||||
RequestMappingHandlerAdapter adapter = new RequestMappingHandlerAdapter();
|
||||
JsonBodyArgumentResolver resolver = new JsonBodyArgumentResolver(adapter);
|
||||
Method method = JsonBodyArgumentResolverTest.class.getDeclaredMethod("sampleBody", SampleRequest.class);
|
||||
MethodParameter parameter = new MethodParameter(method, 0);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/skill/update");
|
||||
request.setContentType("application/json");
|
||||
request.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
request.setContent("{\"name\":".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> resolver.resolveArgument(parameter, null, new ServletWebRequest(request), null));
|
||||
|
||||
assertEquals(400, exception.getHttpStatus());
|
||||
assertEquals(400, exception.getErrorCode());
|
||||
assertEquals("请求参数格式不正确", exception.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供反射参数签名。
|
||||
*
|
||||
* @param request 示例请求
|
||||
*/
|
||||
private static void sampleBody(@JsonBody(skipConvertError = false) SampleRequest request) {
|
||||
// 仅用于构造 MethodParameter。
|
||||
}
|
||||
|
||||
private record SampleRequest(String name) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user