fix: 完善操作日志与 Skill 审计
- 记录操作成功失败状态并保留原始业务异常 - Skill 写请求仅审计资源标识,避免正文和配置泄露
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
package tech.easyflow.log;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import jakarta.servlet.ReadListener;
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import tech.easyflow.log.entity.WriteLog;
|
||||
import tech.easyflow.log.mapper.WriteLogMapper;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link LogAspect} 操作结果与 Skill 标识审计测试。
|
||||
*/
|
||||
public class LogAspectTest {
|
||||
|
||||
/**
|
||||
* 清理线程绑定的请求上下文。
|
||||
*/
|
||||
@After
|
||||
public void tearDown() {
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证成功操作记录成功状态,且 Skill body 只保留资源标识。
|
||||
*
|
||||
* @throws Throwable 切面执行异常
|
||||
*/
|
||||
@Test
|
||||
public void successfulSkillActionShouldRecordIdentifiersOnly() throws Throwable {
|
||||
WriteLogMapper mapper = mock(WriteLogMapper.class);
|
||||
LogAspect aspect = new LogAspect(mapper, properties());
|
||||
HttpServletRequest request = request("""
|
||||
{"id":101,"resourceId":"202","skillId":303,"name":"secret-name","config":{"token":"secret"}}
|
||||
""");
|
||||
ProceedingJoinPoint joinPoint = joinPoint();
|
||||
Object expected = new Object();
|
||||
when(joinPoint.proceed()).thenReturn(expected);
|
||||
|
||||
try (MockedStatic<StpUtil> stp = Mockito.mockStatic(StpUtil.class)) {
|
||||
stp.when(StpUtil::isLogin).thenReturn(false);
|
||||
assertSame(expected, aspect.doAround(joinPoint));
|
||||
}
|
||||
|
||||
ArgumentCaptor<WriteLog> captor = ArgumentCaptor.forClass(WriteLog.class);
|
||||
verify(mapper).insert(captor.capture());
|
||||
WriteLog log = captor.getValue();
|
||||
assertEquals(Integer.valueOf(1), log.getStatus());
|
||||
assertEquals("{\"id\":101,\"resourceId\":\"202\",\"skillId\":303}", log.getActionBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证业务异常原样抛出,同时审计记录失败状态。
|
||||
*
|
||||
* @throws Throwable 预期业务异常
|
||||
*/
|
||||
@Test
|
||||
public void failedActionShouldKeepOriginalThrowableAndRecordFailure() throws Throwable {
|
||||
WriteLogMapper mapper = mock(WriteLogMapper.class);
|
||||
LogAspect aspect = new LogAspect(mapper, properties());
|
||||
request("{\"id\":101}");
|
||||
ProceedingJoinPoint joinPoint = joinPoint();
|
||||
IllegalStateException expected = new IllegalStateException("business failed");
|
||||
doThrow(expected).when(joinPoint).proceed();
|
||||
|
||||
Throwable actual = null;
|
||||
try (MockedStatic<StpUtil> stp = Mockito.mockStatic(StpUtil.class)) {
|
||||
stp.when(StpUtil::isLogin).thenReturn(false);
|
||||
try {
|
||||
aspect.doAround(joinPoint);
|
||||
} catch (Throwable throwable) {
|
||||
actual = throwable;
|
||||
}
|
||||
}
|
||||
|
||||
assertSame(expected, actual);
|
||||
ArgumentCaptor<WriteLog> captor = ArgumentCaptor.forClass(WriteLog.class);
|
||||
verify(mapper).insert(captor.capture());
|
||||
assertEquals(Integer.valueOf(9), captor.getValue().getStatus());
|
||||
assertEquals("{\"id\":101}", captor.getValue().getActionBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证日志落库异常不会覆盖已发生的业务异常。
|
||||
*
|
||||
* @throws Throwable 预期业务异常
|
||||
*/
|
||||
@Test
|
||||
public void loggingFailureShouldNotMaskBusinessFailure() throws Throwable {
|
||||
WriteLogMapper mapper = mock(WriteLogMapper.class);
|
||||
RuntimeException loggingFailure = new RuntimeException("log failed");
|
||||
when(mapper.insert(any(WriteLog.class))).thenThrow(loggingFailure);
|
||||
LogAspect aspect = new LogAspect(mapper, properties());
|
||||
request("{\"id\":101}");
|
||||
ProceedingJoinPoint joinPoint = joinPoint();
|
||||
IllegalArgumentException expected = new IllegalArgumentException("business failed");
|
||||
doThrow(expected).when(joinPoint).proceed();
|
||||
|
||||
Throwable actual = null;
|
||||
try (MockedStatic<StpUtil> stp = Mockito.mockStatic(StpUtil.class)) {
|
||||
stp.when(StpUtil::isLogin).thenReturn(false);
|
||||
try {
|
||||
aspect.doAround(joinPoint);
|
||||
} catch (Throwable throwable) {
|
||||
actual = throwable;
|
||||
}
|
||||
}
|
||||
|
||||
assertSame(expected, actual);
|
||||
assertEquals(1, actual.getSuppressed().length);
|
||||
assertSame(loggingFailure, actual.getSuppressed()[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证畸形 JSON 不会影响业务结果,且不会写入不可信正文。
|
||||
*
|
||||
* @throws Throwable 切面执行异常
|
||||
*/
|
||||
@Test
|
||||
public void malformedSkillBodyShouldNotAffectAction() throws Throwable {
|
||||
WriteLogMapper mapper = mock(WriteLogMapper.class);
|
||||
LogAspect aspect = new LogAspect(mapper, properties());
|
||||
request("{invalid-json");
|
||||
ProceedingJoinPoint joinPoint = joinPoint();
|
||||
when(joinPoint.proceed()).thenReturn("ok");
|
||||
|
||||
try (MockedStatic<StpUtil> stp = Mockito.mockStatic(StpUtil.class)) {
|
||||
stp.when(StpUtil::isLogin).thenReturn(false);
|
||||
assertEquals("ok", aspect.doAround(joinPoint));
|
||||
}
|
||||
|
||||
ArgumentCaptor<WriteLog> captor = ArgumentCaptor.forClass(WriteLog.class);
|
||||
verify(mapper).insert(captor.capture());
|
||||
assertEquals(Integer.valueOf(1), captor.getValue().getStatus());
|
||||
assertNull(captor.getValue().getActionBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试日志配置。
|
||||
*
|
||||
* @return 日志配置
|
||||
*/
|
||||
private LogRecordProperties properties() {
|
||||
LogRecordProperties properties = new LogRecordProperties();
|
||||
properties.setRecordActionPrefix("/api/v1");
|
||||
return properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并绑定测试请求。
|
||||
*
|
||||
* @param body JSON body
|
||||
* @return 测试请求
|
||||
* @throws IOException 输入流创建失败
|
||||
*/
|
||||
private HttpServletRequest request(String body) throws IOException {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getServletPath()).thenReturn("/api/v1/skill/update");
|
||||
when(request.getContentType()).thenReturn("application/json;charset=UTF-8");
|
||||
when(request.getCharacterEncoding()).thenReturn(StandardCharsets.UTF_8.name());
|
||||
when(request.getParameterNames()).thenReturn(Collections.emptyEnumeration());
|
||||
when(request.getRequestURL()).thenReturn(new StringBuffer("http://localhost/api/v1/skill/update"));
|
||||
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
|
||||
when(request.getInputStream()).thenReturn(inputStream(body));
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试连接点。
|
||||
*
|
||||
* @return 测试连接点
|
||||
* @throws NoSuchMethodException 测试方法不存在
|
||||
*/
|
||||
private ProceedingJoinPoint joinPoint() throws NoSuchMethodException {
|
||||
ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
|
||||
MethodSignature signature = mock(MethodSignature.class);
|
||||
Method method = TestController.class.getMethod("update");
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
when(signature.getDeclaringType()).thenReturn(TestController.class);
|
||||
when(signature.getMethod()).thenReturn(method);
|
||||
return joinPoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建基于字节数组的 Servlet 输入流。
|
||||
*
|
||||
* @param body 请求正文
|
||||
* @return Servlet 输入流
|
||||
*/
|
||||
private ServletInputStream inputStream(String body) {
|
||||
ByteArrayInputStream input = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
|
||||
return new ServletInputStream() {
|
||||
@Override
|
||||
public boolean isFinished() {
|
||||
return input.available() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadListener(ReadListener readListener) {
|
||||
// 同步测试输入流不需要异步读取监听。
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() {
|
||||
return input.read();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 Controller 签名载体。
|
||||
*/
|
||||
public static class TestController {
|
||||
|
||||
/**
|
||||
* 模拟更新入口。
|
||||
*
|
||||
* @return 空结果
|
||||
*/
|
||||
public Object update() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user