|
@@ -0,0 +1,312 @@
|
|
|
|
|
+package com.etotem.cfc.controller;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
|
|
+import com.etotem.cfc.config.JwtConfig;
|
|
|
|
|
+import com.etotem.cfc.config.JwtInterceptor;
|
|
|
|
|
+import com.etotem.cfc.controller.admin.AdminAuthController;
|
|
|
|
|
+import com.etotem.cfc.controller.auth.AuthController;
|
|
|
|
|
+import com.etotem.cfc.dto.AdminLoginDTO;
|
|
|
|
|
+import com.etotem.cfc.dto.LoginResultDTO;
|
|
|
|
|
+import com.etotem.cfc.dto.PhoneLoginDTO;
|
|
|
|
|
+import com.etotem.cfc.dto.SendCodeDTO;
|
|
|
|
|
+import com.etotem.cfc.dto.WechatPhoneLoginDTO;
|
|
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
|
|
+import com.etotem.cfc.service.LoginAttemptService;
|
|
|
|
|
+import com.etotem.cfc.service.UserService;
|
|
|
|
|
+import com.etotem.cfc.service.VerificationCodeService;
|
|
|
|
|
+import com.etotem.cfc.service.WechatWebLoginService;
|
|
|
|
|
+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.lang.reflect.Method;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * SC-G-03 登录方式口径契约级单测(能力键 auth.guest.loginMethod)
|
|
|
|
|
+ * ================================================================
|
|
|
|
|
+ * 覆盖(对应 tests/user-scenarios/01-guest.md 场景 G-3、tests/test-cases/guest.md TC-G-03):
|
|
|
|
|
+ * - 小程序端:微信一键登录(wechat-login,SC-G-02 已覆盖)、微信手机号快捷登录(wechat-phone-login)、
|
|
|
|
|
+ * 验证码登录(phone-login / send-code,API 兼容保留:小程序端无入口,服务端能力保留)。
|
|
|
|
|
+ * - Web 管理后台:密码登录(login-by-password)、验证码登录(send-code + login)。
|
|
|
|
|
+ * - 异常流 (TC-G-03-E):验证码错误/过期 → 明确失败且不产生会话;手机号格式非法 → 400;不泄露堆栈。
|
|
|
|
|
+ * - 权限流 (TC-G-03-P):全部登录端点在 JwtInterceptor 公开白名单内,游客无 token 可触达(不误拦 401)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 溯源:SC-G-03 · tests/user-scenarios/01-guest.md 场景 G-3 · tests/test-cases/guest.md TC-G-03/TC-G-03-E
|
|
|
|
|
+ *
|
|
|
|
|
+ * 纯单元测试:Mockito 直接调用 handler + MockHttpServletRequest/Response,不依赖数据库/真实微信/Spring 容器。
|
|
|
|
|
+ */
|
|
|
|
|
+public class ScG03LoginMethodContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private UserService userService;
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private VerificationCodeService verificationCodeService;
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private JwtConfig jwtConfig;
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private UserMapper userMapper;
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
|
|
+ @InjectMocks
|
|
|
|
|
+ private AuthController authController;
|
|
|
|
|
+
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private LoginAttemptService loginAttemptService;
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private WechatWebLoginService wechatWebLoginService;
|
|
|
|
|
+ @InjectMocks
|
|
|
|
|
+ private AdminAuthController adminAuthController;
|
|
|
|
|
+
|
|
|
|
|
+ @InjectMocks
|
|
|
|
|
+ private JwtInterceptor jwtInterceptor;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ public void setUp() {
|
|
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+ // 正常流 TC-G-03:验证码登录(API 兼容保留:服务端能力)
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void sendCode_ValidPhone_GeneratesCode() {
|
|
|
|
|
+ SendCodeDTO dto = new SendCodeDTO();
|
|
|
|
|
+ dto.setPhone("13800000001");
|
|
|
|
|
+ dto.setType("login");
|
|
|
|
|
+
|
|
|
|
|
+ Result<Boolean> result = authController.sendCode(dto);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
|
|
+ assertTrue(result.getData(), "发送验证码应返回 true");
|
|
|
|
|
+ verify(verificationCodeService, times(1)).generateCode("13800000001", "login");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void phoneLogin_ValidCode_ReturnsToken() {
|
|
|
|
|
+ PhoneLoginDTO dto = new PhoneLoginDTO();
|
|
|
|
|
+ dto.setPhone("13800000001");
|
|
|
|
|
+ dto.setCode("123456");
|
|
|
|
|
+
|
|
|
|
|
+ when(verificationCodeService.verifyCode("13800000001", "123456", "login")).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ LoginResultDTO login = new LoginResultDTO();
|
|
|
|
|
+ login.setToken("jwt-token");
|
|
|
|
|
+ login.setUserId(99L);
|
|
|
|
|
+ login.setRole("parent");
|
|
|
|
|
+ when(userService.phoneLogin(dto)).thenReturn(login);
|
|
|
|
|
+
|
|
|
|
|
+ Result<LoginResultDTO> result = authController.phoneLogin(dto);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
|
|
+ assertEquals("jwt-token", result.getData().getToken());
|
|
|
|
|
+ assertEquals("parent", result.getData().getRole());
|
|
|
|
|
+ verify(verificationCodeService, times(1)).verifyCode("13800000001", "123456", "login");
|
|
|
|
|
+ verify(userService, times(1)).phoneLogin(dto);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void wechatPhoneLogin_ValidInput_ReturnsToken() {
|
|
|
|
|
+ WechatPhoneLoginDTO dto = new WechatPhoneLoginDTO();
|
|
|
|
|
+ dto.setCode("wx-code");
|
|
|
|
|
+ dto.setPhoneCode("phone-code");
|
|
|
|
|
+
|
|
|
|
|
+ LoginResultDTO login = new LoginResultDTO();
|
|
|
|
|
+ login.setToken("wxtoken");
|
|
|
|
|
+ login.setUserId(77L);
|
|
|
|
|
+ login.setRole("parent");
|
|
|
|
|
+ when(userService.wechatPhoneLogin(dto)).thenReturn(login);
|
|
|
|
|
+
|
|
|
|
|
+ Result<LoginResultDTO> result = authController.wechatPhoneLogin(dto);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
|
|
+ assertEquals("wxtoken", result.getData().getToken());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+ // 正常流 TC-G-03:Web 管理后台密码登录(login-by-password)
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void adminLoginByPassword_ValidCredential_ReturnsToken() {
|
|
|
|
|
+ AdminLoginDTO dto = new AdminLoginDTO();
|
|
|
|
|
+ dto.setPhone("13800000001");
|
|
|
|
|
+ dto.setPassword("password123");
|
|
|
|
|
+
|
|
|
|
|
+ // 预置后台角色用户,密码以 BCrypt 存储(兼容逻辑,verifyLoginPassword 支持 MD5+BCrypt)
|
|
|
|
|
+ User admin = new User();
|
|
|
|
|
+ admin.setId(5L);
|
|
|
|
|
+ admin.setPhone("13800000001");
|
|
|
|
|
+ admin.setRole("admin");
|
|
|
|
|
+ admin.setPassword("{bcrypt}" + "$2a$10$abcdefghijklmnopqrstuv");
|
|
|
|
|
+ when(userMapper.selectOne(any())).thenReturn(admin);
|
|
|
|
|
+ when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
|
|
|
|
|
+ when(userService.getUserRoles(5L)).thenReturn(java.util.Collections.singletonList("admin"));
|
|
|
|
|
+ when(jwtConfig.generateToken(eq(5L), anyList())).thenReturn("admin-jwt");
|
|
|
|
|
+
|
|
|
|
|
+ // 密码用正确哈希后验证:直接构造 BCrypt 匹配
|
|
|
|
|
+ org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder encoder =
|
|
|
|
|
+ new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder();
|
|
|
|
|
+ admin.setPassword(encoder.encode("password123"));
|
|
|
|
|
+
|
|
|
|
|
+ MockHttpServletRequest req = new MockHttpServletRequest();
|
|
|
|
|
+ Result<java.util.Map<String, Object>> result = adminAuthController.loginByPassword(dto, req);
|
|
|
|
|
+
|
|
|
|
|
+ // 若 mock 链完整则 200 + token
|
|
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
|
|
+ assertNotNull(result.getData());
|
|
|
|
|
+ assertEquals("admin-jwt", result.getData().get("token"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void adminLoginByPassword_EmptyBody_Is400() {
|
|
|
|
|
+ MockHttpServletRequest req = new MockHttpServletRequest();
|
|
|
|
|
+ Result<java.util.Map<String, Object>> result = adminAuthController.loginByPassword(null, req);
|
|
|
|
|
+ assertEquals(400, result.getCode(), "空 body 应返回业务码 400");
|
|
|
|
|
+ assertNull(result.getData());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void adminLoginByPassword_MissingFields_Is400() {
|
|
|
|
|
+ AdminLoginDTO dto = new AdminLoginDTO();
|
|
|
|
|
+ dto.setPhone("13800000001");
|
|
|
|
|
+ dto.setPassword(null);
|
|
|
|
|
+ MockHttpServletRequest req = new MockHttpServletRequest();
|
|
|
|
|
+ Result<java.util.Map<String, Object>> result = adminAuthController.loginByPassword(dto, req);
|
|
|
|
|
+ assertEquals(400, result.getCode(), "缺密码应返回业务码 400");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+ // 异常流 TC-G-03-E:验证码错误/手机号非法 → 明确失败且不产生会话
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void sendCode_InvalidPhone_ReturnsError() {
|
|
|
|
|
+ SendCodeDTO dto = new SendCodeDTO();
|
|
|
|
|
+ dto.setPhone("123"); // 非法格式
|
|
|
|
|
+ Result<Boolean> result = authController.sendCode(dto);
|
|
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
|
|
+ assertTrue(result.getMessage().contains("格式不正确"), "手机号非法应提示格式错误,实际: " + result.getMessage());
|
|
|
|
|
+ verify(verificationCodeService, never()).generateCode(anyString(), anyString());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void sendCode_EmptyPhone_ReturnsError() {
|
|
|
|
|
+ SendCodeDTO dto = new SendCodeDTO();
|
|
|
|
|
+ dto.setPhone("");
|
|
|
|
|
+ Result<Boolean> result = authController.sendCode(dto);
|
|
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
|
|
+ assertTrue(result.getMessage().contains("不能为空"), "空手机号应提示,实际: " + result.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void phoneLogin_WrongCode_ReturnsErrorNoToken() {
|
|
|
|
|
+ PhoneLoginDTO dto = new PhoneLoginDTO();
|
|
|
|
|
+ dto.setPhone("13800000001");
|
|
|
|
|
+ dto.setCode("000000");
|
|
|
|
|
+
|
|
|
|
|
+ when(verificationCodeService.verifyCode("13800000001", "000000", "login")).thenReturn(false);
|
|
|
|
|
+
|
|
|
|
|
+ Result<LoginResultDTO> result = authController.phoneLogin(dto);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(500, result.getCode(), "验证码错误应返回业务码 500");
|
|
|
|
|
+ assertTrue(result.getMessage().contains("验证码错误"), "实际: " + result.getMessage());
|
|
|
|
|
+ assertNull(result.getData(), "验证码错误绝不产生会话(data 必须为 null)");
|
|
|
|
|
+ verify(userService, never()).phoneLogin(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void phoneLogin_ServiceFailure_ErrorMessageDoesNotLeakStack() {
|
|
|
|
|
+ PhoneLoginDTO dto = new PhoneLoginDTO();
|
|
|
|
|
+ dto.setPhone("13800000001");
|
|
|
|
|
+ dto.setCode("123456");
|
|
|
|
|
+ when(verificationCodeService.verifyCode(anyString(), anyString(), anyString())).thenReturn(true);
|
|
|
|
|
+ when(userService.phoneLogin(dto)).thenThrow(new RuntimeException("登录失败"));
|
|
|
|
|
+
|
|
|
|
|
+ Result<LoginResultDTO> result = authController.phoneLogin(dto);
|
|
|
|
|
+
|
|
|
|
|
+ String msg = result.getMessage();
|
|
|
|
|
+ assertNotNull(msg);
|
|
|
|
|
+ assertFalse(msg.contains("RuntimeException"), "不得泄露异常类名: " + msg);
|
|
|
|
|
+ assertFalse(msg.contains("at com."), "不得泄露堆栈: " + msg);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void wechatPhoneLogin_MissingParams_ReturnsError() {
|
|
|
|
|
+ WechatPhoneLoginDTO dto = new WechatPhoneLoginDTO();
|
|
|
|
|
+ dto.setCode(null);
|
|
|
|
|
+ dto.setPhoneCode(null);
|
|
|
|
|
+ Result<LoginResultDTO> result = authController.wechatPhoneLogin(dto);
|
|
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
|
|
+ assertNull(result.getData());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+ // 权限流 TC-G-03-P:全部登录端点在公开白名单内(游客无 token 可触达)
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void permission_loginEndpoints_arePublicNotBlocked() throws Exception {
|
|
|
|
|
+ String[] publicPaths = {
|
|
|
|
|
+ "/api/auth/send-code",
|
|
|
|
|
+ "/api/auth/phone-login",
|
|
|
|
|
+ "/api/auth/wechat-phone-login",
|
|
|
|
|
+ "/api/auth/wechat-login",
|
|
|
|
|
+ "/api/auth/register-with-invite",
|
|
|
|
|
+ "/api/admin-auth/send-code",
|
|
|
|
|
+ "/api/admin-auth/login",
|
|
|
|
|
+ "/api/admin-auth/login-by-password",
|
|
|
|
|
+ "/api/admin-auth/account-roles",
|
|
|
|
|
+ "/api/admin-auth/wechat-scan-qr",
|
|
|
|
|
+ "/api/admin-auth/wechat-scan-poll",
|
|
|
|
|
+ };
|
|
|
|
|
+ for (String path : publicPaths) {
|
|
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
|
|
+ request.setMethod("POST");
|
|
|
|
|
+ request.setRequestURI(path);
|
|
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
|
|
+ boolean allowed = jwtInterceptor.preHandle(request, response, null);
|
|
|
|
|
+ assertTrue(allowed, "登录端点应为公开路径,游客可触达: " + path);
|
|
|
|
|
+ assertEquals(HttpServletResponse.SC_OK, response.getStatus(), "公开登录端点不应写 401: " + path);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+ // 结构契约:统一 POST
|
|
|
|
|
+ // ====================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void structure_loginEndpointsUsePostMapping() throws Exception {
|
|
|
|
|
+ // 小程序端
|
|
|
|
|
+ for (String m : new String[]{"sendCode", "phoneLogin", "wechatPhoneLogin"}) {
|
|
|
|
|
+ for (Method method : AuthController.class.getDeclaredMethods()) {
|
|
|
|
|
+ if (method.getName().equals(m)) {
|
|
|
|
|
+ assertNotNull(method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class),
|
|
|
|
|
+ "AuthController." + m + " 必须为 @PostMapping");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 管理端
|
|
|
|
|
+ for (String m : new String[]{"sendCode", "login", "loginByPassword", "wechatScanQr", "wechatScanPoll"}) {
|
|
|
|
|
+ for (Method method : AdminAuthController.class.getDeclaredMethods()) {
|
|
|
|
|
+ if (method.getName().equals(m)) {
|
|
|
|
|
+ assertNotNull(method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class),
|
|
|
|
|
+ "AdminAuthController." + m + " 必须为 @PostMapping");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|