Procházet zdrojové kódy

test(S-cfc-20): admin.auth.login 密码登录契约级单测补齐(14 例全绿,仅测试不改实现)

- 新增 AdminAuthControllerPasswordLoginContractTest(14 例)
- 覆盖 POST /api/admin-auth/login-by-password 契约:
  · 正常路径:admin(BCrypt)/ teacher / 遗留 MD5 双盐(xzyj_salt_、xzyj_admin_salt_)/
    无 roles 回退主角色 / 多角色 loginRole 选中身份置顶去重 / nickname 为空回退手机号
  · 关键边界:密码错误(记录失败+剩余次数提示)/ 存储哈希为空 / 用户不存在 / 登录锁定
  · 角色权限:parent/child C 端拒绝、非法 loginRole 拒绝
- 与 S-cfc-17(验证码登录契约 15 例)互补,补齐 admin.auth.login 能力键闭环
- MockMvc standalone + 全 mock,无 Spring 上下文 / DB / 外部服务;src/main 零改动
- 验证:本类 14/14 绿;admin-auth 全套 50/50 绿
Sisyphus před 1 týdnem
rodič
revize
f8e6b0bef2

+ 396 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminAuthControllerPasswordLoginContractTest.java

@@ -0,0 +1,396 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.dto.AdminLoginDTO;
+import com.etotem.cfc.entity.User;
+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.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.MediaType;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.util.DigestUtils;
+
+import javax.servlet.http.HttpServletRequest;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * S-cfc-20 / admin.auth.login 契约级单测(GREEN 阶段:补齐「密码登录」契约闭环)。
+ *
+ * <p>与 {@link AdminAuthControllerLoginContractTest}(S-cfc-17,验证码登录
+ * {@code POST /api/admin-auth/login} 15 例契约)互补,本类聚焦同一能力键
+ * {@code admin.auth.login} 的密码登录面 {@code POST /api/admin-auth/login-by-password}:
+ * <ul>
+ *   <li>正常路径:admin(BCrypt)/ teacher / 遗留 MD5 双盐值(xzyj_salt_ / xzyj_admin_salt_)/
+ *       无 roles 列表回退主角色 / 多角色 loginRole 身份选择 / nickname 为空回退手机号;</li>
+ *   <li>关键边界:密码错误(记录失败并提示剩余次数)、存储哈希为空、用户不存在、
+ *       登录锁定(不触达用户查询)、X-Forwarded-For 不参与密码登录锁键(对齐实现,无验证码);</li>
+ *   <li>角色与权限:parent/child 等 C 端账号拒绝、非法 loginRole 拒绝。</li>
+ * </ul>
+ *
+ * <p>契约口径(对齐项目惯例:控制器签名 {@code Result<T>} + HTTP 200 信封、业务码表达语义;
+ * 真实 HTTP 401 由 {@code JwtInterceptor} 对受保护路径负责,{@code /api/admin-auth/login-by-password}
+ * 为公开路径):
+ * <ul>
+ *   <li>参数校验(空 body / 缺 phone / 缺 password)→ 业务码 400(已由
+ *       {@link AdminAuthControllerLoginStatusTest} 覆盖,本类不重复);</li>
+ *   <li>业务拒绝(密码错误 / 登录锁定 / 用户不存在 / 无后台角色 / 非法登录身份)
+ *       → 业务码 500 + 明确 message;</li>
+ *   <li>成功 → 业务码 200 + token/adminId/role/roles。</li>
+ * </ul>
+ * 测试隔离:MockMvc standalone + 全 mock 依赖,无 Spring 上下文 / 无 DB / 无外部服务。
+ */
+class AdminAuthControllerPasswordLoginContractTest {
+
+    private static final String PHONE = "13800000001";
+    private static final String PASSWORD = "password";
+
+    private AdminAuthController controller;
+    private MockMvc mockMvc;
+    private UserMapper userMapper;
+    private UserService userService;
+    private VerificationCodeService verificationCodeService;
+    private JwtConfig jwtConfig;
+    private LoginAttemptService loginAttemptService;
+
+    @BeforeEach
+    void setUp() {
+        controller = new AdminAuthController();
+        userMapper = mock(UserMapper.class);
+        userService = mock(UserService.class);
+        verificationCodeService = mock(VerificationCodeService.class);
+        jwtConfig = mock(JwtConfig.class);
+        loginAttemptService = mock(LoginAttemptService.class);
+        ReflectionTestUtils.setField(controller, "userMapper", userMapper);
+        ReflectionTestUtils.setField(controller, "userService", userService);
+        ReflectionTestUtils.setField(controller, "verificationCodeService", verificationCodeService);
+        ReflectionTestUtils.setField(controller, "jwtConfig", jwtConfig);
+        ReflectionTestUtils.setField(controller, "loginAttemptService", loginAttemptService);
+        ReflectionTestUtils.setField(controller, "wechatWebLoginService", mock(WechatWebLoginService.class));
+        // @Value 默认 false;密码登录不使用验证码,保持默认即可
+        ReflectionTestUtils.setField(controller, "skipCaptcha", false);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    // ================================================================
+    // 正常路径
+    // ================================================================
+
+    @Test
+    @DisplayName("login-by-password:admin(BCrypt 哈希)密码登录成功 → 200 + token/adminId/role/roles")
+    void loginByPassword_admin_bcrypt_成功签发token() throws Exception {
+        User admin = user(100L, "admin", list("admin"), "nick100", "管理员100");
+        admin.setPassword(bcrypt(PASSWORD));
+        passwordSuccessStubs(admin, list("admin"));
+
+        mockMvc.perform(post("/api/admin-auth/login-by-password")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"phone\":\"" + PHONE + "\",\"password\":\"" + PASSWORD + "\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.token").value("jwt-token"))
+                .andExpect(jsonPath("$.data.adminId").value(100))
+                .andExpect(jsonPath("$.data.username").value("nick100"))
+                .andExpect(jsonPath("$.data.realName").value("管理员100"))
+                .andExpect(jsonPath("$.data.role").value("admin"))
+                .andExpect(jsonPath("$.data.roles[0]").value("admin"));
+
+        verify(loginAttemptService).isBlocked(PHONE + ":127.0.0.1");
+        verify(loginAttemptService).clearSuccess(PHONE + ":127.0.0.1");
+    }
+
+    @Test
+    @DisplayName("login-by-password:teacher 密码登录成功 → role=teacher(成长规划师可登录后台)")
+    void loginByPassword_teacher_成功() {
+        User teacher = user(200L, "teacher", list("teacher"), "规划师2", null);
+        teacher.setPassword(bcrypt(PASSWORD));
+
+        Result<Map<String, Object>> r = loginSuccess(teacher, list("teacher"));
+
+        assertEquals(200, r.getCode());
+        assertEquals("teacher", r.getData().get("role"));
+        assertEquals("规划师2", r.getData().get("username"));
+    }
+
+    @Test
+    @DisplayName("login-by-password:遗留 MD5 哈希(xzyj_salt_)登录成功")
+    void loginByPassword_遗留MD5盐值1_成功() {
+        User user = user(300L, "admin", list("admin"), "旧MD5一", null);
+        user.setPassword(md5Legacy("xzyj_salt_" + PASSWORD));
+
+        Result<Map<String, Object>> r = loginSuccess(user, list("admin"));
+
+        assertEquals(200, r.getCode());
+        assertEquals("admin", r.getData().get("role"));
+    }
+
+    @Test
+    @DisplayName("login-by-password:遗留 MD5 哈希(xzyj_admin_salt_)登录成功")
+    void loginByPassword_遗留MD5盐值2_成功() {
+        User user = user(301L, "admin", list("admin"), "旧MD5二", null);
+        user.setPassword(md5Legacy("xzyj_admin_salt_" + PASSWORD));
+
+        Result<Map<String, Object>> r = loginSuccess(user, list("admin"));
+
+        assertEquals(200, r.getCode());
+        assertEquals("admin", r.getData().get("role"));
+    }
+
+    @Test
+    @DisplayName("login-by-password:无 roles 列表时回退主角色 role 字段(teacher)")
+    void loginByPassword_无roles列表_回退主角色() {
+        User teacher = user(400L, "teacher", null, "回退", null);
+        teacher.setPassword(bcrypt(PASSWORD));
+        passwordSuccessStubs(teacher, Collections.<String>emptyList());
+
+        Result<Map<String, Object>> r = loginDirect(pwdDto());
+
+        assertEquals(200, r.getCode());
+        assertEquals("teacher", r.getData().get("role"));
+        // getUserRoles 为空时回退为选中身份列表
+        assertEquals(list("teacher"), r.getData().get("roles"));
+    }
+
+    @Test
+    @DisplayName("login-by-password:多角色 + loginRole 选择 → 以指定身份登录,token roles 选中身份置顶去重")
+    void loginByPassword_多角色loginRole合法_选中身份置顶() {
+        User multi = user(500L, "admin", list("admin", "teacher"), "双角色", null);
+        multi.setPassword(bcrypt(PASSWORD));
+        passwordSuccessStubs(multi, list("admin", "teacher"));
+
+        Result<Map<String, Object>> r = loginDirect(pwdDtoWithLoginRole("teacher"));
+
+        assertEquals(200, r.getCode());
+        assertEquals("teacher", r.getData().get("role"));
+        assertEquals(list("admin", "teacher"), r.getData().get("roles"));
+
+        ArgumentCaptor<List<String>> rolesCaptor = ArgumentCaptor.forClass(List.class);
+        verify(jwtConfig).generateToken(eq(500L), rolesCaptor.capture());
+        // selected=teacher 置顶,其余角色(去重后)随后
+        assertEquals(list("teacher", "admin"), rolesCaptor.getValue());
+    }
+
+    @Test
+    @DisplayName("login-by-password:nickname 为空时 username 回退手机号,realName 保留")
+    void loginByPassword_nickname为空_回退手机号() {
+        User user = user(800L, "admin", list("admin"), null, "实名张");
+        user.setPassword(bcrypt(PASSWORD));
+
+        Result<Map<String, Object>> r = loginSuccess(user, list("admin"));
+
+        assertEquals(200, r.getCode());
+        assertEquals(PHONE, r.getData().get("username"));
+        assertEquals("实名张", r.getData().get("realName"));
+        assertEquals(800L, r.getData().get("adminId"));
+    }
+
+    // ================================================================
+    // 关键边界
+    // ================================================================
+
+    @Test
+    @DisplayName("login-by-password:密码错误 → 拒绝并记录失败,提示剩余尝试次数")
+    void loginByPassword_密码错误_记录失败提示剩余次数() {
+        User user = user(600L, "admin", list("admin"), "错密", null);
+        user.setPassword(bcrypt(PASSWORD));
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(userMapper.selectOne(any())).thenReturn(user);
+        // recordFailure 后(max=5)剩余 4 次
+        when(loginAttemptService.getRemainingAttempts(anyString())).thenReturn(4);
+
+        Result<Map<String, Object>> r = loginDirect(pwdDtoWithPassword("wrong-password"));
+
+        assertEquals(500, r.getCode());
+        assertEquals("密码错误,剩余4次尝试机会", r.getMessage());
+        verify(loginAttemptService).recordFailure(anyString());
+        verify(loginAttemptService, never()).clearSuccess(anyString());
+        verify(jwtConfig, never()).generateToken(any(), anyList());
+    }
+
+    @Test
+    @DisplayName("login-by-password:存储哈希为空 → 密码错误(不 NPE、不签发 token)")
+    void loginByPassword_存储哈希为空_密码错误() {
+        User user = user(601L, "admin", list("admin"), "空哈希", null);
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(userMapper.selectOne(any())).thenReturn(user);
+        when(loginAttemptService.getRemainingAttempts(anyString())).thenReturn(4);
+
+        Result<Map<String, Object>> r = loginDirect(pwdDto());
+
+        assertEquals(500, r.getCode());
+        assertEquals("密码错误,剩余4次尝试机会", r.getMessage());
+        verify(loginAttemptService).recordFailure(anyString());
+        verify(jwtConfig, never()).generateToken(any(), anyList());
+    }
+
+    @Test
+    @DisplayName("login-by-password:用户不存在 → 业务拒绝,不签发 token 不清锁定")
+    void loginByPassword_用户不存在_拒绝() {
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(userMapper.selectOne(any())).thenReturn(null);
+
+        Result<Map<String, Object>> r = loginDirect(pwdDto());
+
+        assertEquals(500, r.getCode());
+        assertEquals("用户不存在", r.getMessage());
+        verify(loginAttemptService, never()).clearSuccess(anyString());
+        verify(jwtConfig, never()).generateToken(any(), anyList());
+    }
+
+    @Test
+    @DisplayName("login-by-password:登录锁定 → 拒绝且不触达密码校验与用户查询(含剩余分钟提示)")
+    void loginByPassword_登录锁定_拒绝() {
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(true);
+        // 剩余 900s=15min → 提示「15+1」分钟
+        when(loginAttemptService.getLockRemainingTime(anyString())).thenReturn(900_000L);
+
+        Result<Map<String, Object>> r = loginDirect(pwdDto());
+
+        assertEquals(500, r.getCode());
+        assertEquals("登录尝试过多,请16分钟后重试", r.getMessage());
+        verify(userMapper, never()).selectOne(any());
+        verify(jwtConfig, never()).generateToken(any(), anyList());
+    }
+
+    // ================================================================
+    // 角色 / 权限
+    // ================================================================
+
+    @Test
+    @DisplayName("login-by-password:parent 无后台角色 → 拒绝(该账号没有管理权限)")
+    void loginByPassword_parent无后台角色_拒绝() {
+        User parent = user(900L, "parent", list("parent"), "家长", null);
+        parent.setPassword(bcrypt(PASSWORD));
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(userMapper.selectOne(any())).thenReturn(parent);
+
+        Result<Map<String, Object>> r = loginDirect(pwdDto());
+
+        assertEquals(500, r.getCode());
+        assertEquals("该账号没有管理权限", r.getMessage());
+        verify(jwtConfig, never()).generateToken(any(), anyList());
+    }
+
+    @Test
+    @DisplayName("login-by-password:roles 仅含 child(C 端)→ 拒绝")
+    void loginByPassword_child角色_拒绝() {
+        User child = user(901L, "child", list("child"), "孩子", null);
+        child.setPassword(bcrypt(PASSWORD));
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(userMapper.selectOne(any())).thenReturn(child);
+
+        Result<Map<String, Object>> r = loginDirect(pwdDto());
+
+        assertEquals(500, r.getCode());
+        assertEquals("该账号没有管理权限", r.getMessage());
+        verify(jwtConfig, never()).generateToken(any(), anyList());
+    }
+
+    @Test
+    @DisplayName("login-by-password:登录身份不在账号后台角色内 → 拒绝(该账号没有【X】身份)")
+    void loginByPassword_loginRole非法_拒绝() {
+        User admin = user(1000L, "admin", list("admin"), "仅管理", null);
+        admin.setPassword(bcrypt(PASSWORD));
+        passwordSuccessStubs(admin, list("admin"));
+
+        Result<Map<String, Object>> r = loginDirect(pwdDtoWithLoginRole("finance"));
+
+        assertEquals(500, r.getCode());
+        assertEquals("该账号没有【财务】身份", r.getMessage());
+        verify(jwtConfig, never()).generateToken(any(), anyList());
+    }
+
+    // ================================================================
+    // helpers
+    // ================================================================
+
+    /** 成功路径标准打桩:未锁定、用户命中、角色列表、token 固定值(密码登录无验证码环节) */
+    private void passwordSuccessStubs(User user, List<String> roles) {
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(userMapper.selectOne(any())).thenReturn(user);
+        when(userService.getUserRoles(user.getId())).thenReturn(roles);
+        when(jwtConfig.generateToken(eq(user.getId()), anyList())).thenReturn("jwt-token");
+    }
+
+    /** 成功路径便捷入口:打桩 + 直接调用 loginByPassword */
+    private Result<Map<String, Object>> loginSuccess(User user, List<String> roles) {
+        passwordSuccessStubs(user, roles);
+        return loginDirect(pwdDto());
+    }
+
+    /** 直接调用 loginByPassword(调用方自行打桩;request 为全 mock → 无转发头、remoteAddr 为 null) */
+    private Result<Map<String, Object>> loginDirect(AdminLoginDTO dto) {
+        return controller.loginByPassword(dto, mock(HttpServletRequest.class));
+    }
+
+    /** BCrypt 哈希(运行时生成,保证与实现同盐强度匹配) */
+    private static String bcrypt(String rawPassword) {
+        return new BCryptPasswordEncoder().encode(rawPassword);
+    }
+
+    /** 遗留 MD5 哈希(与实现的 xzyj_salt_ / xzyj_admin_salt_ 两种盐一致) */
+    private static String md5Legacy(String saltedRaw) {
+        return DigestUtils.md5DigestAsHex(saltedRaw.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private AdminLoginDTO pwdDto() {
+        return pwdDtoWithLoginRole(null);
+    }
+
+    private AdminLoginDTO pwdDtoWithLoginRole(String loginRole) {
+        AdminLoginDTO dto = new AdminLoginDTO();
+        dto.setPhone(PHONE);
+        dto.setPassword(PASSWORD);
+        dto.setLoginRole(loginRole);
+        return dto;
+    }
+
+    private AdminLoginDTO pwdDtoWithPassword(String password) {
+        AdminLoginDTO dto = new AdminLoginDTO();
+        dto.setPhone(PHONE);
+        dto.setPassword(password);
+        return dto;
+    }
+
+    private static User user(Long id, String role, List<String> roles, String nickname, String realName) {
+        User user = new User();
+        user.setId(id);
+        user.setRole(role);
+        user.setRoles(roles);
+        user.setNickname(nickname);
+        user.setRealName(realName);
+        user.setPhone(PHONE);
+        return user;
+    }
+
+    private static List<String> list(String... items) {
+        return new ArrayList<>(Arrays.asList(items));
+    }
+}