Commit f500f6129fd10247dfbe4eae9798ba15222f0acf

Authored by zichun
1 parent 307c37a2

test(usr): 登录端到端验收回归 REQ-USR-004

backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java 0 → 100644
  1 +package com.xly.erp.modules.usr;
  2 +
  3 +import static org.assertj.core.api.Assertions.assertThat;
  4 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
  5 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
  6 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  7 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
  8 +
  9 +import com.baomidou.mybatisplus.core.toolkit.Wrappers;
  10 +import com.fasterxml.jackson.databind.ObjectMapper;
  11 +import com.fasterxml.jackson.databind.JsonNode;
  12 +import com.xly.erp.modules.usr.entity.UsrCompany;
  13 +import com.xly.erp.modules.usr.entity.UsrUser;
  14 +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper;
  15 +import com.xly.erp.modules.usr.mapper.UsrUserMapper;
  16 +import java.nio.charset.StandardCharsets;
  17 +import java.time.LocalDateTime;
  18 +import java.util.Base64;
  19 +import java.util.HashMap;
  20 +import java.util.Map;
  21 +import org.junit.jupiter.api.AfterEach;
  22 +import org.junit.jupiter.api.BeforeEach;
  23 +import org.junit.jupiter.api.Test;
  24 +import org.springframework.beans.factory.annotation.Autowired;
  25 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  26 +import org.springframework.boot.test.context.SpringBootTest;
  27 +import org.springframework.security.crypto.password.PasswordEncoder;
  28 +import org.springframework.test.context.ActiveProfiles;
  29 +import org.springframework.test.web.servlet.MockMvc;
  30 +import org.springframework.test.web.servlet.MvcResult;
  31 +
  32 +/**
  33 + * REQ-USR-004 T7:登录端到端验收回归(spec § 7 验收标准 1-12)。
  34 + *
  35 + * <p>@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1);
  36 + * 真实 JwtUtil / BCryptPasswordEncoder。@AfterEach 按前缀清理 fixture
  37 + * (用户前缀 it_login_,公司名前缀 IT_LOGIN_CO_)。登录用户 sPassword 用真实 encode("666666")。</p>
  38 + */
  39 +@SpringBootTest
  40 +@AutoConfigureMockMvc
  41 +@ActiveProfiles("test")
  42 +class UsrLoginIT {
  43 +
  44 + private static final String USER_PREFIX = "it_login_";
  45 + private static final String RL_USER_PREFIX = "it_login_rl_";
  46 + private static final String CO_PREFIX = "IT_LOGIN_CO_";
  47 + private static final String RAW_PWD = "666666";
  48 +
  49 + @Autowired
  50 + private MockMvc mockMvc;
  51 +
  52 + @Autowired
  53 + private ObjectMapper objectMapper;
  54 +
  55 + @Autowired
  56 + private PasswordEncoder passwordEncoder;
  57 +
  58 + @Autowired
  59 + private UsrUserMapper usrUserMapper;
  60 +
  61 + @Autowired
  62 + private UsrCompanyMapper usrCompanyMapper;
  63 +
  64 + private Integer companyId;
  65 +
  66 + @BeforeEach
  67 + void seedCompany() {
  68 + UsrCompany c = new UsrCompany();
  69 + c.setSCompanyName(CO_PREFIX + "总部");
  70 + c.setSVersion("企业版");
  71 + usrCompanyMapper.insert(c);
  72 + companyId = c.getIIncrement();
  73 + }
  74 +
  75 + @AfterEach
  76 + void cleanup() {
  77 + usrUserMapper.delete(Wrappers.<UsrUser>lambdaQuery()
  78 + .likeRight(UsrUser::getSUserName, USER_PREFIX));
  79 + usrCompanyMapper.delete(Wrappers.<UsrCompany>lambdaQuery()
  80 + .likeRight(UsrCompany::getSCompanyName, CO_PREFIX));
  81 + }
  82 +
  83 + private Integer insertUser(String userName, int isVoid) {
  84 + UsrUser u = new UsrUser();
  85 + u.setSUserName(userName);
  86 + u.setSPassword(passwordEncoder.encode(RAW_PWD));
  87 + u.setSUserType("普通用户");
  88 + u.setSLanguage("中文");
  89 + u.setICanModifyBill(0);
  90 + u.setIIsVoid(isVoid);
  91 + u.setSCreator("it_login_seed");
  92 + usrUserMapper.insert(u);
  93 + return u.getIIncrement();
  94 + }
  95 +
  96 + private String loginBody(String userName, String password, Integer compId) throws Exception {
  97 + Map<String, Object> body = new HashMap<>();
  98 + if (userName != null) {
  99 + body.put("sUserName", userName);
  100 + }
  101 + if (password != null) {
  102 + body.put("password", password);
  103 + }
  104 + if (compId != null) {
  105 + body.put("companyId", compId);
  106 + }
  107 + return objectMapper.writeValueAsString(body);
  108 + }
  109 +
  110 + private MvcResult doLogin(String userName, String password, Integer compId) throws Exception {
  111 + return mockMvc.perform(post("/api/usr/login")
  112 + .contentType("application/json")
  113 + .content(loginBody(userName, password, compId)))
  114 + .andReturn();
  115 + }
  116 +
  117 + @Test
  118 + void ac1LoginSuccess() throws Exception {
  119 + String userName = USER_PREFIX + "ac1";
  120 + Integer id = insertUser(userName, 0);
  121 +
  122 + MvcResult result = mockMvc.perform(post("/api/usr/login")
  123 + .contentType("application/json")
  124 + .content(loginBody(userName, RAW_PWD, companyId)))
  125 + .andExpect(status().isOk())
  126 + .andExpect(jsonPath("$.code").value(0))
  127 + .andExpect(jsonPath("$.data.token").isNotEmpty())
  128 + .andExpect(jsonPath("$.data.user.id").value(id))
  129 + .andExpect(jsonPath("$.data.user.sUserName").value(userName))
  130 + .andExpect(jsonPath("$.data.user.sUserType").value("普通用户"))
  131 + .andExpect(jsonPath("$.data.user.sLanguage").value("中文"))
  132 + .andReturn();
  133 +
  134 + String resp = result.getResponse().getContentAsString();
  135 + assertThat(resp).doesNotContain("sPassword");
  136 + assertThat(resp.toLowerCase()).doesNotContain("password");
  137 + assertThat(resp).doesNotContain(RAW_PWD);
  138 + }
  139 +
  140 + @Test
  141 + void ac2TokenAcceptedByProtectedApi() throws Exception {
  142 + String userName = USER_PREFIX + "ac2";
  143 + insertUser(userName, 0);
  144 + MvcResult login = doLogin(userName, RAW_PWD, companyId);
  145 + String token = objectMapper.readTree(login.getResponse().getContentAsString())
  146 + .path("data").path("token").asText();
  147 + assertThat(token).isNotBlank();
  148 +
  149 + // 用 token 调受保护接口(REQ-USR-003)→ 非 401。
  150 + mockMvc.perform(get("/api/usr/users")
  151 + .param("pageNum", "1").param("pageSize", "10")
  152 + .header("Authorization", "Bearer " + token))
  153 + .andExpect(status().isOk())
  154 + .andExpect(jsonPath("$.code").value(0));
  155 + }
  156 +
  157 + @Test
  158 + void ac3LoginUpdatesLastLoginDate() throws Exception {
  159 + String userName = USER_PREFIX + "ac3";
  160 + Integer id = insertUser(userName, 0);
  161 + assertThat(usrUserMapper.selectById(id).getTLastLoginDate()).isNull();
  162 +
  163 + doLogin(userName, RAW_PWD, companyId);
  164 +
  165 + LocalDateTime after = usrUserMapper.selectById(id).getTLastLoginDate();
  166 + assertThat(after).isNotNull();
  167 + }
  168 +
  169 + @Test
  170 + void ac4WrongPassword40101() throws Exception {
  171 + String userName = USER_PREFIX + "ac4";
  172 + insertUser(userName, 0);
  173 +
  174 + MvcResult result = doLogin(userName, "wrongpwd", companyId);
  175 + JsonNode resp = objectMapper.readTree(result.getResponse().getContentAsString());
  176 + assertThat(resp.path("code").asInt()).isEqualTo(40101);
  177 + assertThat(resp.path("data").isNull()).isTrue();
  178 + assertThat(result.getResponse().getContentAsString()).doesNotContain("密码错误");
  179 + }
  180 +
  181 + @Test
  182 + void ac5UserNotFound40101SameAsWrongPassword() throws Exception {
  183 + String userName = USER_PREFIX + "ac5";
  184 + insertUser(userName, 0);
  185 +
  186 + // 密码错误响应
  187 + String wrongPwdMsg = objectMapper.readTree(
  188 + doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString())
  189 + .path("message").asText();
  190 +
  191 + // 用户不存在响应
  192 + JsonNode notFound = objectMapper.readTree(
  193 + doLogin(USER_PREFIX + "ghost_never_exists", RAW_PWD, companyId)
  194 + .getResponse().getContentAsString());
  195 +
  196 + assertThat(notFound.path("code").asInt()).isEqualTo(40101);
  197 + assertThat(notFound.path("message").asText()).isEqualTo(wrongPwdMsg);
  198 + }
  199 +
  200 + @Test
  201 + void ac6DisabledUser40302() throws Exception {
  202 + String userName = USER_PREFIX + "ac6";
  203 + Integer id = insertUser(userName, 1);
  204 +
  205 + MvcResult result = doLogin(userName, RAW_PWD, companyId);
  206 + JsonNode resp = objectMapper.readTree(result.getResponse().getContentAsString());
  207 + assertThat(resp.path("code").asInt()).isEqualTo(40302);
  208 + assertThat(resp.path("data").path("token").isMissingNode()
  209 + || resp.path("data").isNull()).isTrue();
  210 +
  211 + // 禁用登录不更新 tLastLoginDate。
  212 + assertThat(usrUserMapper.selectById(id).getTLastLoginDate()).isNull();
  213 + }
  214 +
  215 + @Test
  216 + void ac7MissingParam40001() throws Exception {
  217 + String userName = USER_PREFIX + "ac7";
  218 + insertUser(userName, 0);
  219 +
  220 + // 缺 sUserName
  221 + assertThat(objectMapper.readTree(doLogin(null, RAW_PWD, companyId)
  222 + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001);
  223 + // 缺 password
  224 + assertThat(objectMapper.readTree(doLogin(userName, null, companyId)
  225 + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001);
  226 + // 缺 companyId
  227 + assertThat(objectMapper.readTree(doLogin(userName, RAW_PWD, null)
  228 + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001);
  229 + }
  230 +
  231 + @Test
  232 + void ac8IllegalCompanyId40001() throws Exception {
  233 + String userName = USER_PREFIX + "ac8";
  234 + insertUser(userName, 0);
  235 +
  236 + JsonNode resp = objectMapper.readTree(
  237 + doLogin(userName, RAW_PWD, Integer.MAX_VALUE).getResponse().getContentAsString());
  238 + assertThat(resp.path("code").asInt()).isEqualTo(40001);
  239 + }
  240 +
  241 + @Test
  242 + void ac9RateLimitAfter5Fails42901() throws Exception {
  243 + String userName = RL_USER_PREFIX + "ac9";
  244 + insertUser(userName, 0);
  245 +
  246 + // 连续 5 次密码错误 → 每次 40101
  247 + for (int i = 0; i < 5; i++) {
  248 + JsonNode resp = objectMapper.readTree(
  249 + doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString());
  250 + assertThat(resp.path("code").asInt()).isEqualTo(40101);
  251 + }
  252 + // 第 6 次即使密码正确也 42901(锁定窗内)
  253 + JsonNode locked = objectMapper.readTree(
  254 + doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString());
  255 + assertThat(locked.path("code").asInt()).isEqualTo(42901);
  256 +
  257 + // 成功登录后计数清零分支:用独立用户验证 失败<5 → 成功 → 再失败仍不触发 42901。
  258 + String resetUser = RL_USER_PREFIX + "ac9_reset";
  259 + insertUser(resetUser, 0);
  260 + for (int i = 0; i < 3; i++) {
  261 + doLogin(resetUser, "wrongpwd", companyId);
  262 + }
  263 + // 一次成功清零
  264 + assertThat(objectMapper.readTree(doLogin(resetUser, RAW_PWD, companyId)
  265 + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(0);
  266 + // 再连续失败 5 次:第 5 次才到阈值,前几次仍 40101(验证计数已清零,未沿用旧计数)
  267 + JsonNode afterReset = objectMapper.readTree(
  268 + doLogin(resetUser, "wrongpwd", companyId).getResponse().getContentAsString());
  269 + assertThat(afterReset.path("code").asInt()).isEqualTo(40101);
  270 + }
  271 +
  272 + @Test
  273 + void ac10CompaniesListNoToken() throws Exception {
  274 + mockMvc.perform(get("/api/usr/companies"))
  275 + .andExpect(status().isOk())
  276 + .andExpect(jsonPath("$.code").value(0))
  277 + .andExpect(jsonPath("$.data").isArray())
  278 + .andExpect(jsonPath("$.data[?(@.sCompanyName == '" + CO_PREFIX + "总部')]").exists());
  279 + }
  280 +
  281 + @Test
  282 + void ac11PasswordNeverLeaks() throws Exception {
  283 + String userName = USER_PREFIX + "ac11";
  284 + insertUser(userName, 0);
  285 +
  286 + String ok = doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString();
  287 + String fail = doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString();
  288 +
  289 + for (String resp : new String[] {ok, fail}) {
  290 + assertThat(resp).doesNotContain("sPassword");
  291 + assertThat(resp.toLowerCase()).doesNotContain("password");
  292 + assertThat(resp).doesNotContain(RAW_PWD);
  293 + }
  294 + }
  295 +
  296 + @Test
  297 + void ac12JwtHasExpiry() throws Exception {
  298 + String userName = USER_PREFIX + "ac12";
  299 + insertUser(userName, 0);
  300 +
  301 + String token = objectMapper.readTree(
  302 + doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString())
  303 + .path("data").path("token").asText();
  304 + assertThat(token).isNotBlank();
  305 +
  306 + // 解析 JWT payload(第二段 base64url)→ 断言含 exp 且晚于 iat(有限有效期)。
  307 + String[] segments = token.split("\\.");
  308 + assertThat(segments.length).isEqualTo(3);
  309 + String payloadJson = new String(
  310 + Base64.getUrlDecoder().decode(segments[1]), StandardCharsets.UTF_8);
  311 + JsonNode payload = objectMapper.readTree(payloadJson);
  312 + assertThat(payload.has("exp")).isTrue();
  313 + assertThat(payload.has("iat")).isTrue();
  314 + assertThat(payload.path("exp").asLong()).isGreaterThan(payload.path("iat").asLong());
  315 + }
  316 +}
... ...