UsrLoginIT.java 12.9 KB
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)。
 *
 * <p>@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1);
 * 真实 JwtUtil / BCryptPasswordEncoder。@AfterEach 按前缀清理 fixture
 * (用户前缀 it_login_,公司名前缀 IT_LOGIN_CO_)。登录用户 sPassword 用真实 encode("666666")。</p>
 */
@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.<UsrUser>lambdaQuery()
                .likeRight(UsrUser::getSUserName, USER_PREFIX));
        usrCompanyMapper.delete(Wrappers.<UsrCompany>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<String, Object> 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());
    }
}