From f500f6129fd10247dfbe4eae9798ba15222f0acf Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 15:19:00 +0800 Subject: [PATCH] test(usr): 登录端到端验收回归 REQ-USR-004 --- backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java | 316 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+), 0 deletions(-) create mode 100644 backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java diff --git a/backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java b/backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java new file mode 100644 index 0000000..0147222 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java @@ -0,0 +1,316 @@ +package com.xly.erp.modules.usr; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.xly.erp.modules.usr.entity.UsrCompany; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper; +import com.xly.erp.modules.usr.mapper.UsrUserMapper; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * REQ-USR-004 T7:登录端到端验收回归(spec § 7 验收标准 1-12)。 + * + *

@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1); + * 真实 JwtUtil / BCryptPasswordEncoder。@AfterEach 按前缀清理 fixture + * (用户前缀 it_login_,公司名前缀 IT_LOGIN_CO_)。登录用户 sPassword 用真实 encode("666666")。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class UsrLoginIT { + + private static final String USER_PREFIX = "it_login_"; + private static final String RL_USER_PREFIX = "it_login_rl_"; + private static final String CO_PREFIX = "IT_LOGIN_CO_"; + private static final String RAW_PWD = "666666"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private UsrUserMapper usrUserMapper; + + @Autowired + private UsrCompanyMapper usrCompanyMapper; + + private Integer companyId; + + @BeforeEach + void seedCompany() { + UsrCompany c = new UsrCompany(); + c.setSCompanyName(CO_PREFIX + "总部"); + c.setSVersion("企业版"); + usrCompanyMapper.insert(c); + companyId = c.getIIncrement(); + } + + @AfterEach + void cleanup() { + usrUserMapper.delete(Wrappers.lambdaQuery() + .likeRight(UsrUser::getSUserName, USER_PREFIX)); + usrCompanyMapper.delete(Wrappers.lambdaQuery() + .likeRight(UsrCompany::getSCompanyName, CO_PREFIX)); + } + + private Integer insertUser(String userName, int isVoid) { + UsrUser u = new UsrUser(); + u.setSUserName(userName); + u.setSPassword(passwordEncoder.encode(RAW_PWD)); + u.setSUserType("普通用户"); + u.setSLanguage("中文"); + u.setICanModifyBill(0); + u.setIIsVoid(isVoid); + u.setSCreator("it_login_seed"); + usrUserMapper.insert(u); + return u.getIIncrement(); + } + + private String loginBody(String userName, String password, Integer compId) throws Exception { + Map body = new HashMap<>(); + if (userName != null) { + body.put("sUserName", userName); + } + if (password != null) { + body.put("password", password); + } + if (compId != null) { + body.put("companyId", compId); + } + return objectMapper.writeValueAsString(body); + } + + private MvcResult doLogin(String userName, String password, Integer compId) throws Exception { + return mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(loginBody(userName, password, compId))) + .andReturn(); + } + + @Test + void ac1LoginSuccess() throws Exception { + String userName = USER_PREFIX + "ac1"; + Integer id = insertUser(userName, 0); + + MvcResult result = mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(loginBody(userName, RAW_PWD, companyId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.token").isNotEmpty()) + .andExpect(jsonPath("$.data.user.id").value(id)) + .andExpect(jsonPath("$.data.user.sUserName").value(userName)) + .andExpect(jsonPath("$.data.user.sUserType").value("普通用户")) + .andExpect(jsonPath("$.data.user.sLanguage").value("中文")) + .andReturn(); + + String resp = result.getResponse().getContentAsString(); + assertThat(resp).doesNotContain("sPassword"); + assertThat(resp.toLowerCase()).doesNotContain("password"); + assertThat(resp).doesNotContain(RAW_PWD); + } + + @Test + void ac2TokenAcceptedByProtectedApi() throws Exception { + String userName = USER_PREFIX + "ac2"; + insertUser(userName, 0); + MvcResult login = doLogin(userName, RAW_PWD, companyId); + String token = objectMapper.readTree(login.getResponse().getContentAsString()) + .path("data").path("token").asText(); + assertThat(token).isNotBlank(); + + // 用 token 调受保护接口(REQ-USR-003)→ 非 401。 + mockMvc.perform(get("/api/usr/users") + .param("pageNum", "1").param("pageSize", "10") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void ac3LoginUpdatesLastLoginDate() throws Exception { + String userName = USER_PREFIX + "ac3"; + Integer id = insertUser(userName, 0); + assertThat(usrUserMapper.selectById(id).getTLastLoginDate()).isNull(); + + doLogin(userName, RAW_PWD, companyId); + + LocalDateTime after = usrUserMapper.selectById(id).getTLastLoginDate(); + assertThat(after).isNotNull(); + } + + @Test + void ac4WrongPassword40101() throws Exception { + String userName = USER_PREFIX + "ac4"; + insertUser(userName, 0); + + MvcResult result = doLogin(userName, "wrongpwd", companyId); + JsonNode resp = objectMapper.readTree(result.getResponse().getContentAsString()); + assertThat(resp.path("code").asInt()).isEqualTo(40101); + assertThat(resp.path("data").isNull()).isTrue(); + assertThat(result.getResponse().getContentAsString()).doesNotContain("密码错误"); + } + + @Test + void ac5UserNotFound40101SameAsWrongPassword() throws Exception { + String userName = USER_PREFIX + "ac5"; + insertUser(userName, 0); + + // 密码错误响应 + String wrongPwdMsg = objectMapper.readTree( + doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString()) + .path("message").asText(); + + // 用户不存在响应 + JsonNode notFound = objectMapper.readTree( + doLogin(USER_PREFIX + "ghost_never_exists", RAW_PWD, companyId) + .getResponse().getContentAsString()); + + assertThat(notFound.path("code").asInt()).isEqualTo(40101); + assertThat(notFound.path("message").asText()).isEqualTo(wrongPwdMsg); + } + + @Test + void ac6DisabledUser40302() throws Exception { + String userName = USER_PREFIX + "ac6"; + Integer id = insertUser(userName, 1); + + MvcResult result = doLogin(userName, RAW_PWD, companyId); + JsonNode resp = objectMapper.readTree(result.getResponse().getContentAsString()); + assertThat(resp.path("code").asInt()).isEqualTo(40302); + assertThat(resp.path("data").path("token").isMissingNode() + || resp.path("data").isNull()).isTrue(); + + // 禁用登录不更新 tLastLoginDate。 + assertThat(usrUserMapper.selectById(id).getTLastLoginDate()).isNull(); + } + + @Test + void ac7MissingParam40001() throws Exception { + String userName = USER_PREFIX + "ac7"; + insertUser(userName, 0); + + // 缺 sUserName + assertThat(objectMapper.readTree(doLogin(null, RAW_PWD, companyId) + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001); + // 缺 password + assertThat(objectMapper.readTree(doLogin(userName, null, companyId) + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001); + // 缺 companyId + assertThat(objectMapper.readTree(doLogin(userName, RAW_PWD, null) + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001); + } + + @Test + void ac8IllegalCompanyId40001() throws Exception { + String userName = USER_PREFIX + "ac8"; + insertUser(userName, 0); + + JsonNode resp = objectMapper.readTree( + doLogin(userName, RAW_PWD, Integer.MAX_VALUE).getResponse().getContentAsString()); + assertThat(resp.path("code").asInt()).isEqualTo(40001); + } + + @Test + void ac9RateLimitAfter5Fails42901() throws Exception { + String userName = RL_USER_PREFIX + "ac9"; + insertUser(userName, 0); + + // 连续 5 次密码错误 → 每次 40101 + for (int i = 0; i < 5; i++) { + JsonNode resp = objectMapper.readTree( + doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString()); + assertThat(resp.path("code").asInt()).isEqualTo(40101); + } + // 第 6 次即使密码正确也 42901(锁定窗内) + JsonNode locked = objectMapper.readTree( + doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString()); + assertThat(locked.path("code").asInt()).isEqualTo(42901); + + // 成功登录后计数清零分支:用独立用户验证 失败<5 → 成功 → 再失败仍不触发 42901。 + String resetUser = RL_USER_PREFIX + "ac9_reset"; + insertUser(resetUser, 0); + for (int i = 0; i < 3; i++) { + doLogin(resetUser, "wrongpwd", companyId); + } + // 一次成功清零 + assertThat(objectMapper.readTree(doLogin(resetUser, RAW_PWD, companyId) + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(0); + // 再连续失败 5 次:第 5 次才到阈值,前几次仍 40101(验证计数已清零,未沿用旧计数) + JsonNode afterReset = objectMapper.readTree( + doLogin(resetUser, "wrongpwd", companyId).getResponse().getContentAsString()); + assertThat(afterReset.path("code").asInt()).isEqualTo(40101); + } + + @Test + void ac10CompaniesListNoToken() throws Exception { + mockMvc.perform(get("/api/usr/companies")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[?(@.sCompanyName == '" + CO_PREFIX + "总部')]").exists()); + } + + @Test + void ac11PasswordNeverLeaks() throws Exception { + String userName = USER_PREFIX + "ac11"; + insertUser(userName, 0); + + String ok = doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString(); + String fail = doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString(); + + for (String resp : new String[] {ok, fail}) { + assertThat(resp).doesNotContain("sPassword"); + assertThat(resp.toLowerCase()).doesNotContain("password"); + assertThat(resp).doesNotContain(RAW_PWD); + } + } + + @Test + void ac12JwtHasExpiry() throws Exception { + String userName = USER_PREFIX + "ac12"; + insertUser(userName, 0); + + String token = objectMapper.readTree( + doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString()) + .path("data").path("token").asText(); + assertThat(token).isNotBlank(); + + // 解析 JWT payload(第二段 base64url)→ 断言含 exp 且晚于 iat(有限有效期)。 + String[] segments = token.split("\\."); + assertThat(segments.length).isEqualTo(3); + String payloadJson = new String( + Base64.getUrlDecoder().decode(segments[1]), StandardCharsets.UTF_8); + JsonNode payload = objectMapper.readTree(payloadJson); + assertThat(payload.has("exp")).isTrue(); + assertThat(payload.has("iat")).isTrue(); + assertThat(payload.path("exp").asLong()).isGreaterThan(payload.path("iat").asLong()); + } +} -- libgit2 0.22.2