|
|
@@ -0,0 +1,250 @@
|
|
|
+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.springframework.http.MediaType;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
+import org.springframework.test.web.servlet.MvcResult;
|
|
|
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
+
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+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;
|
|
|
+
|
|
|
+/**
|
|
|
+ * TDD 研发负责人 B-2 / R5:POST /api/admin-auth/login 未授权/空 body/缺参不得暴露 500。
|
|
|
+ *
|
|
|
+ * <p>缺陷:空 body / 缺参原走 {@code Result.error(...)}(业务码 500,且 dto==null 会 NPE);</p>
|
|
|
+ *
|
|
|
+ * <p>契约口径(对齐项目既有惯例:控制器签名 {@code Result<T>} + HTTP 200 信封、
|
|
|
+ * 业务码表达语义;真实 HTTP 401 由 {@code JwtInterceptor} 对受保护路径负责,
|
|
|
+ * {@code /api/admin-auth/login} 为公开路径):
|
|
|
+ * <ul>
|
|
|
+ * <li>空 body(dto==null)→ 业务码 400,无 NPE;</li>
|
|
|
+ * <li>缺 code / 缺 phone / 空对象 {} → 业务码 400(参数校验);</li>
|
|
|
+ * <li>正常验证码登录成功 → 业务码 200 + token/adminId/role/roles(成功路径行为不变);</li>
|
|
|
+ * <li>同面公开端点 {@code send-code} / {@code login-by-password} 空 body / 缺参 → 400(同类 R5 暴露)。</li>
|
|
|
+ * </ul>
|
|
|
+ * 测试隔离:MockMvc standalone + 全 mock 依赖,无 Spring 上下文 / 无 DB。
|
|
|
+ */
|
|
|
+class AdminAuthControllerLoginStatusTest {
|
|
|
+
|
|
|
+ private AdminAuthController controller;
|
|
|
+ private MockMvc mockMvc;
|
|
|
+ private UserMapper userMapper;
|
|
|
+ private UserService userService;
|
|
|
+ private VerificationCodeService verificationCodeService;
|
|
|
+ private JwtConfig jwtConfig;
|
|
|
+ private LoginAttemptService loginAttemptService;
|
|
|
+ private WechatWebLoginService wechatWebLoginService;
|
|
|
+
|
|
|
+ @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);
|
|
|
+ wechatWebLoginService = mock(WechatWebLoginService.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", wechatWebLoginService);
|
|
|
+ mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------
|
|
|
+ // (a) 空 body(dto==null)→ 非 5xx / 400,无 NPE
|
|
|
+ // ---------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login:直接调用 null dto(空 body)→ 业务码 400,无 NPE,且不触达业务依赖")
|
|
|
+ void login_nullDto_directCall_returns400WithoutNpe() {
|
|
|
+ HttpServletRequest request = mock(HttpServletRequest.class);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result = controller.login(null, request);
|
|
|
+
|
|
|
+ assertEquals(400, result.getCode());
|
|
|
+ assertNotNull(result.getMessage());
|
|
|
+ // 参数校验前置:未触达验证码/锁定/用户查询等业务依赖
|
|
|
+ verify(loginAttemptService, never()).isBlocked(anyString());
|
|
|
+ verify(verificationCodeService, never()).verifyCode(anyString(), anyString(), anyString());
|
|
|
+ verify(userMapper, never()).selectOne(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login HTTP:空请求体 → 非 5xx(允许 200+业务码400 或框架 400),绝无 500")
|
|
|
+ void login_emptyBody_http_not5xx() throws Exception {
|
|
|
+ MvcResult mvcResult = mockMvc.perform(post("/api/admin-auth/login")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON))
|
|
|
+ .andReturn();
|
|
|
+
|
|
|
+ int httpStatus = mvcResult.getResponse().getStatus();
|
|
|
+ assertTrue(httpStatus >= 200 && httpStatus < 500,
|
|
|
+ "空 body 不得返回 5xx,实际 HTTP " + httpStatus);
|
|
|
+
|
|
|
+ // 若返回了业务 JSON,业务码也不得为 500
|
|
|
+ String body = mvcResult.getResponse().getContentAsString();
|
|
|
+ if (body != null && !body.isEmpty()) {
|
|
|
+ assertFalse(body.contains("\"code\":500"), "空 body 业务码不得为 500: " + body);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------
|
|
|
+ // (a/b) 空对象 {} 与缺 code → 400(参数校验,非 500)
|
|
|
+ // ---------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login HTTP:空对象 {}(历史实测触发 500 的 payload)→ 业务码 400")
|
|
|
+ void login_emptyObject_returns400() throws Exception {
|
|
|
+ mockMvc.perform(post("/api/admin-auth/login")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.message").value("手机号和验证码不能为空"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login HTTP:缺 code → 业务码 400(参数校验)")
|
|
|
+ void login_missingCode_returns400() throws Exception {
|
|
|
+ mockMvc.perform(post("/api/admin-auth/login")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"phone\":\"13800000001\"}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.message").value("手机号和验证码不能为空"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login HTTP:缺 phone → 业务码 400(参数校验)")
|
|
|
+ void login_missingPhone_returns400() throws Exception {
|
|
|
+ mockMvc.perform(post("/api/admin-auth/login")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"code\":\"123456\"}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.message").value("手机号和验证码不能为空"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------
|
|
|
+ // (c) 正常登录成功路径不受影响
|
|
|
+ // ---------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login HTTP:合法验证码登录 → 业务码 200 + token/adminId/role/roles")
|
|
|
+ void login_success_returnsToken() throws Exception {
|
|
|
+ User admin = adminUser(100L);
|
|
|
+ when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
|
|
|
+ when(verificationCodeService.verifyCode("13800000001", "123456", "admin_login")).thenReturn(true);
|
|
|
+ when(userMapper.selectOne(any())).thenReturn(admin);
|
|
|
+ when(userService.getUserRoles(100L)).thenReturn(Collections.singletonList("admin"));
|
|
|
+ when(jwtConfig.generateToken(eq(100L), anyList())).thenReturn("jwt-token");
|
|
|
+
|
|
|
+ mockMvc.perform(post("/api/admin-auth/login")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"phone\":\"13800000001\",\"code\":\"123456\"}"))
|
|
|
+ .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.role").value("admin"));
|
|
|
+
|
|
|
+ // 成功路径仍完整走验证码校验与登录尝试清零(业务逻辑不变;lockKey 格式为 phone:ip)
|
|
|
+ verify(verificationCodeService).verifyCode("13800000001", "123456", "admin_login");
|
|
|
+ verify(loginAttemptService).clearSuccess("13800000001:127.0.0.1");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------
|
|
|
+ // 同面公开端点:send-code / login-by-password 同类 R5 暴露(空 body/缺参 → 400)
|
|
|
+ // ---------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:空 body(dto==null)→ 400,无 NPE")
|
|
|
+ void loginByPassword_nullDto_returns400() {
|
|
|
+ HttpServletRequest request = mock(HttpServletRequest.class);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result = controller.loginByPassword(null, request);
|
|
|
+
|
|
|
+ assertEquals(400, result.getCode());
|
|
|
+ assertNotNull(result.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:缺密码 → 业务码 400(参数校验)")
|
|
|
+ void loginByPassword_missingPassword_returns400() {
|
|
|
+ AdminLoginDTO dto = new AdminLoginDTO();
|
|
|
+ dto.setPhone("13800000001");
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result = controller.loginByPassword(dto, mock(HttpServletRequest.class));
|
|
|
+
|
|
|
+ assertEquals(400, result.getCode());
|
|
|
+ assertEquals("用户名和密码不能为空", result.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("send-code:空 body(dto==null)→ 400,无 NPE")
|
|
|
+ void sendCode_nullDto_returns400() {
|
|
|
+ Result<Boolean> result = controller.sendCode(null);
|
|
|
+
|
|
|
+ assertEquals(400, result.getCode());
|
|
|
+ assertNotNull(result.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("send-code:缺手机号 → 业务码 400(参数校验)")
|
|
|
+ void sendCode_missingPhone_returns400() {
|
|
|
+ Result<Boolean> result = controller.sendCode(new com.etotem.cfc.dto.SendCodeDTO());
|
|
|
+
|
|
|
+ assertEquals(400, result.getCode());
|
|
|
+ assertEquals("手机号不能为空", result.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------
|
|
|
+ // helpers
|
|
|
+ // ---------------------------------------------------------------
|
|
|
+
|
|
|
+ private static User adminUser(Long id) {
|
|
|
+ User user = new User();
|
|
|
+ user.setId(id);
|
|
|
+ user.setRole("admin");
|
|
|
+ user.setRoles(new ArrayList<>(Collections.singletonList("admin")));
|
|
|
+ user.setNickname("nick" + id);
|
|
|
+ user.setRealName("管理员" + id);
|
|
|
+ user.setPhone("13800000001");
|
|
|
+ return user;
|
|
|
+ }
|
|
|
+}
|