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());
}
}