|
@@ -0,0 +1,316 @@
|
|
|
|
|
+package com.etotem.cfc.config;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
|
|
+import io.jsonwebtoken.Claims;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.mockito.InjectMocks;
|
|
|
|
|
+import org.mockito.Mock;
|
|
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
|
|
+import org.springframework.mock.web.MockHttpServletRequest;
|
|
|
|
|
+import org.springframework.mock.web.MockHttpServletResponse;
|
|
|
|
|
+import org.springframework.security.core.Authentication;
|
|
|
|
|
+import org.springframework.security.core.GrantedAuthority;
|
|
|
|
|
+import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
|
|
+
|
|
|
|
|
+import javax.servlet.http.HttpServletResponse;
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.stream.Collectors;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * S-cfc-10 · auth.guest.jwtGuard — JwtInterceptor 契约边界补充测试(RED 阶段补齐)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>与 {@link ScG06JwtGuardTest}(TC-G-06 正常/异常/权限三类主用例)互补,专门覆盖
|
|
|
|
|
+ * JwtInterceptor 的关键边界与角色/权限上下文行为:
|
|
|
|
|
+ * <ol>
|
|
|
|
|
+ * <li><b>OPTIONS 预检放行</b>:CORS 预检请求不做鉴权,任何路径无 token 直接放行(true),不写 401;</li>
|
|
|
|
|
+ * <li><b>query 参数 token</b>:无 Authorization Header 时允许从 ?token= 读取 token(既有契约);</li>
|
|
|
|
|
+ * <li><b>teacher 有效 token</b>:放行并注入 role=teacher + ROLE_teacher 权限上下文(成长规划师角色适用);</li>
|
|
|
|
|
+ * <li><b>admin 有效 token</b>:放行并注入 role=admin + ROLE_admin 权限上下文(后台角色适用);</li>
|
|
|
|
|
+ * <li><b>LOGIN_REQUIRED_PATHS 边界</b>:无 token 访问 family-role-switch / ai-health-coach → 401;
|
|
|
|
|
+ * 携有效 token 访问同一路径 → 放行(有 token 时登录必需路径不再拦截);</li>
|
|
|
|
|
+ * </ol>
|
|
|
|
|
+ *
|
|
|
|
|
+ * 纯单元测试:MockServlet 容器 + Mockito,不依赖数据库/微信/外部服务。
|
|
|
|
|
+ */
|
|
|
|
|
+public class JwtInterceptorContractEdgeTest {
|
|
|
|
|
+
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private JwtConfig jwtConfig;
|
|
|
|
|
+
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private UserMapper userMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @InjectMocks
|
|
|
|
|
+ private JwtInterceptor jwtInterceptor;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ public void setUp() {
|
|
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
|
|
+ SecurityContextHolder.clearContext();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+ // 关键边界 1:OPTIONS 预检请求放行(不鉴权、不写 401)
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void optionsPreflight_受保护路径无token_直接放行() throws Exception {
|
|
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
|
|
+ request.setMethod("OPTIONS");
|
|
|
|
|
+ request.setRequestURI("/api/family/member/list");
|
|
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
|
|
+
|
|
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(result, "OPTIONS 预检必须放行(CORS 前置握手)");
|
|
|
|
|
+ assertEquals(HttpServletResponse.SC_OK, response.getStatus(), "OPTIONS 不应写 401/403");
|
|
|
|
|
+ assertTrue(response.getContentAsString().isEmpty(), "OPTIONS 不应写业务响应体");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void optionsPreflight_公开路径_直接放行() throws Exception {
|
|
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
|
|
+ request.setMethod("OPTIONS");
|
|
|
|
|
+ request.setRequestURI("/api/product/list");
|
|
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
|
|
+
|
|
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(result);
|
|
|
|
|
+ assertEquals(HttpServletResponse.SC_OK, response.getStatus());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+ // 关键边界 2:query 参数 token(无 Authorization Header 时从 ?token= 读取)
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void queryToken_有效token_放行并注入用户上下文() throws Exception {
|
|
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
|
|
+ request.setMethod("POST");
|
|
|
|
|
+ request.setRequestURI("/api/family/member/list");
|
|
|
|
|
+ // 无 Authorization Header,改用 query 参数 token
|
|
|
|
|
+ request.setParameter("token", "valid-query-token");
|
|
|
|
|
+
|
|
|
|
|
+ Claims claims = mock(Claims.class);
|
|
|
|
|
+ when(claims.getSubject()).thenReturn("7001");
|
|
|
|
|
+ when(claims.get("role")).thenReturn("parent");
|
|
|
|
|
+ when(claims.get("roles")).thenReturn(Collections.singletonList("parent"));
|
|
|
|
|
+ when(jwtConfig.parseToken("valid-query-token")).thenReturn(claims);
|
|
|
|
|
+
|
|
|
|
|
+ User user = new User();
|
|
|
|
|
+ user.setId(7001L);
|
|
|
|
|
+ user.setRole("parent");
|
|
|
|
|
+ user.setFamilyId(30001L);
|
|
|
|
|
+ when(userMapper.selectById(7001L)).thenReturn(user);
|
|
|
|
|
+
|
|
|
|
|
+ FamilyMember member = new FamilyMember();
|
|
|
|
|
+ member.setId(9999L);
|
|
|
|
|
+ member.setFamilyId(30001L);
|
|
|
|
|
+ member.setUserId(7001L);
|
|
|
|
|
+ when(familyMemberMapper.selectOne(any())).thenReturn(member);
|
|
|
|
|
+
|
|
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(result, "query token 有效时应放行");
|
|
|
|
|
+ assertEquals(7001L, request.getAttribute("userId"));
|
|
|
|
|
+ assertEquals("parent", request.getAttribute("role"));
|
|
|
|
|
+ assertEquals(30001L, request.getAttribute("familyId"));
|
|
|
|
|
+ assertEquals(9999L, request.getAttribute("currentMemberId"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void queryToken_无效token_统一401不泄露() throws Exception {
|
|
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
|
|
+ request.setMethod("POST");
|
|
|
|
|
+ request.setRequestURI("/api/health/report/list");
|
|
|
|
|
+ request.setParameter("token", "bad-query-token");
|
|
|
|
|
+
|
|
|
|
|
+ when(jwtConfig.parseToken("bad-query-token"))
|
|
|
|
|
+ .thenThrow(new RuntimeException("JWT compact of a plaintext string"));
|
|
|
|
|
+
|
|
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
|
|
+
|
|
|
|
|
+ assertFalse(result);
|
|
|
|
|
+ assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
|
|
|
|
|
+ assertTrue(response.getContentAsString().contains("请先登录"));
|
|
|
|
|
+ assertFalse(response.getContentAsString().contains("Exception"),
|
|
|
|
|
+ "异常信息不得泄露到响应体: " + response.getContentAsString());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+ // 角色/权限:teacher 有效 token → 放行 + ROLE_teacher 权限上下文
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void teacherToken_放行并注入teacher上下文() throws Exception {
|
|
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
|
|
+ request.setMethod("POST");
|
|
|
|
|
+ request.setRequestURI("/api/health/report/list");
|
|
|
|
|
+ request.addHeader("Authorization", "Bearer teacher-token");
|
|
|
|
|
+
|
|
|
|
|
+ Claims claims = mock(Claims.class);
|
|
|
|
|
+ when(claims.getSubject()).thenReturn("6001");
|
|
|
|
|
+ when(claims.get("role")).thenReturn("teacher");
|
|
|
|
|
+ when(claims.get("roles")).thenReturn(Collections.singletonList("teacher"));
|
|
|
|
|
+ when(jwtConfig.parseToken("teacher-token")).thenReturn(claims);
|
|
|
|
|
+
|
|
|
|
|
+ User user = new User();
|
|
|
|
|
+ user.setId(6001L);
|
|
|
|
|
+ user.setRole("teacher");
|
|
|
|
|
+ user.setFamilyId(60001L);
|
|
|
|
|
+ when(userMapper.selectById(6001L)).thenReturn(user);
|
|
|
|
|
+
|
|
|
|
|
+ FamilyMember member = new FamilyMember();
|
|
|
|
|
+ member.setId(6666L);
|
|
|
|
|
+ member.setFamilyId(60001L);
|
|
|
|
|
+ member.setUserId(6001L);
|
|
|
|
|
+ when(familyMemberMapper.selectOne(any())).thenReturn(member);
|
|
|
|
|
+
|
|
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(result, "teacher 有效 token 应放行");
|
|
|
|
|
+ assertEquals(6001L, request.getAttribute("userId"));
|
|
|
|
|
+ assertEquals("teacher", request.getAttribute("role"));
|
|
|
|
|
+ List<String> roles = (List<String>) request.getAttribute("roles");
|
|
|
|
|
+ assertTrue(roles.contains("teacher"), "roles 应含 teacher");
|
|
|
|
|
+
|
|
|
|
|
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
|
|
|
+ assertTrue(auth != null && hasAuthority(auth, "ROLE_teacher"),
|
|
|
|
|
+ "SecurityContext 应发布 ROLE_teacher 供 @PreAuthorize 使用");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+ // 角色/权限:admin 有效 token → 放行 + ROLE_admin 权限上下文
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void adminToken_放行并注入admin权限上下文() throws Exception {
|
|
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
|
|
+ request.setMethod("POST");
|
|
|
|
|
+ request.setRequestURI("/api/family-platform/balance");
|
|
|
|
|
+ request.addHeader("Authorization", "Bearer admin-token");
|
|
|
|
|
+
|
|
|
|
|
+ Claims claims = mock(Claims.class);
|
|
|
|
|
+ when(claims.getSubject()).thenReturn("8001");
|
|
|
|
|
+ when(claims.get("role")).thenReturn("admin");
|
|
|
|
|
+ when(claims.get("roles")).thenReturn(Collections.singletonList("admin"));
|
|
|
|
|
+ when(jwtConfig.parseToken("admin-token")).thenReturn(claims);
|
|
|
|
|
+
|
|
|
|
|
+ User user = new User();
|
|
|
|
|
+ user.setId(8001L);
|
|
|
|
|
+ user.setRole("admin");
|
|
|
|
|
+ user.setFamilyId(80001L);
|
|
|
|
|
+ when(userMapper.selectById(8001L)).thenReturn(user);
|
|
|
|
|
+
|
|
|
|
|
+ FamilyMember member = new FamilyMember();
|
|
|
|
|
+ member.setId(8008L);
|
|
|
|
|
+ member.setFamilyId(80001L);
|
|
|
|
|
+ member.setUserId(8001L);
|
|
|
|
|
+ when(familyMemberMapper.selectOne(any())).thenReturn(member);
|
|
|
|
|
+
|
|
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(result, "admin 有效 token 应放行");
|
|
|
|
|
+ assertEquals(8001L, request.getAttribute("userId"));
|
|
|
|
|
+ assertEquals("admin", request.getAttribute("role"));
|
|
|
|
|
+
|
|
|
|
|
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
|
|
|
+ assertTrue(auth != null && hasAuthority(auth, "ROLE_admin"),
|
|
|
|
|
+ "SecurityContext 应发布 ROLE_admin 供 @PreAuthorize 使用");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+ // 关键边界 3:LOGIN_REQUIRED_PATHS —— 无 token 401;有效 token 放行
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void loginRequiredPath_无token_401() throws Exception {
|
|
|
|
|
+ // family-side 角色切换(SEC-20261031-01 强制登录族)
|
|
|
|
|
+ assertLoginRequired401("/api/family/user/switch-role");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void loginRequiredAiHealthCoach_无token_401() throws Exception {
|
|
|
|
|
+ // AI 健康教练:耗 AI 配额 + 家庭上下文,必须登录
|
|
|
|
|
+ assertLoginRequired401("/api/ai/health-coach/send");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void loginRequiredAuthSwitchRole_无token_401() throws Exception {
|
|
|
|
|
+ // 角色切换(可重签 token),必须登录
|
|
|
|
|
+ assertLoginRequired401("/api/auth/switch-role");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void loginRequiredPath_有效token_放行() throws Exception {
|
|
|
|
|
+ // 登录必需路径携有效 token 时不再拦截(401 仅针对无 token/无效 token)
|
|
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
|
|
+ request.setMethod("POST");
|
|
|
|
|
+ request.setRequestURI("/api/family/user/switch-role");
|
|
|
|
|
+ request.addHeader("Authorization", "Bearer valid-token");
|
|
|
|
|
+
|
|
|
|
|
+ Claims claims = mock(Claims.class);
|
|
|
|
|
+ when(claims.getSubject()).thenReturn("9001");
|
|
|
|
|
+ when(claims.get("role")).thenReturn("parent");
|
|
|
|
|
+ when(claims.get("roles")).thenReturn(Collections.singletonList("parent"));
|
|
|
|
|
+ when(jwtConfig.parseToken("valid-token")).thenReturn(claims);
|
|
|
|
|
+
|
|
|
|
|
+ User user = new User();
|
|
|
|
|
+ user.setId(9001L);
|
|
|
|
|
+ user.setRole("parent");
|
|
|
|
|
+ user.setFamilyId(90001L);
|
|
|
|
|
+ when(userMapper.selectById(9001L)).thenReturn(user);
|
|
|
|
|
+
|
|
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(result, "LOGIN_REQUIRED 路径携有效 token 应放行");
|
|
|
|
|
+ assertEquals(9001L, request.getAttribute("userId"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+ // 私有 helper
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+
|
|
|
|
|
+ private void assertLoginRequired401(String path) throws Exception {
|
|
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
|
|
+ request.setMethod("POST");
|
|
|
|
|
+ request.setRequestURI(path);
|
|
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
|
|
+
|
|
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
|
|
+
|
|
|
|
|
+ assertFalse(result, path + " 无 token 必须被拦(LOGIN_REQUIRED)");
|
|
|
|
|
+ assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), path + " 应返回 401");
|
|
|
|
|
+ String body = response.getContentAsString();
|
|
|
|
|
+ assertTrue(body.contains("请先登录"), path + " 应返回统一 401 提示: " + body);
|
|
|
|
|
+ assertFalse(body.contains("Exception"), path + " 不应泄露内部异常: " + body);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean hasAuthority(Authentication auth, String role) {
|
|
|
|
|
+ return auth.getAuthorities().stream()
|
|
|
|
|
+ .map(GrantedAuthority::getAuthority)
|
|
|
|
|
+ .collect(Collectors.toList())
|
|
|
|
|
+ .contains(role);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|