test: add comprehensive unit tests for operation log feature
- Add IpUtilsTest with 9 test cases covering all IP extraction scenarios - Add OperationLogAspectTest with 8 test cases covering all aspect behaviors - Fix error handling in OperationLogAspect to prevent log failures from affecting main flow - Add onErrorResume handlers for graceful degradation - Ensure all tests pass (17/17, 100% pass rate) Test Coverage: - IP extraction from various sources (X-Forwarded-For, X-Real-IP, RemoteAddress) - IPv6 to IPv4 conversion - Reactive type support (Mono, Flux) - Error handling and graceful degradation - Parameter serialization and truncation - Edge cases and boundary conditions
This commit is contained in:
+4
@@ -47,11 +47,13 @@ public class OperationLogAspect {
|
||||
.flatMap(res -> {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
return saveLogAsync(operationLogAnnotation, username, ip, method, params, res, duration, "0", null)
|
||||
.onErrorResume(e -> Mono.empty())
|
||||
.thenReturn(res);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
return saveLogAsync(operationLogAnnotation, username, ip, method, params, null, duration, "1", error.getMessage())
|
||||
.onErrorResume(e -> Mono.empty())
|
||||
.then(Mono.error(error));
|
||||
})
|
||||
);
|
||||
@@ -62,11 +64,13 @@ public class OperationLogAspect {
|
||||
.flatMapMany(res -> {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
return saveLogAsync(operationLogAnnotation, username, ip, method, params, res, duration, "0", null)
|
||||
.onErrorResume(e -> Mono.empty())
|
||||
.thenMany(Flux.fromIterable(res));
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
return saveLogAsync(operationLogAnnotation, username, ip, method, params, null, duration, "1", error.getMessage())
|
||||
.onErrorResume(e -> Mono.empty())
|
||||
.thenMany(Flux.error(error));
|
||||
})
|
||||
);
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
package cn.novalon.manage.sys.audit;
|
||||
|
||||
import cn.novalon.manage.sys.core.domain.OperationLog;
|
||||
import cn.novalon.manage.sys.core.service.IOperationLogService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.Signature;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* OperationLogAspect 单元测试
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-04-03
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OperationLogAspectTest {
|
||||
|
||||
@Mock
|
||||
private IOperationLogService logService;
|
||||
|
||||
@Mock
|
||||
private ProceedingJoinPoint joinPoint;
|
||||
|
||||
@Mock
|
||||
private Signature signature;
|
||||
|
||||
@Mock
|
||||
private ServerRequest serverRequest;
|
||||
|
||||
@Mock
|
||||
private ServerRequest.Headers headers;
|
||||
|
||||
private OperationLogAspect aspect;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper();
|
||||
aspect = new OperationLogAspect(logService, objectMapper);
|
||||
|
||||
// 默认mock行为
|
||||
lenient().when(serverRequest.headers()).thenReturn(headers);
|
||||
lenient().when(headers.firstHeader(any())).thenReturn(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当方法返回Mono成功时,应保存操作日志")
|
||||
void around_whenMonoSuccess_shouldSaveLog() throws Throwable {
|
||||
cn.novalon.manage.sys.audit.OperationLog annotation = createTestAnnotation("创建用户", "用户管理");
|
||||
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
when(signature.toShortString()).thenReturn("SysUserHandler.createUser");
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[]{serverRequest});
|
||||
when(joinPoint.proceed()).thenReturn(Mono.just("success"));
|
||||
when(logService.save(any(OperationLog.class))).thenReturn(Mono.just(new OperationLog()));
|
||||
|
||||
Object result = aspect.around(joinPoint, annotation);
|
||||
|
||||
StepVerifier.create((Mono<?>) result)
|
||||
.expectNextMatches(obj -> "success".equals(obj))
|
||||
.verifyComplete();
|
||||
|
||||
verify(logService, timeout(1000)).save(any(OperationLog.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当方法返回Mono失败时,应保存错误日志")
|
||||
void around_whenMonoError_shouldSaveErrorLog() throws Throwable {
|
||||
cn.novalon.manage.sys.audit.OperationLog annotation = createTestAnnotation("删除用户", "用户管理");
|
||||
RuntimeException testError = new RuntimeException("删除失败");
|
||||
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
when(signature.toShortString()).thenReturn("SysUserHandler.deleteUser");
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[]{serverRequest});
|
||||
when(joinPoint.proceed()).thenReturn(Mono.error(testError));
|
||||
when(logService.save(any(OperationLog.class))).thenReturn(Mono.just(new OperationLog()));
|
||||
|
||||
Object result = aspect.around(joinPoint, annotation);
|
||||
|
||||
StepVerifier.create((Mono<?>) result)
|
||||
.expectError(RuntimeException.class)
|
||||
.verify();
|
||||
|
||||
verify(logService, timeout(1000)).save(argThat(log ->
|
||||
"1".equals(log.getStatus()) && "删除失败".equals(log.getErrorMsg())
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当方法返回Flux成功时,应保存操作日志")
|
||||
void around_whenFluxSuccess_shouldSaveLog() throws Throwable {
|
||||
cn.novalon.manage.sys.audit.OperationLog annotation = createTestAnnotation("查询用户列表", "用户管理");
|
||||
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
when(signature.toShortString()).thenReturn("SysUserHandler.listUsers");
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[]{serverRequest});
|
||||
when(joinPoint.proceed()).thenReturn(Flux.just("user1", "user2", "user3"));
|
||||
when(logService.save(any(OperationLog.class))).thenReturn(Mono.just(new OperationLog()));
|
||||
|
||||
Object result = aspect.around(joinPoint, annotation);
|
||||
|
||||
StepVerifier.create((Flux<?>) result)
|
||||
.expectNextMatches(obj -> "user1".equals(obj))
|
||||
.expectNextMatches(obj -> "user2".equals(obj))
|
||||
.expectNextMatches(obj -> "user3".equals(obj))
|
||||
.verifyComplete();
|
||||
|
||||
verify(logService, timeout(1000)).save(any(OperationLog.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当方法抛出异常时,应保存错误日志并重新抛出")
|
||||
void around_whenMethodThrowsException_shouldSaveLogAndRethrow() throws Throwable {
|
||||
cn.novalon.manage.sys.audit.OperationLog annotation = createTestAnnotation("更新用户", "用户管理");
|
||||
RuntimeException testError = new RuntimeException("更新失败");
|
||||
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
when(signature.toShortString()).thenReturn("SysUserHandler.updateUser");
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[]{serverRequest});
|
||||
when(joinPoint.proceed()).thenThrow(testError);
|
||||
when(logService.save(any(OperationLog.class))).thenReturn(Mono.just(new OperationLog()));
|
||||
|
||||
assertThrows(RuntimeException.class, () -> {
|
||||
aspect.around(joinPoint, annotation);
|
||||
});
|
||||
|
||||
verify(logService, timeout(1000)).save(argThat(log ->
|
||||
"1".equals(log.getStatus()) && "更新失败".equals(log.getErrorMsg())
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当参数过大时,应截断参数")
|
||||
void around_whenParamsTooLarge_shouldTruncate() throws Throwable {
|
||||
cn.novalon.manage.sys.audit.OperationLog annotation = createTestAnnotation("创建用户", "用户管理");
|
||||
|
||||
StringBuilder largeParam = new StringBuilder();
|
||||
for (int i = 0; i < 3000; i++) {
|
||||
largeParam.append("a");
|
||||
}
|
||||
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
when(signature.toShortString()).thenReturn("SysUserHandler.createUser");
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[]{largeParam.toString()});
|
||||
when(joinPoint.proceed()).thenReturn(Mono.just("success"));
|
||||
when(logService.save(any(OperationLog.class))).thenReturn(Mono.just(new OperationLog()));
|
||||
|
||||
Object result = aspect.around(joinPoint, annotation);
|
||||
|
||||
StepVerifier.create((Mono<?>) result)
|
||||
.expectNextMatches(obj -> "success".equals(obj))
|
||||
.verifyComplete();
|
||||
|
||||
verify(logService, timeout(1000)).save(argThat(log -> {
|
||||
String params = log.getParams();
|
||||
return params != null && params.contains("truncated");
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当没有ServerRequest参数时,IP应为unknown")
|
||||
void around_whenNoServerRequest_shouldUseUnknownIp() throws Throwable {
|
||||
cn.novalon.manage.sys.audit.OperationLog annotation = createTestAnnotation("创建用户", "用户管理");
|
||||
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
when(signature.toShortString()).thenReturn("SysUserHandler.createUser");
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[]{"param1", "param2"});
|
||||
when(joinPoint.proceed()).thenReturn(Mono.just("success"));
|
||||
when(logService.save(any(OperationLog.class))).thenReturn(Mono.just(new OperationLog()));
|
||||
|
||||
Object result = aspect.around(joinPoint, annotation);
|
||||
|
||||
StepVerifier.create((Mono<?>) result)
|
||||
.expectNextMatches(obj -> "success".equals(obj))
|
||||
.verifyComplete();
|
||||
|
||||
verify(logService, timeout(1000)).save(argThat(log ->
|
||||
"unknown".equals(log.getIp())
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当日志保存失败时,不应影响主流程")
|
||||
void around_whenLogSaveFails_shouldNotAffectMainFlow() throws Throwable {
|
||||
cn.novalon.manage.sys.audit.OperationLog annotation = createTestAnnotation("创建用户", "用户管理");
|
||||
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
when(signature.toShortString()).thenReturn("SysUserHandler.createUser");
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[]{serverRequest});
|
||||
when(joinPoint.proceed()).thenReturn(Mono.just("success"));
|
||||
when(logService.save(any(OperationLog.class))).thenReturn(Mono.error(new RuntimeException("数据库错误")));
|
||||
|
||||
Object result = aspect.around(joinPoint, annotation);
|
||||
|
||||
StepVerifier.create((Mono<?>) result)
|
||||
.expectNextMatches(obj -> "success".equals(obj))
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当方法返回非响应式类型时,应直接返回")
|
||||
void around_whenNonReactiveResult_shouldReturnDirectly() throws Throwable {
|
||||
cn.novalon.manage.sys.audit.OperationLog annotation = createTestAnnotation("同步操作", "测试模块");
|
||||
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
when(signature.toShortString()).thenReturn("TestHandler.syncOperation");
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[]{});
|
||||
when(joinPoint.proceed()).thenReturn("sync-result");
|
||||
|
||||
Object result = aspect.around(joinPoint, annotation);
|
||||
|
||||
assertEquals("sync-result", result);
|
||||
verify(logService, never()).save(any());
|
||||
}
|
||||
|
||||
private cn.novalon.manage.sys.audit.OperationLog createTestAnnotation(String operation, String module) {
|
||||
return new cn.novalon.manage.sys.audit.OperationLog() {
|
||||
@Override
|
||||
public String operation() {
|
||||
return operation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String module() {
|
||||
return module;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends java.lang.annotation.Annotation> annotationType() {
|
||||
return cn.novalon.manage.sys.audit.OperationLog.class;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package cn.novalon.manage.sys.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* IpUtils 单元测试
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-04-03
|
||||
*/
|
||||
class IpUtilsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("当request为null时,应返回unknown")
|
||||
void getClientIp_whenRequestIsNull_shouldReturnUnknown() {
|
||||
String ip = IpUtils.getClientIp(null);
|
||||
assertEquals("unknown", ip);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当X-Forwarded-For头存在时,应返回第一个IP")
|
||||
void getClientIp_whenXForwardedForExists_shouldReturnFirstIp() {
|
||||
ServerRequest request = mock(ServerRequest.class);
|
||||
ServerRequest.Headers headers = mock(ServerRequest.Headers.class);
|
||||
|
||||
when(request.headers()).thenReturn(headers);
|
||||
when(headers.firstHeader("X-Forwarded-For")).thenReturn("192.168.1.100, 10.0.0.1");
|
||||
when(request.remoteAddress()).thenReturn(Optional.empty());
|
||||
|
||||
String ip = IpUtils.getClientIp(request);
|
||||
assertEquals("192.168.1.100", ip);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当X-Forwarded-For为单个IP时,应直接返回")
|
||||
void getClientIp_whenXForwardedForSingleIp_shouldReturnIt() {
|
||||
ServerRequest request = mock(ServerRequest.class);
|
||||
ServerRequest.Headers headers = mock(ServerRequest.Headers.class);
|
||||
|
||||
when(request.headers()).thenReturn(headers);
|
||||
when(headers.firstHeader("X-Forwarded-For")).thenReturn("192.168.1.100");
|
||||
when(request.remoteAddress()).thenReturn(Optional.empty());
|
||||
|
||||
String ip = IpUtils.getClientIp(request);
|
||||
assertEquals("192.168.1.100", ip);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当X-Forwarded-For为unknown时,应检查X-Real-IP")
|
||||
void getClientIp_whenXForwardedForIsUnknown_shouldCheckXRealIp() {
|
||||
ServerRequest request = mock(ServerRequest.class);
|
||||
ServerRequest.Headers headers = mock(ServerRequest.Headers.class);
|
||||
|
||||
when(request.headers()).thenReturn(headers);
|
||||
when(headers.firstHeader("X-Forwarded-For")).thenReturn("unknown");
|
||||
when(headers.firstHeader("X-Real-IP")).thenReturn("192.168.1.200");
|
||||
when(request.remoteAddress()).thenReturn(Optional.empty());
|
||||
|
||||
String ip = IpUtils.getClientIp(request);
|
||||
assertEquals("192.168.1.200", ip);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当X-Real-IP存在时,应返回该IP")
|
||||
void getClientIp_whenXRealIpExists_shouldReturnIt() {
|
||||
ServerRequest request = mock(ServerRequest.class);
|
||||
ServerRequest.Headers headers = mock(ServerRequest.Headers.class);
|
||||
|
||||
when(request.headers()).thenReturn(headers);
|
||||
when(headers.firstHeader("X-Forwarded-For")).thenReturn(null);
|
||||
when(headers.firstHeader("X-Real-IP")).thenReturn("192.168.1.200");
|
||||
when(request.remoteAddress()).thenReturn(Optional.empty());
|
||||
|
||||
String ip = IpUtils.getClientIp(request);
|
||||
assertEquals("192.168.1.200", ip);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当没有代理头时,应使用RemoteAddress")
|
||||
void getClientIp_whenNoProxyHeaders_shouldUseRemoteAddress() {
|
||||
ServerRequest request = mock(ServerRequest.class);
|
||||
ServerRequest.Headers headers = mock(ServerRequest.Headers.class);
|
||||
InetSocketAddress socketAddress = mock(InetSocketAddress.class);
|
||||
java.net.InetAddress inetAddress = mock(java.net.InetAddress.class);
|
||||
|
||||
when(request.headers()).thenReturn(headers);
|
||||
when(headers.firstHeader("X-Forwarded-For")).thenReturn(null);
|
||||
when(headers.firstHeader("X-Real-IP")).thenReturn(null);
|
||||
when(request.remoteAddress()).thenReturn(Optional.of(socketAddress));
|
||||
when(socketAddress.getAddress()).thenReturn(inetAddress);
|
||||
when(inetAddress.getHostAddress()).thenReturn("192.168.1.50");
|
||||
|
||||
String ip = IpUtils.getClientIp(request);
|
||||
assertEquals("192.168.1.50", ip);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当RemoteAddress为IPv6本地地址时,应转换为IPv4")
|
||||
void getClientIp_whenRemoteAddressIsIpv6Localhost_shouldConvertToIpv4() {
|
||||
ServerRequest request = mock(ServerRequest.class);
|
||||
ServerRequest.Headers headers = mock(ServerRequest.Headers.class);
|
||||
InetSocketAddress socketAddress = mock(InetSocketAddress.class);
|
||||
java.net.InetAddress inetAddress = mock(java.net.InetAddress.class);
|
||||
|
||||
when(request.headers()).thenReturn(headers);
|
||||
when(headers.firstHeader("X-Forwarded-For")).thenReturn(null);
|
||||
when(headers.firstHeader("X-Real-IP")).thenReturn(null);
|
||||
when(request.remoteAddress()).thenReturn(Optional.of(socketAddress));
|
||||
when(socketAddress.getAddress()).thenReturn(inetAddress);
|
||||
when(inetAddress.getHostAddress()).thenReturn("0:0:0:0:0:0:0:1");
|
||||
|
||||
String ip = IpUtils.getClientIp(request);
|
||||
assertEquals("127.0.0.1", ip);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当所有IP源都不可用时,应返回unknown")
|
||||
void getClientIp_whenAllSourcesFail_shouldReturnUnknown() {
|
||||
ServerRequest request = mock(ServerRequest.class);
|
||||
ServerRequest.Headers headers = mock(ServerRequest.Headers.class);
|
||||
|
||||
when(request.headers()).thenReturn(headers);
|
||||
when(headers.firstHeader("X-Forwarded-For")).thenReturn(null);
|
||||
when(headers.firstHeader("X-Real-IP")).thenReturn(null);
|
||||
when(request.remoteAddress()).thenReturn(Optional.empty());
|
||||
|
||||
String ip = IpUtils.getClientIp(request);
|
||||
assertEquals("unknown", ip);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当X-Forwarded-For为空字符串时,应跳过")
|
||||
void getClientIp_whenXForwardedForIsEmpty_shouldSkip() {
|
||||
ServerRequest request = mock(ServerRequest.class);
|
||||
ServerRequest.Headers headers = mock(ServerRequest.Headers.class);
|
||||
|
||||
when(request.headers()).thenReturn(headers);
|
||||
when(headers.firstHeader("X-Forwarded-For")).thenReturn("");
|
||||
when(headers.firstHeader("X-Real-IP")).thenReturn("192.168.1.200");
|
||||
when(request.remoteAddress()).thenReturn(Optional.empty());
|
||||
|
||||
String ip = IpUtils.getClientIp(request);
|
||||
assertEquals("192.168.1.200", ip);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user