From 0a0174c01e0bd9830c9cfabfdc683a5858d5a371 Mon Sep 17 00:00:00 2001 From: Chuck1sn Date: Tue, 8 Jul 2025 10:48:20 +0800 Subject: [PATCH] add curl field --- .../com/zl/mjga/aspect/LoggingAspect.java | 136 ++++ .../com/zl/mjga/dto/aoplog/AopLogRespDto.java | 3 + backend/src/main/resources/application.yml | 5 +- .../db/migration/V1_0_4__init_aop_log.sql | 1 + .../integration/aspect/LoggingAspectTest.java | 51 +- .../integration/mvc/AopLogControllerTest.java | 1 + .../unit/LoggingAspectCurlGenerationTest.java | 612 ++++++++++++++++++ .../LoggingAspectCurlGenerationTest_README.md | 103 +++ .../migration/test/V1_0_4__init_aop_log.sql | 13 +- 9 files changed, 913 insertions(+), 12 deletions(-) create mode 100644 backend/src/test/java/com/zl/mjga/unit/LoggingAspectCurlGenerationTest.java create mode 100644 backend/src/test/java/com/zl/mjga/unit/LoggingAspectCurlGenerationTest_README.md diff --git a/backend/src/main/java/com/zl/mjga/aspect/LoggingAspect.java b/backend/src/main/java/com/zl/mjga/aspect/LoggingAspect.java index 8ff72cc..0857739 100644 --- a/backend/src/main/java/com/zl/mjga/aspect/LoggingAspect.java +++ b/backend/src/main/java/com/zl/mjga/aspect/LoggingAspect.java @@ -6,18 +6,24 @@ import com.zl.mjga.annotation.SkipAopLog; import com.zl.mjga.repository.UserRepository; import com.zl.mjga.service.AopLogService; import jakarta.servlet.http.HttpServletRequest; +import java.io.BufferedReader; +import java.io.IOException; import java.lang.reflect.Method; import java.time.Duration; import java.time.Instant; +import java.util.Collections; +import java.util.Enumeration; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.jooq.generated.mjga.tables.pojos.AopLog; import org.jooq.generated.mjga.tables.pojos.User; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -28,6 +34,7 @@ import org.springframework.web.context.request.ServletRequestAttributes; @Component @Slf4j @RequiredArgsConstructor +@ConditionalOnProperty(name = "aop.logging.enabled", havingValue = "true", matchIfMissing = true) public class LoggingAspect { private final AopLogService aopLogService; @@ -137,6 +144,7 @@ public class LoggingAspect { HttpServletRequest request = attributes.getRequest(); aopLog.setIpAddress(getClientIpAddress(request)); aopLog.setUserAgent(request.getHeader("User-Agent")); + aopLog.setCurl(generateCurlCommand(request)); } private String getClientIpAddress(HttpServletRequest request) { @@ -179,4 +187,132 @@ public class LoggingAspect { return e.getMessage(); } } + + public String generateCurlCommand(HttpServletRequest request) { + try { + StringBuilder curl = new StringBuilder("curl -X "); + + curl.append(request.getMethod()); + + String url = getFullRequestUrl(request); + curl.append(" '").append(url).append("'"); + + appendHeaders(curl, request); + + if (hasRequestBody(request.getMethod())) { + appendRequestBody(curl, request); + } + return curl.toString(); + } catch (Exception e) { + log.error("Failed to generate curl command", e); + return "curl command generation failed: " + e.getMessage(); + } + } + + private String getFullRequestUrl(HttpServletRequest request) { + StringBuilder url = new StringBuilder(); + + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int serverPort = request.getServerPort(); + + if (scheme == null) { + scheme = "http"; + } + if (serverName == null) { + serverName = "localhost"; + } + + url.append(scheme).append("://").append(serverName); + + if ((scheme.equals("http") && serverPort != 80) + || (scheme.equals("https") && serverPort != 443)) { + url.append(":").append(serverPort); + } + + url.append(request.getRequestURI()); + if (request.getQueryString() != null) { + url.append("?").append(request.getQueryString()); + } + + return url.toString(); + } + + private void appendHeaders(StringBuilder curl, HttpServletRequest request) { + Enumeration headerNames = request.getHeaderNames(); + for (String headerName : Collections.list(headerNames)) { + if (shouldSkipHeader(headerName)) { + continue; + } + + String headerValue = request.getHeader(headerName); + curl.append(" -H '").append(headerName).append(": ").append(headerValue).append("'"); + } + } + + private boolean shouldSkipHeader(String headerName) { + String lowerName = headerName.toLowerCase(); + return lowerName.equals("host") + || lowerName.equals("content-length") + || lowerName.equals("connection") + || lowerName.startsWith("sec-") + || lowerName.equals("upgrade-insecure-requests"); + } + + private boolean hasRequestBody(String method) { + return "POST".equalsIgnoreCase(method) + || "PUT".equalsIgnoreCase(method) + || "PATCH".equalsIgnoreCase(method); + } + + private void appendRequestBody(StringBuilder curl, HttpServletRequest request) { + try { + String contentType = request.getContentType(); + if (StringUtils.contains(contentType, "application/json")) { + String body = getRequestBody(request); + if (StringUtils.isNotEmpty(body)) { + curl.append(" -d '").append(body.replace("'", "\\'")).append("'"); + } + } else if (StringUtils.contains(contentType, "application/x-www-form-urlencoded")) { + appendFormData(curl, request); + } + } catch (Exception e) { + log.warn("Failed to append request body to curl command", e); + } + } + + private String getRequestBody(HttpServletRequest request) { + try (BufferedReader reader = request.getReader()) { + if (reader == null) { + return null; + } + StringBuilder body = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + body.append(line); + } + return body.toString(); + } catch (IOException e) { + log.warn("Failed to read request body", e); + return null; + } + } + + private void appendFormData(StringBuilder curl, HttpServletRequest request) { + Enumeration paramNames = request.getParameterNames(); + StringBuilder formData = new StringBuilder(); + while (paramNames.hasMoreElements()) { + String paramName = paramNames.nextElement(); + String[] paramValues = request.getParameterValues(paramName); + for (String paramValue : paramValues) { + if (!formData.isEmpty()) { + formData.append("&"); + } + formData.append(paramName).append("=").append(paramValue); + } + } + if (!formData.isEmpty()) { + curl.append(" -d '").append(formData).append("'"); + } + } } diff --git a/backend/src/main/java/com/zl/mjga/dto/aoplog/AopLogRespDto.java b/backend/src/main/java/com/zl/mjga/dto/aoplog/AopLogRespDto.java index 4335c41..b87e949 100644 --- a/backend/src/main/java/com/zl/mjga/dto/aoplog/AopLogRespDto.java +++ b/backend/src/main/java/com/zl/mjga/dto/aoplog/AopLogRespDto.java @@ -48,6 +48,9 @@ public class AopLogRespDto { /** 用户代理 */ private String userAgent; + /** curl命令 */ + private String curl; + /** 创建时间 */ private OffsetDateTime createTime; } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 29b542e..09618ac 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -41,4 +41,7 @@ minio: endpoint: ${MINIO_ENDPOINT} access-key: ${MINIO_ROOT_USER} secret-key: ${MINIO_ROOT_PASSWORD} - default-bucket: ${MINIO_DEFAULT_BUCKETS} \ No newline at end of file + default-bucket: ${MINIO_DEFAULT_BUCKETS} +aop: + logging: + enabled: true diff --git a/backend/src/main/resources/db/migration/V1_0_4__init_aop_log.sql b/backend/src/main/resources/db/migration/V1_0_4__init_aop_log.sql index a98a4e3..4ecf3ee 100644 --- a/backend/src/main/resources/db/migration/V1_0_4__init_aop_log.sql +++ b/backend/src/main/resources/db/migration/V1_0_4__init_aop_log.sql @@ -10,6 +10,7 @@ CREATE TABLE mjga.aop_log ( user_id BIGINT, ip_address VARCHAR, user_agent VARCHAR, + curl VARCHAR, create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES mjga.user(id) ON DELETE SET NULL ); diff --git a/backend/src/test/java/com/zl/mjga/integration/aspect/LoggingAspectTest.java b/backend/src/test/java/com/zl/mjga/integration/aspect/LoggingAspectTest.java index 1574d57..4b34d0b 100644 --- a/backend/src/test/java/com/zl/mjga/integration/aspect/LoggingAspectTest.java +++ b/backend/src/test/java/com/zl/mjga/integration/aspect/LoggingAspectTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @@ -47,11 +48,10 @@ class LoggingAspectTest { @Mock private ServletRequestAttributes servletRequestAttributes; @Mock private HttpServletRequest httpServletRequest; - private LoggingAspect loggingAspect; + @InjectMocks LoggingAspect loggingAspect; @BeforeEach void setUp() { - loggingAspect = new LoggingAspect(aopLogService, objectMapper, userRepository); SecurityContextHolder.setContext(securityContext); } @@ -149,7 +149,6 @@ class LoggingAspectTest { assertThat(log.getMethodName()).isEqualTo("serviceMethod"); assertThat(log.getSuccess()).isTrue(); assertThat(log.getUserId()).isEqualTo(123L); - // Service层不应该有请求信息 assertThat(log.getIpAddress()).isNull(); assertThat(log.getUserAgent()).isNull(); }); @@ -179,7 +178,6 @@ class LoggingAspectTest { assertThat(log.getMethodName()).isEqualTo("findById"); assertThat(log.getSuccess()).isTrue(); assertThat(log.getUserId()).isEqualTo(123L); - // Repository层不应该有请求信息 assertThat(log.getIpAddress()).isNull(); assertThat(log.getUserAgent()).isNull(); }); @@ -414,6 +412,51 @@ class LoggingAspectTest { return TestController.class.getMethod("skipLogMethod"); } + @Test + void logController_givenHttpRequest_shouldGenerateCurlCommand() throws Throwable { + // arrange + TestController target = new TestController(); + Object[] args = {"arg1"}; + String expectedResult = "success"; + User mockUser = createMockUser(123L, "testUser"); + + setupAuthenticatedUser("testUser", mockUser); + setupJoinPoint(target, "testMethod", args, expectedResult); + setupSerialization("[\"arg1\"]", "\"success\""); + + // Setup HTTP request mocks before setupRequestContext + when(httpServletRequest.getMethod()).thenReturn("POST"); + when(httpServletRequest.getScheme()).thenReturn("http"); + when(httpServletRequest.getServerName()).thenReturn("localhost"); + when(httpServletRequest.getServerPort()).thenReturn(8080); + when(httpServletRequest.getRequestURI()).thenReturn("/api/test"); + when(httpServletRequest.getQueryString()).thenReturn("param1=value1"); + when(httpServletRequest.getContentType()).thenReturn("application/json"); + when(httpServletRequest.getHeaderNames()) + .thenReturn( + java.util.Collections.enumeration( + java.util.Arrays.asList("Content-Type", "Authorization"))); + when(httpServletRequest.getHeader("Content-Type")).thenReturn("application/json"); + when(httpServletRequest.getHeader("Authorization")).thenReturn("Bearer token123"); + + try (MockedStatic mockedRequestContextHolder = + setupRequestContext("127.0.0.1", "Test-Agent")) { + // action + Object result = loggingAspect.logController(joinPoint); + + // assert + assertThat(result).isEqualTo(expectedResult); + verifyLogSaved( + log -> { + assertThat(log.getCurl()).isNotNull(); + assertThat(log.getCurl()).contains("curl -X POST"); + assertThat(log.getCurl()).contains("'http://localhost:8080/api/test?param1=value1'"); + assertThat(log.getCurl()).contains("-H 'Content-Type: application/json'"); + assertThat(log.getCurl()).contains("-H 'Authorization: Bearer token123'"); + }); + } + } + // Test classes for mocking private static class TestController { public String testMethod() { diff --git a/backend/src/test/java/com/zl/mjga/integration/mvc/AopLogControllerTest.java b/backend/src/test/java/com/zl/mjga/integration/mvc/AopLogControllerTest.java index 873636c..e0f634c 100644 --- a/backend/src/test/java/com/zl/mjga/integration/mvc/AopLogControllerTest.java +++ b/backend/src/test/java/com/zl/mjga/integration/mvc/AopLogControllerTest.java @@ -180,6 +180,7 @@ public class AopLogControllerTest { .username("testUser") .ipAddress("127.0.0.1") .userAgent("Test Agent") + .curl("curl -X GET 'http://localhost:8080/test' -H 'Content-Type: application/json'") .createTime(OffsetDateTime.now()) .build(); } diff --git a/backend/src/test/java/com/zl/mjga/unit/LoggingAspectCurlGenerationTest.java b/backend/src/test/java/com/zl/mjga/unit/LoggingAspectCurlGenerationTest.java new file mode 100644 index 0000000..def7fa4 --- /dev/null +++ b/backend/src/test/java/com/zl/mjga/unit/LoggingAspectCurlGenerationTest.java @@ -0,0 +1,612 @@ +package com.zl.mjga.unit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zl.mjga.aspect.LoggingAspect; +import com.zl.mjga.repository.UserRepository; +import com.zl.mjga.service.AopLogService; +import jakarta.servlet.http.HttpServletRequest; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("LoggingAspect - generateCurlCommand 方法测试") +class LoggingAspectCurlGenerationTest { + + @Mock private AopLogService aopLogService; + @Mock private ObjectMapper objectMapper; + @Mock private UserRepository userRepository; + @Mock private HttpServletRequest request; + + private LoggingAspect loggingAspect; + + @BeforeEach + void setUp() { + loggingAspect = new LoggingAspect(aopLogService, objectMapper, userRepository); + } + + @Nested + @DisplayName("GET 请求测试") + class GetRequestTests { + + @Test + @DisplayName("基本 GET 请求 - 无查询参数") + void generateCurlCommand_givenBasicGetRequest_shouldGenerateCorrectCurl() { + // arrange + when(request.getMethod()).thenReturn("GET"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn(null); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result).isEqualTo("curl -X GET 'http://localhost:8080/api/users'"); + } + + @Test + @DisplayName("GET 请求 - 包含查询参数") + void generateCurlCommand_givenGetRequestWithQueryParams_shouldIncludeQueryString() { + // arrange + when(request.getMethod()).thenReturn("GET"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn("page=1&size=10&name=test"); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result) + .isEqualTo("curl -X GET 'http://localhost:8080/api/users?page=1&size=10&name=test'"); + } + + @Test + @DisplayName("GET 请求 - HTTPS 协议") + void generateCurlCommand_givenHttpsGetRequest_shouldUseHttpsScheme() { + // arrange + when(request.getMethod()).thenReturn("GET"); + when(request.getScheme()).thenReturn("https"); + when(request.getServerName()).thenReturn("api.example.com"); + when(request.getServerPort()).thenReturn(443); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn(null); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result).isEqualTo("curl -X GET 'https://api.example.com/api/users'"); + } + + @Test + @DisplayName("GET 请求 - 自定义端口") + void generateCurlCommand_givenGetRequestWithCustomPort_shouldIncludePort() { + // arrange + when(request.getMethod()).thenReturn("GET"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(9090); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn(null); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result).isEqualTo("curl -X GET 'http://localhost:9090/api/users'"); + } + } + + @Nested + @DisplayName("POST 请求测试") + class PostRequestTests { + + @Test + @DisplayName("POST 请求 - JSON 请求体") + void generateCurlCommand_givenPostRequestWithJsonBody_shouldIncludeDataFlag() + throws IOException { + // arrange + String jsonBody = "{\"name\":\"test\",\"age\":25}"; + setupPostRequest(jsonBody, "application/json"); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result) + .contains("curl -X POST") + .contains("'http://localhost:8080/api/users'") + .contains("-d '{\"name\":\"test\",\"age\":25}'"); + } + + @Test + @DisplayName("POST 请求 - 空 JSON 请求体") + void generateCurlCommand_givenPostRequestWithEmptyJsonBody_shouldNotIncludeDataFlag() + throws IOException { + // arrange + setupPostRequest("", "application/json"); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result) + .contains("curl -X POST") + .contains("'http://localhost:8080/api/users'") + .doesNotContain("-d"); + } + + @Test + @DisplayName("POST 请求 - 包含单引号的 JSON") + void generateCurlCommand_givenPostRequestWithQuotesInJson_shouldEscapeQuotes() + throws IOException { + // arrange + String jsonBody = "{\"message\":\"It's a test\"}"; + setupPostRequest(jsonBody, "application/json"); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result).contains("-d '{\"message\":\"It\\'s a test\"}'"); + } + + private void setupPostRequest(String body, String contentType) throws IOException { + when(request.getMethod()).thenReturn("POST"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn(null); + when(request.getContentType()).thenReturn(contentType); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + if (body != null && !body.trim().isEmpty()) { + BufferedReader reader = new BufferedReader(new StringReader(body)); + when(request.getReader()).thenReturn(reader); + } else { + when(request.getReader()).thenReturn(new BufferedReader(new StringReader(""))); + } + } + } + + @Nested + @DisplayName("PUT 和 PATCH 请求测试") + class PutAndPatchRequestTests { + + @Test + @DisplayName("PUT 请求 - JSON 请求体") + void generateCurlCommand_givenPutRequestWithJsonBody_shouldIncludeDataFlag() + throws IOException { + // arrange + String jsonBody = "{\"id\":1,\"name\":\"updated\"}"; + setupRequestWithBody("PUT", jsonBody, "application/json"); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result).contains("curl -X PUT").contains("-d '{\"id\":1,\"name\":\"updated\"}'"); + } + + @Test + @DisplayName("PATCH 请求 - JSON 请求体") + void generateCurlCommand_givenPatchRequestWithJsonBody_shouldIncludeDataFlag() + throws IOException { + // arrange + String jsonBody = "{\"name\":\"patched\"}"; + setupRequestWithBody("PATCH", jsonBody, "application/json"); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result).contains("curl -X PATCH").contains("-d '{\"name\":\"patched\"}'"); + } + + private void setupRequestWithBody(String method, String body, String contentType) + throws IOException { + when(request.getMethod()).thenReturn(method); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/users/1"); + when(request.getQueryString()).thenReturn(null); + when(request.getContentType()).thenReturn(contentType); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + BufferedReader reader = new BufferedReader(new StringReader(body)); + when(request.getReader()).thenReturn(reader); + } + } + + @Nested + @DisplayName("表单数据请求测试") + class FormDataRequestTests { + + @Test + @DisplayName("POST 请求 - 表单数据") + void generateCurlCommand_givenPostRequestWithFormData_shouldIncludeFormData() { + // arrange + when(request.getMethod()).thenReturn("POST"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/login"); + when(request.getQueryString()).thenReturn(null); + when(request.getContentType()).thenReturn("application/x-www-form-urlencoded"); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + List paramNames = new ArrayList<>(); + paramNames.add("username"); + paramNames.add("password"); + when(request.getParameterNames()).thenReturn(Collections.enumeration(paramNames)); + when(request.getParameterValues("username")).thenReturn(new String[] {"testuser"}); + when(request.getParameterValues("password")).thenReturn(new String[] {"testpass"}); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result) + .contains("curl -X POST") + .contains("'http://localhost:8080/api/login'") + .contains("-d 'username=testuser&password=testpass'"); + } + + @Test + @DisplayName("POST 请求 - 多值表单参数") + void generateCurlCommand_givenPostRequestWithMultiValueFormData_shouldIncludeAllValues() { + // arrange + when(request.getMethod()).thenReturn("POST"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/submit"); + when(request.getQueryString()).thenReturn(null); + when(request.getContentType()).thenReturn("application/x-www-form-urlencoded"); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + List paramNames = new ArrayList<>(); + paramNames.add("tags"); + when(request.getParameterNames()).thenReturn(Collections.enumeration(paramNames)); + when(request.getParameterValues("tags")).thenReturn(new String[] {"tag1", "tag2", "tag3"}); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result).contains("-d 'tags=tag1&tags=tag2&tags=tag3'"); + } + + @Test + @DisplayName("POST 请求 - 空表单数据") + void generateCurlCommand_givenPostRequestWithEmptyFormData_shouldNotIncludeDataFlag() { + // arrange + when(request.getMethod()).thenReturn("POST"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/submit"); + when(request.getQueryString()).thenReturn(null); + when(request.getContentType()).thenReturn("application/x-www-form-urlencoded"); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + when(request.getParameterNames()).thenReturn(Collections.emptyEnumeration()); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result) + .contains("curl -X POST") + .contains("'http://localhost:8080/api/submit'") + .doesNotContain("-d"); + } + } + + @Nested + @DisplayName("请求头处理测试") + class HeaderProcessingTests { + + @Test + @DisplayName("包含常规请求头") + void generateCurlCommand_givenRequestWithHeaders_shouldIncludeHeaders() { + // arrange + when(request.getMethod()).thenReturn("GET"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn(null); + + List headerNames = new ArrayList<>(); + headerNames.add("Authorization"); + headerNames.add("Content-Type"); + headerNames.add("Accept"); + when(request.getHeaderNames()).thenReturn(Collections.enumeration(headerNames)); + when(request.getHeader("Authorization")).thenReturn("Bearer token123"); + when(request.getHeader("Content-Type")).thenReturn("application/json"); + when(request.getHeader("Accept")).thenReturn("application/json"); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result) + .contains("-H 'Authorization: Bearer token123'") + .contains("-H 'Content-Type: application/json'") + .contains("-H 'Accept: application/json'"); + } + + @Test + @DisplayName("跳过特定请求头") + void generateCurlCommand_givenRequestWithSkippedHeaders_shouldExcludeSkippedHeaders() { + // arrange + when(request.getMethod()).thenReturn("GET"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn(null); + + List headerNames = new ArrayList<>(); + headerNames.add("Authorization"); + when(request.getHeaderNames()).thenReturn(Collections.enumeration(headerNames)); + when(request.getHeader("Authorization")).thenReturn("Bearer token123"); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result).contains("-H 'Authorization: Bearer token123'"); + } + + @Test + @DisplayName("验证跳过的请求头不会出现在 curl 命令中") + void generateCurlCommand_givenSkippedHeaders_shouldNotIncludeSkippedHeaders() { + // arrange + when(request.getMethod()).thenReturn("GET"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn(null); + + List headerNames = new ArrayList<>(); + headerNames.add("Host"); + headerNames.add("Content-Length"); + headerNames.add("Connection"); + headerNames.add("Sec-Fetch-Mode"); + headerNames.add("Upgrade-Insecure-Requests"); + headerNames.add("Accept"); + when(request.getHeaderNames()).thenReturn(Collections.enumeration(headerNames)); + when(request.getHeader("Accept")).thenReturn("application/json"); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result) + .contains("-H 'Accept: application/json'") + .doesNotContain("Host") + .doesNotContain("Content-Length") + .doesNotContain("Connection") + .doesNotContain("Sec-Fetch-Mode") + .doesNotContain("Upgrade-Insecure-Requests"); + } + } + + @Nested + @DisplayName("异常情况测试") + class ExceptionHandlingTests { + + @Test + @DisplayName("读取请求体时发生 IOException") + void generateCurlCommand_givenIOExceptionWhenReadingBody_shouldHandleGracefully() + throws IOException { + // arrange + when(request.getMethod()).thenReturn("POST"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn(null); + when(request.getContentType()).thenReturn("application/json"); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + when(request.getReader()).thenThrow(new IOException("Reader error")); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result) + .contains("curl -X POST") + .contains("'http://localhost:8080/api/users'") + .doesNotContain("-d"); // 应该不包含数据标志,因为读取失败 + } + + @Test + @DisplayName("请求参数为 null") + void generateCurlCommand_givenNullRequest_shouldReturnErrorMessage() { + // action + String result = loggingAspect.generateCurlCommand(null); + + // assert + assertThat(result).contains("curl command generation failed:"); + } + + @Test + @DisplayName("请求方法为 null") + void generateCurlCommand_givenNullMethod_shouldHandleGracefully() { + // arrange + when(request.getMethod()).thenReturn(null); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn(null); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result).contains("curl -X null"); + } + + @Test + @DisplayName("服务器信息为 null") + void generateCurlCommand_givenNullServerInfo_shouldUseDefaults() { + // arrange + when(request.getMethod()).thenReturn("GET"); + when(request.getScheme()).thenReturn(null); + when(request.getServerName()).thenReturn(null); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn(null); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result).isEqualTo("curl -X GET 'http://localhost:8080/api/users'"); + } + } + + @Nested + @DisplayName("边界用例测试") + class BoundaryTests { + + @Test + @DisplayName("最小化 GET 请求") + void generateCurlCommand_givenMinimalGetRequest_shouldGenerateBasicCurl() { + // arrange + when(request.getMethod()).thenReturn("GET"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(80); // 标准端口 + when(request.getRequestURI()).thenReturn("/"); + when(request.getQueryString()).thenReturn(null); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result).isEqualTo("curl -X GET 'http://localhost/'"); + } + + @Test + @DisplayName("复杂查询参数 - 包含特殊字符") + void generateCurlCommand_givenComplexQueryParams_shouldIncludeAllParams() { + // arrange + when(request.getMethod()).thenReturn("GET"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/search"); + when(request.getQueryString()) + .thenReturn("q=hello%20world&filter=type%3Duser&sort=name%2Casc"); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result) + .isEqualTo( + "curl -X GET" + + " 'http://localhost:8080/api/search?q=hello%20world&filter=type%3Duser&sort=name%2Casc'"); + } + + @Test + @DisplayName("DELETE 请求 - 不应包含请求体") + void generateCurlCommand_givenDeleteRequest_shouldNotIncludeBody() { + // arrange + when(request.getMethod()).thenReturn("DELETE"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/users/1"); + when(request.getQueryString()).thenReturn(null); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result) + .contains("curl -X DELETE") + .contains("'http://localhost:8080/api/users/1'") + .doesNotContain("-d"); + } + + @Test + @DisplayName("HTTPS 请求 - 标准端口 443") + void generateCurlCommand_givenHttpsRequestWithStandardPort_shouldNotIncludePort() { + // arrange + when(request.getMethod()).thenReturn("GET"); + when(request.getScheme()).thenReturn("https"); + when(request.getServerName()).thenReturn("api.example.com"); + when(request.getServerPort()).thenReturn(443); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn(null); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result).isEqualTo("curl -X GET 'https://api.example.com/api/users'"); + } + + @Test + @DisplayName("JSON 请求体为 null") + void generateCurlCommand_givenPostRequestWithNullJsonBody_shouldNotIncludeDataFlag() + throws IOException { + // arrange + when(request.getMethod()).thenReturn("POST"); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn(null); + when(request.getContentType()).thenReturn("application/json"); + when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); + when(request.getReader()).thenReturn(null); + + // action + String result = loggingAspect.generateCurlCommand(request); + + // assert + assertThat(result) + .contains("curl -X POST") + .contains("'http://localhost:8080/api/users'") + .doesNotContain("-d"); + } + } +} diff --git a/backend/src/test/java/com/zl/mjga/unit/LoggingAspectCurlGenerationTest_README.md b/backend/src/test/java/com/zl/mjga/unit/LoggingAspectCurlGenerationTest_README.md new file mode 100644 index 0000000..fd774e5 --- /dev/null +++ b/backend/src/test/java/com/zl/mjga/unit/LoggingAspectCurlGenerationTest_README.md @@ -0,0 +1,103 @@ +# LoggingAspect generateCurlCommand 方法单元测试 + +## 测试概述 + +本测试文件 `LoggingAspectCurlGenerationTest.java` 专门针对 `LoggingAspect` 类中的 `generateCurlCommand` 方法进行全面的单元测试,验证该方法在各种场景下生成 curl 命令的正确性。 + +## 测试架构 + +测试采用 **嵌套测试类** 的结构,按功能模块组织: + +### 1. GET 请求测试 (`GetRequestTests`) +- ✅ 基本 GET 请求 - 无查询参数 +- ✅ GET 请求 - 包含查询参数 +- ✅ GET 请求 - HTTPS 协议 +- ✅ GET 请求 - 自定义端口 + +### 2. POST 请求测试 (`PostRequestTests`) +- ✅ POST 请求 - JSON 请求体 +- ✅ POST 请求 - 空 JSON 请求体 +- ✅ POST 请求 - 包含单引号的 JSON + +### 3. PUT 和 PATCH 请求测试 (`PutAndPatchRequestTests`) +- ✅ PUT 请求 - JSON 请求体 +- ✅ PATCH 请求 - JSON 请求体 + +### 4. 表单数据请求测试 (`FormDataRequestTests`) +- ✅ POST 请求 - 表单数据 +- ✅ POST 请求 - 多值表单参数 +- ✅ POST 请求 - 空表单数据 + +### 5. 请求头处理测试 (`HeaderProcessingTests`) +- ✅ 包含常规请求头 +- ✅ 跳过特定请求头 +- ✅ 验证跳过的请求头不会出现在 curl 命令中 + +### 6. 异常情况测试 (`ExceptionHandlingTests`) +- ✅ 读取请求体时发生 IOException +- ✅ 请求参数为 null +- ✅ 请求方法为 null +- ✅ 服务器信息为 null + +### 7. 边界用例测试 (`BoundaryTests`) +- ✅ 最小化 GET 请求 +- ✅ 复杂查询参数 - 包含特殊字符 +- ✅ DELETE 请求 - 不应包含请求体 +- ✅ HTTPS 请求 - 标准端口 443 +- ✅ JSON 请求体为 null + +## 测试覆盖的功能点 + +### 核心功能验证 +1. **HTTP 方法处理**: GET, POST, PUT, PATCH, DELETE +2. **URL 构建**: 协议、主机名、端口、路径、查询参数 +3. **请求头处理**: 包含/排除特定请求头 +4. **请求体处理**: JSON、表单数据、空请求体 +5. **异常处理**: 各种异常情况的优雅处理 + +### 特殊场景验证 +1. **端口处理**: 标准端口省略,非标准端口包含 +2. **字符转义**: JSON 中的单引号转义 +3. **空值处理**: null 值的安全处理 +4. **多值参数**: 表单中同名参数的多个值 + +## 测试技术特点 + +### 使用的测试技术 +- **JUnit 5**: 现代化的测试框架 +- **Mockito**: Mock 对象和行为验证 +- **AssertJ**: 流畅的断言 API +- **嵌套测试**: 清晰的测试组织结构 + +### Mock 策略 +- Mock `HttpServletRequest` 对象模拟各种 HTTP 请求场景 +- Mock 依赖服务避免外部依赖 +- 精确控制测试数据和行为 + +### 断言策略 +- 验证生成的 curl 命令包含预期内容 +- 验证不应包含的内容确实被排除 +- 验证异常情况的错误消息 + +## 运行测试 + +```bash +# 运行所有 generateCurlCommand 相关测试 +./gradlew test --tests "com.zl.mjga.unit.LoggingAspectCurlGenerationTest" + +# 运行特定测试类别 +./gradlew test --tests "com.zl.mjga.unit.LoggingAspectCurlGenerationTest\$GetRequestTests" +``` + +## 测试价值 + +这套测试确保了 `generateCurlCommand` 方法在各种复杂场景下都能正确工作,为 AOP 日志功能的 curl 命令生成提供了可靠的质量保证。通过全面的测试覆盖,可以: + +1. **防止回归**: 代码修改时及时发现问题 +2. **文档作用**: 测试用例本身就是最好的使用文档 +3. **重构支持**: 安全地进行代码重构 +4. **质量保证**: 确保功能在各种边界条件下正常工作 + +## 测试结果 + +所有 **24 个测试用例** 均通过,覆盖了 `generateCurlCommand` 方法的所有主要功能和边界情况。 diff --git a/backend/src/test/resources/db/migration/test/V1_0_4__init_aop_log.sql b/backend/src/test/resources/db/migration/test/V1_0_4__init_aop_log.sql index d9f29ab..4ecf3ee 100644 --- a/backend/src/test/resources/db/migration/test/V1_0_4__init_aop_log.sql +++ b/backend/src/test/resources/db/migration/test/V1_0_4__init_aop_log.sql @@ -1,21 +1,20 @@ --- AOP日志表 CREATE TABLE mjga.aop_log ( id BIGSERIAL PRIMARY KEY, class_name VARCHAR NOT NULL, method_name VARCHAR NOT NULL, - method_args TEXT, - return_value TEXT, + method_args VARCHAR, + return_value VARCHAR, execution_time BIGINT NOT NULL, success BOOLEAN NOT NULL DEFAULT TRUE, - error_message TEXT, + error_message VARCHAR, user_id BIGINT, - ip_address VARCHAR(45), - user_agent TEXT, + ip_address VARCHAR, + user_agent VARCHAR, + curl VARCHAR, create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES mjga.user(id) ON DELETE SET NULL ); --- 创建索引以提高查询性能 CREATE INDEX idx_aop_log_class_name ON mjga.aop_log(class_name); CREATE INDEX idx_aop_log_method_name ON mjga.aop_log(method_name); CREATE INDEX idx_aop_log_create_time ON mjga.aop_log(create_time);