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

test(S-cfc-17): admin.auth.login 契约级单测补齐(15 例全绿,仅测试不改实现)

能力键 admin.auth.login(POST /api/admin-auth/login)在 discovery 台账为「未覆盖」,
本提交补齐登录契约最小闭环,与既有 AdminAuthControllerLoginStatusTest(B-2/R5 空 body/缺参 400)
互补,聚焦:
- 正常路径:admin/teacher 成功、遗留角色归一化(activity_admin→activity_manager、
  supplier_admin→supplier)、多角色 loginRole 身份选择(token roles 选中身份置顶)、
  无 roles 列表回退主角色;
- 关键边界:用户不存在、验证码错误(recordFailure)、登录锁定(剩余分钟提示)、
  skipCaptcha 跳过校验、X-Forwarded-For 锁键 IP 口径、nickname 为空回退手机号;
- 角色/权限:parent/child 等 C 端账号拒绝、非法 loginRole 拒绝。

验证:mvn -DskipTests=false -Dtest=AdminAuthControllerLoginContractTest test 15/15 通过;
同域 AdminAuth*(LoginStatus 10 + WechatScan 11)36/36 通过,无回归。

暴露缺陷(登记不改实现,交编排侧 GREEN 决策):
blockers.md B-2 / test_blockers.md R5 期望无后台角色/非法身份返回 401/403,
当前实现仍以业务码 500 + HTTP 200 信封表达(Result.error),测试如实断言现状。

仅新增测试文件,未改动 src/main。
Sisyphus 6 дней назад
Родитель
Сommit
9e391ac5de

+ 372 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminAuthControllerLoginContractTest.java

@@ -0,0 +1,372 @@
+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.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import javax.servlet.http.HttpServletRequest;
+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-17 / admin.auth.login 契约级单测(RED 阶段:补齐「未覆盖」能力键的最小闭环)。
+ *
+ * <p>与 {@link AdminAuthControllerLoginStatusTest}(B-2/R5:空 body/缺参 → 400 语义)互补,
+ * 本类聚焦 {@code POST /api/admin-auth/login} 的完整登录契约:
+ * <ul>
+ *   <li>正常路径:admin / teacher / 遗留角色归一化(activity_admin→activity_manager,
+ *       supplier_admin→supplier)/ 多角色 loginRole 身份选择 / 无 roles 列表回退主角色;</li>
+ *   <li>关键边界:用户不存在、验证码错误(记录失败)、登录锁定、skipCaptcha 跳过校验、
+ *       X-Forwarded-For IP 取数口径、nickname 为空回退手机号;</li>
+ *   <li>角色与权限:parent/child 等 C 端账号拒绝、非法 loginRole 拒绝。</li>
+ * </ul>
+ *
+ * <p>契约口径(对齐项目惯例:控制器签名 {@code Result<T>} + HTTP 200 信封、业务码表达语义;
+ * 真实 HTTP 401 由 {@code JwtInterceptor} 对受保护路径负责,{@code /api/admin-auth/login}
+ * 为公开路径):
+ * <ul>
+ *   <li>参数校验(空 body / 缺 phone / 缺 code)→ 业务码 400;</li>
+ *   <li>业务拒绝(验证码错误 / 登录锁定 / 用户不存在 / 无后台角色 / 非法登录身份)
+ *       → 业务码 500 + 明确 message —— 对应 blockers.md B-2 / test_blockers.md R5 期望的
+ *       401/403 语义,当前实现仍以业务码 500 表达(HTTP 200 信封),如实入测并登记缺陷,不改 src/main;</li>
+ *   <li>成功 → 业务码 200 + token/adminId/role/roles。</li>
+ * </ul>
+ * 测试隔离:MockMvc standalone + 全 mock 依赖,无 Spring 上下文 / 无 DB / 无外部服务。
+ */
+class AdminAuthControllerLoginContractTest {
+
+    private static final String PHONE = "13800000001";
+    private static final String CODE = "123456";
+
+    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;skip-captcha 路径由专门 case 打开
+        ReflectionTestUtils.setField(controller, "skipCaptcha", false);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    // ================================================================
+    // 正常路径
+    // ================================================================
+
+    @Test
+    @DisplayName("login:admin 验证码登录成功 → 200 + token/adminId/role/roles")
+    void login_admin_成功签发token与角色() throws Exception {
+        successStubs(user(100L, "admin", list("admin"), "nick100", "管理员100"), list("admin"));
+
+        mockMvc.perform(post("/api/admin-auth/login")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"phone\":\"" + PHONE + "\",\"code\":\"" + CODE + "\"}"))
+                .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(verificationCodeService).verifyCode(PHONE, CODE, "admin_login");
+        verify(loginAttemptService).isBlocked(PHONE + ":127.0.0.1");
+        verify(loginAttemptService).clearSuccess(PHONE + ":127.0.0.1");
+    }
+
+    @Test
+    @DisplayName("login:teacher 登录成功 → role=teacher(成长规划师可登录后台)")
+    void login_teacher_成功() {
+        Result<Map<String, Object>> r = loginSuccess(user(200L, "teacher", list("teacher"), "规划师2", null), list("teacher"));
+
+        assertEquals(200, r.getCode());
+        assertEquals("teacher", r.getData().get("role"));
+        assertEquals("规划师2", r.getData().get("username"));
+    }
+
+    @Test
+    @DisplayName("login:遗留角色 activity_admin 归一化为 activity_manager 登录成功")
+    void login_遗留角色_归一化为新角色() {
+        Result<Map<String, Object>> r = loginSuccess(user(300L, "activity_admin", list("activity_admin"), "旧活动", null), list("activity_admin"));
+
+        assertEquals(200, r.getCode());
+        assertEquals("activity_manager", r.getData().get("role"));
+    }
+
+    @Test
+    @DisplayName("login:遗留角色 supplier_admin 归一化为 supplier 登录成功")
+    void login_供应商遗留角色_归一化() {
+        Result<Map<String, Object>> r = loginSuccess(user(1100L, "supplier_admin", list("supplier_admin"), "旧供应商", null), list("supplier_admin"));
+
+        assertEquals(200, r.getCode());
+        assertEquals("supplier", r.getData().get("role"));
+    }
+
+    @Test
+    @DisplayName("login:无 roles 列表时回退主角色 role 字段(teacher)")
+    void login_无roles列表_回退主角色() {
+        User teacher = user(400L, "teacher", null, "回退", null);
+        successStubs(teacher, Collections.<String>emptyList());
+
+        Result<Map<String, Object>> r = loginDirect(dto());
+
+        assertEquals(200, r.getCode());
+        assertEquals("teacher", r.getData().get("role"));
+        // getUserRoles 为空时回退为选中身份列表
+        assertEquals(list("teacher"), r.getData().get("roles"));
+    }
+
+    @Test
+    @DisplayName("login:多角色 + loginRole 选择 → 以指定身份登录,token roles 选中身份置顶去重")
+    void login_多角色loginRole合法_选中身份置顶() {
+        User multi = user(500L, "admin", list("admin", "teacher"), "双角色", null);
+        successStubs(multi, list("admin", "teacher"));
+
+        Result<Map<String, Object>> r = loginDirect(dtoWithLoginRole("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:用户不存在 → 业务拒绝,不签发 token 不清锁定")
+    void login_用户不存在_拒绝() {
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(verificationCodeService.verifyCode(PHONE, CODE, "admin_login")).thenReturn(true);
+        when(userMapper.selectOne(any())).thenReturn(null);
+
+        Result<Map<String, Object>> r = loginDirect(dto());
+
+        assertEquals(500, r.getCode());
+        assertEquals("用户不存在", r.getMessage());
+        verify(loginAttemptService, never()).clearSuccess(anyString());
+        verify(jwtConfig, never()).generateToken(any(), anyList());
+    }
+
+    @Test
+    @DisplayName("login:验证码错误 → 拒绝并记录失败尝试")
+    void login_验证码错误_记录失败() {
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(verificationCodeService.verifyCode(PHONE, CODE, "admin_login")).thenReturn(false);
+
+        Result<Map<String, Object>> r = loginDirect(dto());
+
+        assertEquals(500, r.getCode());
+        assertEquals("验证码错误或已过期", r.getMessage());
+        verify(loginAttemptService).recordFailure(anyString());
+        verify(userMapper, never()).selectOne(any());
+        verify(jwtConfig, never()).generateToken(any(), anyList());
+    }
+
+    @Test
+    @DisplayName("login:登录锁定 → 拒绝且不触达验证码与用户查询(含剩余分钟提示)")
+    void login_登录锁定_拒绝() {
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(true);
+        // 剩余 900s=15min → 提示「15+1」分钟
+        when(loginAttemptService.getLockRemainingTime(anyString())).thenReturn(900_000L);
+
+        Result<Map<String, Object>> r = loginDirect(dto());
+
+        assertEquals(500, r.getCode());
+        assertEquals("登录尝试过多,请16分钟后重试", r.getMessage());
+        verify(verificationCodeService, never()).verifyCode(anyString(), anyString(), anyString());
+        verify(userMapper, never()).selectOne(any());
+    }
+
+    @Test
+    @DisplayName("login:skipCaptcha=true 时跳过验证码校验直接登录")
+    void login_skipCaptcha_跳过验证码() throws Exception {
+        ReflectionTestUtils.setField(controller, "skipCaptcha", true);
+        successStubs(user(600L, "admin", list("admin"), "免码", null), list("admin"));
+
+        mockMvc.perform(post("/api/admin-auth/login")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"phone\":\"" + PHONE + "\",\"code\":\"wrong-code\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        verify(verificationCodeService, never()).verifyCode(anyString(), anyString(), anyString());
+    }
+
+    @Test
+    @DisplayName("login:X-Forwarded-For 作为锁键 IP 口径(防绕过锁定)")
+    void login_IP取数_优先XForwardedFor() throws Exception {
+        successStubs(user(700L, "admin", list("admin"), "ip", null), list("admin"));
+
+        mockMvc.perform(post("/api/admin-auth/login")
+                        .header("X-Forwarded-For", "203.0.113.5")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"phone\":\"" + PHONE + "\",\"code\":\"" + CODE + "\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        verify(loginAttemptService).isBlocked(PHONE + ":203.0.113.5");
+        verify(loginAttemptService).clearSuccess(PHONE + ":203.0.113.5");
+    }
+
+    @Test
+    @DisplayName("login:nickname 为空时 username 回退手机号,realName 保留")
+    void login_nickname为空_回退手机号() {
+        Result<Map<String, Object>> r = loginSuccess(user(800L, "admin", list("admin"), null, "实名张"), 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:parent 无后台角色 → 拒绝(该账号没有管理权限),HTTP 200 信封 + 业务码表达")
+    void login_parent无后台角色_拒绝() throws Exception {
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(verificationCodeService.verifyCode(PHONE, CODE, "admin_login")).thenReturn(true);
+        when(userMapper.selectOne(any())).thenReturn(user(900L, "parent", list("parent"), "家长", null));
+
+        mockMvc.perform(post("/api/admin-auth/login")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"phone\":\"" + PHONE + "\",\"code\":\"" + CODE + "\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("该账号没有管理权限"));
+
+        verify(jwtConfig, never()).generateToken(any(), anyList());
+    }
+
+    @Test
+    @DisplayName("login:roles 仅含 child(C 端)→ 拒绝")
+    void login_child角色_拒绝() {
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(verificationCodeService.verifyCode(PHONE, CODE, "admin_login")).thenReturn(true);
+        when(userMapper.selectOne(any())).thenReturn(user(901L, "child", list("child"), "孩子", null));
+
+        Result<Map<String, Object>> r = loginDirect(dto());
+
+        assertEquals(500, r.getCode());
+        assertEquals("该账号没有管理权限", r.getMessage());
+        verify(jwtConfig, never()).generateToken(any(), anyList());
+    }
+
+    @Test
+    @DisplayName("login:登录身份不在账号后台角色内 → 拒绝(该账号没有【X】身份)")
+    void login_loginRole非法_拒绝() {
+        successStubs(user(1000L, "admin", list("admin"), "仅管理", null), list("admin"));
+
+        Result<Map<String, Object>> r = loginDirect(dtoWithLoginRole("finance"));
+
+        assertEquals(500, r.getCode());
+        assertEquals("该账号没有【财务】身份", r.getMessage());
+        verify(jwtConfig, never()).generateToken(any(), anyList());
+    }
+
+    // ================================================================
+    // helpers
+    // ================================================================
+
+    /** 成功路径标准打桩:未锁定、验证码通过、用户命中、角色列表、token 固定值 */
+    private void successStubs(User user, List<String> roles) {
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(verificationCodeService.verifyCode(PHONE, CODE, "admin_login")).thenReturn(true);
+        when(userMapper.selectOne(any())).thenReturn(user);
+        when(userService.getUserRoles(user.getId())).thenReturn(roles);
+        when(jwtConfig.generateToken(eq(user.getId()), anyList())).thenReturn("jwt-token");
+    }
+
+    /** 成功路径便捷入口:打桩 + 直接调用 login */
+    private Result<Map<String, Object>> loginSuccess(User user, List<String> roles) {
+        successStubs(user, roles);
+        return loginDirect(dto());
+    }
+
+    /** 直接调用 login(调用方自行打桩;request 为全 mock → 无转发头、remoteAddr 为 null) */
+    private Result<Map<String, Object>> loginDirect(AdminLoginDTO dto) {
+        return controller.login(dto, mock(HttpServletRequest.class));
+    }
+
+    private AdminLoginDTO dto() {
+        return dtoWithLoginRole(null);
+    }
+
+    private AdminLoginDTO dtoWithLoginRole(String loginRole) {
+        AdminLoginDTO dto = new AdminLoginDTO();
+        dto.setPhone(PHONE);
+        dto.setCode(CODE);
+        dto.setLoginRole(loginRole);
+        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));
+    }
+}