|
|
@@ -0,0 +1,303 @@
|
|
|
+package com.etotem.cfc.config;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.etotem.cfc.service.UserService;
|
|
|
+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 javax.servlet.http.HttpServletResponse;
|
|
|
+import java.util.Collections;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * SC-G-06 越权接口拦截 — 三类用例(对应 tests/test-cases/guest.md TC-G-06 / TC-G-06-E / TC-G-06-P)。
|
|
|
+ *
|
|
|
+ * 正常流 TC-G-06 :游客无 token 直调受保护接口 → 401,响应为统一 Result 结构,不泄露内部堆栈。
|
|
|
+ * 异常流 TC-G-06-E :缺 token / 过期&非法 token → 统一 401;公开白名单路径不被误拦,正常放行。
|
|
|
+ * 权限流 TC-G-06-P :横向越权(携带本人 token 访问他人 familyId)→ 403,不泄露他人数据。
|
|
|
+ *
|
|
|
+ * 纯单元测试:MockServlet 容器 + Mockito,不依赖数据库,保证可执行性与确定性。
|
|
|
+ */
|
|
|
+public class ScG06JwtGuardTest {
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private JwtConfig jwtConfig;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private UserMapper userMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private JwtInterceptor jwtInterceptor;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private UserService userService;
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private FamilyAccessInterceptor familyAccessInterceptor;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ public void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 正常流 TC-G-06:受保护接口无 token → 401,统一 JSON,不泄露内部结构
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testProtectedFamilyMemberListWithoutTokenIs401() throws Exception {
|
|
|
+ assertProtected401("/api/family/member/list");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testProtectedHealthReportListWithoutTokenIs401() throws Exception {
|
|
|
+ // 修复前:匿名 userId=-1 放行,返回 200 data:[](泄露结构)
|
|
|
+ assertProtected401("/api/health/report/list");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testProtectedTasksTodayWithoutTokenIs401() throws Exception {
|
|
|
+ // 修复前:匿名放行返回 200 data:[](泄露结构)
|
|
|
+ assertProtected401("/api/tasks/today");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testProtectedEnergySandboxWithoutTokenIs401() throws Exception {
|
|
|
+ // 修复前:匿名放行到 controller 返回 500「用户不存在」(泄露内部错误)
|
|
|
+ assertProtected401("/api/energy/sandbox");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testProtectedWishesListWithoutTokenIs401() throws Exception {
|
|
|
+ assertProtected401("/api/wishes/list");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testProtectedUserInfoWithoutTokenIs401() throws Exception {
|
|
|
+ // 修复前:匿名返回 200 data:null(泄露结构)
|
|
|
+ assertProtected401("/api/user/info");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testProtectedFamilyPlatformBalanceWithoutTokenIs401() throws Exception {
|
|
|
+ assertProtected401("/api/family-platform/balance");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testProtectedHealthCheckinListWithoutTokenIs401() throws Exception {
|
|
|
+ assertProtected401("/api/health/checkin/list");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 断言:无 token 访问受保护路径 → 拦截(false)+ HTTP 401 + 统一 Result 结构;
|
|
|
+ * 响应体唯一内容为 401 提示,绝不包含内部异常/堆栈(不泄露内部错误)。
|
|
|
+ */
|
|
|
+ private void assertProtected401(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 必须被拦截");
|
|
|
+ assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus(),
|
|
|
+ path + " 应返回 HTTP 401");
|
|
|
+ String body = response.getContentAsString();
|
|
|
+ assertTrue(body.contains("\"code\":401"), path + " 响应应含统一 code=401,实际: " + body);
|
|
|
+ assertTrue(body.contains("请先登录"), path + " 响应应含统一 message,实际: " + body);
|
|
|
+ assertFalse(body.contains("Exception"), path + " 响应不应泄露内部异常: " + body);
|
|
|
+ assertFalse(body.contains("at com.etotem"), path + " 响应不应泄露调用堆栈: " + body);
|
|
|
+ assertTrue(body.contains("\"data\":null"), path + " 响应应含统一 data:null,实际: " + body);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 异常流 TC-G-06-E:缺 token / 过期&非法 token → 401;公开路径不误拦
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testInvalidTokenOnProtectedPathIs401() throws Exception {
|
|
|
+ // 过期/被篡改 token:非公开受保护路径一律 401
|
|
|
+ when(jwtConfig.parseToken(anyString()))
|
|
|
+ .thenThrow(new io.jsonwebtoken.ExpiredJwtException(null, null, "token expired"));
|
|
|
+
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
+ request.setMethod("POST");
|
|
|
+ request.setRequestURI("/api/health/report/list");
|
|
|
+ request.addHeader("Authorization", "Bearer expired.token.here");
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
+
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
+
|
|
|
+ assertFalse(result);
|
|
|
+ assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
|
|
|
+ String body = response.getContentAsString();
|
|
|
+ assertTrue(body.contains("请先登录"));
|
|
|
+ assertFalse(body.contains("ExpiredJwtException"), "异常信息不得泄露到响应体: " + body);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testGarbageTokenOnProtectedPathIs401() throws Exception {
|
|
|
+ when(jwtConfig.parseToken(anyString())).thenThrow(new RuntimeException("jjwt parse failure"));
|
|
|
+
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
+ request.setMethod("POST");
|
|
|
+ request.setRequestURI("/api/tasks/today");
|
|
|
+ request.addHeader("Authorization", "Bearer not-a-jwt");
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
+
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
+
|
|
|
+ assertFalse(result);
|
|
|
+ assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
|
|
|
+ assertFalse(response.getContentAsString().contains("jjwt"), "内部异常不得泄露: " + response.getContentAsString());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testPublicProductListWithoutTokenNotBlocked() throws Exception {
|
|
|
+ assertPublicPassThrough("/api/product/list");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testPublicArticlesListWithoutTokenNotBlocked() throws Exception {
|
|
|
+ assertPublicPassThrough("/api/articles/list");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testPublicInviteValidateWithoutTokenNotBlocked() throws Exception {
|
|
|
+ // 邀请落地页校验令牌:游客注册前即可调用,属公开路径,不得误拦
|
|
|
+ assertPublicPassThrough("/api/family/invite/validate");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testPublicInviteValidateFamilyCodeWithoutTokenNotBlocked() throws Exception {
|
|
|
+ assertPublicPassThrough("/api/family/invite/validate-family-code");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testPublicAuthLoginWithoutTokenNotBlocked() throws Exception {
|
|
|
+ assertPublicPassThrough("/api/auth/wechat-login");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testPublicPathWithInvalidTokenDowngradedAnonymous() throws Exception {
|
|
|
+ // 公开路径带无效/过期 token:不 401、降级匿名放行,游客可继续浏览(既有契约保持)
|
|
|
+ when(jwtConfig.parseToken(anyString()))
|
|
|
+ .thenThrow(new io.jsonwebtoken.ExpiredJwtException(null, null, "expired"));
|
|
|
+
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
+ request.setMethod("POST");
|
|
|
+ request.setRequestURI("/api/product/list");
|
|
|
+ request.addHeader("Authorization", "Bearer expired.token");
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
+
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
+
|
|
|
+ assertTrue(result, "公开路径无效 token 应降级匿名放行");
|
|
|
+ assertEquals(HttpServletResponse.SC_OK, response.getStatus());
|
|
|
+ }
|
|
|
+
|
|
|
+ private void assertPublicPassThrough(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);
|
|
|
+
|
|
|
+ assertTrue(result, path + " 公开路径不应被拦截");
|
|
|
+ assertEquals(HttpServletResponse.SC_OK, response.getStatus(), path + " 不应写入 401/403");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 权限流 TC-G-06-P:横向越权 → 403;同家庭访问放行;不泄露他人数据
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testHorizontalPrivilegeEscalationIs403() throws Exception {
|
|
|
+ // 本人有效家庭集 {1},请求携带他人 familyId=999 → 403
|
|
|
+ Long userId = 100L;
|
|
|
+ User user = mockUser(userId, 1L, "parent");
|
|
|
+ when(userService.getEffectiveFamilyIds(userId)).thenReturn(Collections.singleton(1L));
|
|
|
+
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
+ request.setMethod("POST");
|
|
|
+ request.setRequestURI("/api/health/report/list");
|
|
|
+ request.setParameter("familyId", "999");
|
|
|
+ request.setAttribute("userId", userId);
|
|
|
+ request.setAttribute("role", "parent");
|
|
|
+ request.setAttribute("roles", Collections.singletonList("parent"));
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
+
|
|
|
+ boolean result = familyAccessInterceptor.preHandle(request, response, null);
|
|
|
+
|
|
|
+ assertFalse(result, "访问他人家庭数据应被拦截");
|
|
|
+ assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus(), "横向越权应返回 403");
|
|
|
+ String body = response.getContentAsString();
|
|
|
+ assertTrue(body.contains("无权访问"), "应返回统一 403 提示,实际: " + body);
|
|
|
+ assertTrue(body.contains("\"data\":null"), "越权响应 data 必须为 null(不携带业务数据),实际: " + body);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testHorizontalPrivilegeEscalationDoesNotLeakBodyData() throws Exception {
|
|
|
+ Long userId = 200L;
|
|
|
+ when(userService.getEffectiveFamilyIds(userId)).thenReturn(Collections.singleton(2L));
|
|
|
+
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
+ request.setMethod("POST");
|
|
|
+ request.setRequestURI("/api/tasks/today");
|
|
|
+ request.setParameter("familyId", "777");
|
|
|
+ request.setAttribute("userId", userId);
|
|
|
+ request.setAttribute("role", "child");
|
|
|
+ request.setAttribute("roles", Collections.singletonList("child"));
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
+
|
|
|
+ familyAccessInterceptor.preHandle(request, response, null);
|
|
|
+
|
|
|
+ String body = response.getContentAsString();
|
|
|
+ assertTrue(body.contains("\"code\":403"), "越权响应应含统一 code=403,实际: " + body);
|
|
|
+ assertTrue(body.contains("\"data\":null"), "越权响应 data 必须为 null(不携带任何业务数据),实际: " + body);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void testSameFamilyAccessIsAllowed() throws Exception {
|
|
|
+ Long userId = 300L;
|
|
|
+ User user = mockUser(userId, 1L, "parent");
|
|
|
+ when(userService.getEffectiveFamilyIds(userId)).thenReturn(Collections.singleton(1L));
|
|
|
+
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
+ request.setMethod("POST");
|
|
|
+ request.setRequestURI("/api/health/report/list");
|
|
|
+ request.setParameter("familyId", "1");
|
|
|
+ request.setAttribute("userId", userId);
|
|
|
+ request.setAttribute("role", "parent");
|
|
|
+ request.setAttribute("roles", Collections.singletonList("parent"));
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
+
|
|
|
+ boolean result = familyAccessInterceptor.preHandle(request, response, null);
|
|
|
+
|
|
|
+ assertTrue(result, "同家庭数据访问应放行");
|
|
|
+ assertEquals(HttpServletResponse.SC_OK, response.getStatus());
|
|
|
+ }
|
|
|
+
|
|
|
+ private User mockUser(Long id, Long familyId, String role) {
|
|
|
+ User user = new User();
|
|
|
+ user.setId(id);
|
|
|
+ user.setFamilyId(familyId);
|
|
|
+ user.setRole(role);
|
|
|
+ return user;
|
|
|
+ }
|
|
|
+}
|