Просмотр исходного кода

test(tdd): S-cfc-10 auth.guest.jwtGuard 契约边界补充(OPTIONS预检/query-token/teacher-admin角色/LOGIN_REQUIRED 401)10 例全绿

- 背景:SC-G-06 实现+主用例(ScG06JwtGuardTest 19 例)已由 73e2f194e 合入 origin/cfclub;
  本提交补齐该键契约边界 RED 阶段缺口,与既有 39 例回归合计 49/49 通过。
- 新增 JwtInterceptorContractEdgeTest 10 例:
  1) OPTIONS 预检放行(受保护/公开路径均不鉴权、不写 401)
  2) query 参数 ?token= 获取路径:有效 token 放行注入 userId/role/familyId/currentMemberId;无效 token 统一 401 不泄露
  3) teacher 有效 token 放行 + 注入 role=teacher + ROLE_teacher 权限上下文(@PreAuthorize 用)
  4) admin 有效 token 放行 + 注入 role=admin + ROLE_admin 权限上下文
  5) LOGIN_REQUIRED_PATHS 族边界:/api/family/user/switch-role、/api/ai/health-coach/send、/api/auth/switch-role 无 token → 401;同一路径携有效 token → 放行
- 只新增 src/test 下与该键相关测试,不改 src/main(未暴露实现缺陷)
Agnes Agent 1 неделя назад
Родитель
Сommit
df2364ba3

+ 316 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/JwtInterceptorContractEdgeTest.java

@@ -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);
+    }
+}