|
|
@@ -0,0 +1,270 @@
|
|
|
+package com.etotem.cfc.controller;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.config.JwtInterceptor;
|
|
|
+import com.etotem.cfc.config.JwtConfig;
|
|
|
+import com.etotem.cfc.controller.auth.AuthController;
|
|
|
+import com.etotem.cfc.dto.LoginResultDTO;
|
|
|
+import com.etotem.cfc.dto.WechatLoginDTO;
|
|
|
+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 org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import java.lang.reflect.Method;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.*;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * SC-G-02 微信一键登录 — 契约级单测(能力键 {@code auth.guest.wechatLogin})。
|
|
|
+ *
|
|
|
+ * <p>对应 {@code tests/user-scenarios/01-guest.md} 场景 G-2 与 {@code tests/test-cases/guest.md}:
|
|
|
+ * <ul>
|
|
|
+ * <li><b>TC-G-02 正常流</b>:{@code POST /api/auth/wechat-login} → {@code Result} 含 token + 用户信息;
|
|
|
+ * 角色默认 {@code parent};首次登录建号({@code isNewUser=true})。</li>
|
|
|
+ * <li><b>TC-G-02a 边界</b>:同一微信二次登录应返回已有账号,不重复建号({@code isNewUser=false})。</li>
|
|
|
+ * <li><b>TC-G-02-E 异常流</b>:微信授权失败 / 服务异常 → {@code Result.error}(code=500),
|
|
|
+ * 只透传业务 message,绝不泄露内部堆栈/异常类名。</li>
|
|
|
+ * <li><b>TC-G-02-P 权限流</b>:登录端点在 {@code JwtInterceptor} 公开白名单内,游客无 token
|
|
|
+ * 可正常触达(匿名放行、不误拦 401)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p>纯单元测试:Mockito 直接调用 handler + {@code MockHttpServletRequest/Response},
|
|
|
+ * 不依赖数据库 / 真实微信 / Spring 容器,保证可执行性与确定性。
|
|
|
+ */
|
|
|
+public class AuthWechatLoginContractTest {
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private UserService userService;
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private AuthController controller;
|
|
|
+
|
|
|
+ // ---- JwtInterceptor(权限流:公开白名单放行)----
|
|
|
+ @Mock
|
|
|
+ private JwtConfig jwtConfig;
|
|
|
+ @Mock
|
|
|
+ private UserMapper userMapper;
|
|
|
+ @Mock
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+ @InjectMocks
|
|
|
+ private JwtInterceptor jwtInterceptor;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ public void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 正常流 TC-G-02:登录成功 → Result 含 token + 用户信息(角色默认 parent)
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void wechatLogin_Success_ReturnsTokenAndUserInfo() {
|
|
|
+ WechatLoginDTO dto = new WechatLoginDTO();
|
|
|
+ dto.setCode("wx-code");
|
|
|
+
|
|
|
+ LoginResultDTO login = new LoginResultDTO();
|
|
|
+ login.setToken("jwt-token-abc");
|
|
|
+ login.setUserId(88L);
|
|
|
+ login.setRole("parent");
|
|
|
+ login.setFamilyId(1001L);
|
|
|
+ login.setNickname("微信用户");
|
|
|
+ login.setPhone("13800000001");
|
|
|
+ login.setIsNewUser(true);
|
|
|
+ login.setOpenid("openid-abc");
|
|
|
+ login.setNeedsRoleSelect(true);
|
|
|
+ login.setNeedsFamily(false);
|
|
|
+ when(userService.wechatLogin(dto)).thenReturn(login);
|
|
|
+
|
|
|
+ Result<LoginResultDTO> result = controller.wechatLogin(dto);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode(), "正常登录应返回 code=200");
|
|
|
+ assertEquals("success", result.getMessage());
|
|
|
+ assertNotNull(result.getData(), "应返回 LoginResultDTO");
|
|
|
+ assertEquals("jwt-token-abc", result.getData().getToken());
|
|
|
+ assertEquals(88L, result.getData().getUserId());
|
|
|
+ assertEquals("parent", result.getData().getRole(), "角色应默认 parent");
|
|
|
+ assertEquals(1001L, result.getData().getFamilyId());
|
|
|
+ assertEquals("微信用户", result.getData().getNickname());
|
|
|
+ assertEquals("13800000001", result.getData().getPhone());
|
|
|
+ assertTrue(result.getData().getIsNewUser(), "首次登录应 isNewUser=true");
|
|
|
+ assertEquals("openid-abc", result.getData().getOpenid());
|
|
|
+ assertTrue(result.getData().getNeedsRoleSelect(), "新用户应 needsRoleSelect=true");
|
|
|
+ assertFalse(result.getData().getNeedsFamily(), "已绑定家庭则 needsFamily=false");
|
|
|
+ verify(userService, times(1)).wechatLogin(dto);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void wechatLogin_NewUser_IsDefaultParentAndNeedsRoleSelect() {
|
|
|
+ // TC-G-02 契约:新用户默认成为家长(role=parent),需选择角色
|
|
|
+ WechatLoginDTO dto = new WechatLoginDTO();
|
|
|
+ dto.setCode("wx-code");
|
|
|
+
|
|
|
+ LoginResultDTO login = new LoginResultDTO();
|
|
|
+ login.setRole("parent");
|
|
|
+ login.setIsNewUser(true);
|
|
|
+ login.setNeedsRoleSelect(true);
|
|
|
+ when(userService.wechatLogin(dto)).thenReturn(login);
|
|
|
+
|
|
|
+ Result<LoginResultDTO> result = controller.wechatLogin(dto);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ assertEquals("parent", result.getData().getRole());
|
|
|
+ assertTrue(result.getData().getIsNewUser());
|
|
|
+ assertTrue(result.getData().getNeedsRoleSelect());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 边界 TC-G-02a:同一微信二次登录返回已有账号,不重复建号(isNewUser=false)
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void wechatLogin_ExistingOpenid_ReturnsExistingAccountNoNewCreation() {
|
|
|
+ WechatLoginDTO dto = new WechatLoginDTO();
|
|
|
+ dto.setCode("wx-code");
|
|
|
+
|
|
|
+ LoginResultDTO login = new LoginResultDTO();
|
|
|
+ login.setUserId(88L);
|
|
|
+ login.setRole("parent");
|
|
|
+ login.setIsNewUser(false);
|
|
|
+ login.setNeedsRoleSelect(false);
|
|
|
+ login.setOpenid("openid-abc");
|
|
|
+ when(userService.wechatLogin(dto)).thenReturn(login);
|
|
|
+
|
|
|
+ Result<LoginResultDTO> result = controller.wechatLogin(dto);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ assertEquals(88L, result.getData().getUserId(), "应返回已有账号的 userId,不新建");
|
|
|
+ assertFalse(result.getData().getIsNewUser(), "老用户重登 isNewUser 必须为 false");
|
|
|
+ assertFalse(result.getData().getNeedsRoleSelect(), "老用户不再要求选择角色");
|
|
|
+ assertEquals("openid-abc", result.getData().getOpenid());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 异常流 TC-G-02-E:微信授权失败/服务异常 → Result.error,不泄露内部堆栈
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void wechatLogin_ServiceThrowsRuntimeException_ReturnsBusinessError() {
|
|
|
+ WechatLoginDTO dto = new WechatLoginDTO();
|
|
|
+ dto.setCode("bad-code");
|
|
|
+
|
|
|
+ when(userService.wechatLogin(dto)).thenThrow(new RuntimeException("微信登录失败"));
|
|
|
+
|
|
|
+ Result<LoginResultDTO> result = controller.wechatLogin(dto);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode(), "服务异常应映射为 code=500");
|
|
|
+ assertEquals("微信登录失败", result.getMessage(), "应透传业务 message,不吞掉");
|
|
|
+ assertNull(result.getData(), "异常时不携带业务数据");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void wechatLogin_ServiceThrows_ErrorMessageDoesNotLeakStack() {
|
|
|
+ WechatLoginDTO dto = new WechatLoginDTO();
|
|
|
+ dto.setCode("bad-code");
|
|
|
+
|
|
|
+ when(userService.wechatLogin(dto))
|
|
|
+ .thenThrow(new RuntimeException("获取openid失败,请重试"));
|
|
|
+
|
|
|
+ Result<LoginResultDTO> result = controller.wechatLogin(dto);
|
|
|
+
|
|
|
+ String msg = result.getMessage();
|
|
|
+ assertNotNull(msg);
|
|
|
+ // 契约:错误信息只含业务文案,绝不含异常类名/堆栈帧(不泄露内部结构)
|
|
|
+ assertFalse(msg.contains("RuntimeException"), "不得泄露异常类名: " + msg);
|
|
|
+ assertFalse(msg.contains("at com."), "不得泄露堆栈帧: " + msg);
|
|
|
+ assertFalse(msg.contains("\n"), "错误信息应单行业务文案: " + msg);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void wechatLogin_Code2SessionFailure_ReturnsErrorNoDirtyAccount() {
|
|
|
+ // TC-G-02-E:授权失败 → 返回业务错误,控制器层面不透传 openid/不建号
|
|
|
+ WechatLoginDTO dto = new WechatLoginDTO();
|
|
|
+ dto.setCode("invalid-wx-code");
|
|
|
+
|
|
|
+ when(userService.wechatLogin(dto))
|
|
|
+ .thenThrow(new RuntimeException("获取openid失败,请重试"));
|
|
|
+
|
|
|
+ Result<LoginResultDTO> result = controller.wechatLogin(dto);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertEquals("获取openid失败,请重试", result.getMessage());
|
|
|
+ assertNull(result.getData());
|
|
|
+ verify(userService, times(1)).wechatLogin(dto);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 权限流 TC-G-02-P:登录端点在公开白名单内,游客无 token 可放行(不误拦 401)
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void wechatLogin_PublicPath_NoToken_AnonymousAllowed() throws Exception {
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
+ request.setMethod("POST");
|
|
|
+ request.setRequestURI("/api/auth/wechat-login");
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
+
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
+
|
|
|
+ assertTrue(result, "公开登录端点无 token 必须放行(游客可触达)");
|
|
|
+ assertEquals(200, response.getStatus(), "公开端点不应被写 401");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void wechatLogin_PublicPath_InvalidToken_DowngradedAnonymous() throws Exception {
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
+ request.setMethod("POST");
|
|
|
+ request.setRequestURI("/api/auth/wechat-login");
|
|
|
+ request.addHeader("Authorization", "Bearer expired-token");
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
+
|
|
|
+ // jwtConfig.parseToken 默认返回 null → 解析 NPE → 公开路径降级匿名放行
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
+
|
|
|
+ assertTrue(result, "公开端点 token 失效应降级匿名放行,不 401 拦截");
|
|
|
+ assertEquals(200, response.getStatus());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void wechatLogin_IsNotInLoginRequiredPaths() throws Exception {
|
|
|
+ // 契约:/api/auth/wechat-login 属登录入口,不得落入「必须登录」路径族(否则游客被 401)
|
|
|
+ java.lang.reflect.Field field = JwtInterceptor.class.getDeclaredField("LOGIN_REQUIRED_PATHS");
|
|
|
+ field.setAccessible(true);
|
|
|
+ String[] loginRequired = (String[]) field.get(jwtInterceptor);
|
|
|
+ assertNotNull(loginRequired);
|
|
|
+ for (String p : loginRequired) {
|
|
|
+ assertFalse("/api/auth/wechat-login".equals(p),
|
|
|
+ "登录入口不应被列入必须登录路径: " + p);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // 结构契约:端点映射为 POST /api/auth/wechat-login(统一 @PostMapping + 公开)
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void wechatLogin_MappedToPostApiAuthWechatLogin() throws Exception {
|
|
|
+ RestController rest = AuthController.class.getAnnotation(RestController.class);
|
|
|
+ assertNotNull(rest, "AuthController 应为 @RestController");
|
|
|
+ RequestMapping classMapping = AuthController.class.getAnnotation(RequestMapping.class);
|
|
|
+ assertNotNull(classMapping);
|
|
|
+ assertEquals("/api/auth", classMapping.value()[0], "类映射应为 /api/auth");
|
|
|
+
|
|
|
+ Method m = AuthController.class.getMethod("wechatLogin", WechatLoginDTO.class);
|
|
|
+ PostMapping postMapping = m.getAnnotation(PostMapping.class);
|
|
|
+ assertNotNull(postMapping, "wechatLogin 必须为 @PostMapping(AGENTS.md 禁止 GET/PUT/DELETE)");
|
|
|
+ assertEquals("/wechat-login", postMapping.value()[0]);
|
|
|
+ }
|
|
|
+}
|