package com.ruoyi.system.utils; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.ruoyi.common.constant.CacheConstants; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.redis.RedisCache; import jakarta.servlet.http.HttpServletRequest; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.reflect.MethodSignature; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.lang.reflect.Method; import java.util.Date; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 单设备登录(2026-09-22)会话校验切面测试: * 新格式会话 token(jti 含冒号)必须存在 Redis 会话,被顶号/登出删除后即 401; * 无 Redis 会话的旧 token 和无 jti token 均拒绝。 */ class AuthAspectTest { private RedisCache redisCache; private HttpServletRequest request; private ProceedingJoinPoint joinPoint; private MethodSignature signature; private AuthAspect aspect; @BeforeEach void setUp() { redisCache = mock(RedisCache.class); request = mock(HttpServletRequest.class); joinPoint = mock(ProceedingJoinPoint.class); signature = mock(MethodSignature.class); aspect = new AuthAspect(redisCache); aspect.setHttpServletRequest(request); when(joinPoint.getSignature()).thenReturn(signature); } /** 手工签发新格式会话 token:jti 含冒号但不落 Redis,会话存在性由 redisCache 桩控制 */ private String colonJtiToken(String jti) { return JWT.create() .withClaim("id", "936") .withExpiresAt(new Date(System.currentTimeMillis() + 60_000)) .withJWTId(jti) .sign(Algorithm.HMAC256("TEST-AUTH-TOKEN")); } @Test void deletedSessionIsRejectedBeforeController() throws Throwable { String jti = CacheConstants.USER_TOKEN_KEY + "936:deleted-session"; prepareInvocation("endpoint", colonJtiToken(jti)); when(redisCache.hasKey(jti)).thenReturn(false); Object result = aspect.around(joinPoint); assertEquals(401, ((AjaxResult) result).get("code"), "会话被删(被顶号/登出)应返回 401"); verify(joinPoint, never()).proceed(); } @Test void liveSessionProceedsAndExposesTrustedIdentity() throws Throwable { String jti = CacheConstants.USER_TOKEN_KEY + "936:live-session"; prepareInvocation("endpoint", colonJtiToken(jti)); when(redisCache.hasKey(jti)).thenReturn(true); Object expected = new Object(); when(joinPoint.proceed()).thenReturn(expected); Object actual = aspect.around(joinPoint); assertSame(expected, actual); verify(request).setAttribute(AuthContext.USER_ID_ATTRIBUTE, 936L); verify(request).setAttribute(AuthContext.JTI_ATTRIBUTE, jti); } @Test void legacyNoColonJtiWithoutSessionIsRejected() throws Throwable { // 历史无会话重载 JwtUtil.token(id, userName) 签发:jti 为纯 UUID,不含冒号 prepareInvocation("endpoint", JwtUtil.token("936", "user")); Object actual = aspect.around(joinPoint); assertEquals(401, ((AjaxResult) actual).get("code")); verify(joinPoint, never()).proceed(); } @Test void tokenWithoutJtiIsRejected() throws Throwable { String token = JWT.create() .withClaim("id", "936") .withExpiresAt(new Date(System.currentTimeMillis() + 60_000)) .sign(Algorithm.HMAC256("TEST-AUTH-TOKEN")); prepareInvocation("endpoint", token); Object actual = aspect.around(joinPoint); assertEquals(401, ((AjaxResult) actual).get("code")); verify(redisCache, never()).hasKey(anyString()); verify(joinPoint, never()).proceed(); } @Test void invalidSignatureRejectedBeforeSessionLookup() throws Throwable { prepareInvocation("endpoint", "not-a-jwt"); Object result = aspect.around(joinPoint); assertEquals(401, ((AjaxResult) result).get("code")); verify(redisCache, never()).hasKey(anyString()); verify(joinPoint, never()).proceed(); } private void prepareInvocation(String methodName, String token) throws Exception { Method method = SecuredMethods.class.getDeclaredMethod(methodName); when(signature.getMethod()).thenReturn(method); when(request.getHeader("token")).thenReturn(token); } static class SecuredMethods { @Auth public void endpoint() { } } }