From 78ddc80a61f3736f32456f49fcd6bd1084ae9529 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 13:37:13 +0800 Subject: [PATCH] test(usr): 新增用户端到端验收回归 REQ-USR-001 --- backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java | 4 ++++ backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java | 14 +++++++++++++- backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java diff --git a/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java b/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java index 0bef1c0..42522d7 100644 --- a/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java +++ b/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java @@ -3,12 +3,14 @@ package com.xly.erp.common.config; import com.xly.erp.common.security.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; /** @@ -36,6 +38,8 @@ public class SecurityConfig { http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(eh -> eh + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/usr/login", diff --git a/backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java b/backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java index 289e611..c5c3c39 100644 --- a/backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java +++ b/backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java @@ -1,5 +1,6 @@ package com.xly.erp.modules.usr.dto; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -12,40 +13,51 @@ import java.util.List; * *

REQ-USR-001 T4。Bean Validation 注解校验格式 / 必填 / 枚举; * 关联 id 存在性、枚举兜底后越界判定在 Service 处理。

+ * + *

字段为匈牙利前缀命名(与列名一致),其 getter 形如 {@code getSUserName} 会被 + * Jackson 推断为属性名 {@code SUserName},与契约 JSON 键不符;故对带前缀字段显式 + * {@link JsonProperty} 锁定 JSON 键名,确保反序列化绑定正确。

*/ public class CreateUserDTO { /** 用户名:必填,3-20 位字母 / 数字 / 下划线,全局唯一。 */ + @JsonProperty("sUserName") @NotBlank(message = "用户名不能为空") @Pattern(regexp = "^[A-Za-z0-9_]{3,20}$", message = "用户名须为 3-20 位字母、数字或下划线") private String sUserName; /** 用户号:可选。 */ + @JsonProperty("sUserNo") @Size(max = 50, message = "用户号长度不能超过 50") private String sUserNo; /** 关联职员 ID:可选,存在性在 Service 校验。 */ + @JsonProperty("iEmployeeId") private Integer iEmployeeId; /** 用户类型:可选,为空时 Service 兜底「普通用户」;非空须为合法枚举。 */ + @JsonProperty("sUserType") @Pattern(regexp = "^(普通用户|超级管理员)$", message = "用户类型取值非法") private String sUserType; /** 界面语言:必填,取值 ∈ {中文, 英文, 繁体}。 */ + @JsonProperty("sLanguage") @NotBlank(message = "语言不能为空") @Pattern(regexp = "^(中文|英文|繁体)$", message = "语言取值非法") private String sLanguage; /** 单据修改权限:可选,0 / 1,为空时 Service 兜底 0。 */ + @JsonProperty("iCanModifyBill") @Min(value = 0, message = "单据修改权限取值非法") @Max(value = 1, message = "单据修改权限取值非法") private Integer iCanModifyBill; /** 权限组 ID 列表:可选,元素存在性在 Service 校验。 */ + @JsonProperty("permissionIds") private List permissionIds; /** 初始密码:可选,为空时 Service 兜底 666666。 */ - @Size(max = 100, message = "初始密码长度不能超过 100") + @JsonProperty("initialPassword") private String initialPassword; public String getSUserName() { diff --git a/backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java b/backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java new file mode 100644 index 0000000..964f3f8 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java @@ -0,0 +1,206 @@ +package com.xly.erp.modules.usr; + +import static org.assertj.core.api.Assertions.assertThat; +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.xly.erp.common.security.JwtUtil; +import com.xly.erp.modules.usr.entity.UsrPermission; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.entity.UsrUserPermission; +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper; +import com.xly.erp.modules.usr.mapper.UsrUserMapper; +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +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-001 T8:新增用户端到端验收回归(spec § 7)。 + * + *

@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1)。 + * 认证态通过真实 JwtUtil 签发 token 走 JwtAuthenticationFilter 注入; + * 真实 BCrypt 哈希 + 真实库写入做端到端确认。每个用例自管理 fixture 清理。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class UsrUserCreateIT { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private UsrUserMapper usrUserMapper; + + @Autowired + private UsrUserPermissionMapper usrUserPermissionMapper; + + @Autowired + private UsrPermissionMapper usrPermissionMapper; + + private static final String CALLER = "it_admin"; + + @AfterEach + void cleanup() { + // 删除本测试创建的用户(及其权限授权由外键 CASCADE 清理)与 fixture 权限。 + usrUserMapper.delete(Wrappers.lambdaQuery() + .likeRight(UsrUser::getSUserName, "it_user_")); + usrPermissionMapper.delete(Wrappers.lambdaQuery() + .likeRight(UsrPermission::getSPermissionCode, "IT_PERM_")); + } + + private String adminToken() { + return "Bearer " + jwtUtil.generateToken(CALLER, "超级管理员"); + } + + private String normalToken() { + return "Bearer " + jwtUtil.generateToken("it_normal", "普通用户"); + } + + private Map baseBody(String userName) { + Map body = new HashMap<>(); + body.put("sUserName", userName); + body.put("sLanguage", "中文"); + return body; + } + + private int insertPermissionFixture(String codeSuffix) { + UsrPermission perm = new UsrPermission(); + perm.setSPermissionName("IT权限" + codeSuffix); + perm.setSPermissionCode("IT_PERM_" + codeSuffix); + usrPermissionMapper.insert(perm); + return perm.getIIncrement(); + } + + @Test + void ac1NormalCreatePersistsHashedPassword() throws Exception { + String userName = "it_user_ac1"; + MvcResult result = mockMvc.perform(post("/api/usr/users") + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(baseBody(userName)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").isNumber()) + .andReturn(); + + Map resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class); + Map data = (Map) resp.get("data"); + Integer newId = (Integer) data.get("id"); + + UsrUser saved = usrUserMapper.selectById(newId); + assertThat(saved).isNotNull(); + assertThat(saved.getSPassword()).isNotEqualTo("666666"); + assertThat(passwordEncoder.matches("666666", saved.getSPassword())).isTrue(); + assertThat(saved.getIIsVoid()).isZero(); + assertThat(saved.getSCreator()).isEqualTo(CALLER); + assertThat(saved.getTCreateDate()).isNotNull(); + } + + @Test + void ac2DuplicateUserNameRollsBack() throws Exception { + String userName = "it_user_ac2"; + mockMvc.perform(post("/api/usr/users") + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(baseBody(userName)))) + .andExpect(jsonPath("$.code").value(0)); + + long before = usrUserMapper.selectCount(Wrappers.lambdaQuery() + .eq(UsrUser::getSUserName, userName)); + + mockMvc.perform(post("/api/usr/users") + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(baseBody(userName)))) + .andExpect(jsonPath("$.code").value(40901)); + + long after = usrUserMapper.selectCount(Wrappers.lambdaQuery() + .eq(UsrUser::getSUserName, userName)); + assertThat(after).isEqualTo(before).isEqualTo(1L); + } + + @Test + void ac4PermissionGrantWritesRows() throws Exception { + int permA = insertPermissionFixture("A"); + int permB = insertPermissionFixture("B"); + String userName = "it_user_ac4"; + Map body = baseBody(userName); + body.put("permissionIds", List.of(permA, permB, permA)); + + MvcResult result = mockMvc.perform(post("/api/usr/users") + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(body))) + .andExpect(jsonPath("$.code").value(0)) + .andReturn(); + + Map resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class); + Integer newId = (Integer) ((Map) resp.get("data")).get("id"); + + long grants = usrUserPermissionMapper.selectCount(Wrappers.lambdaQuery() + .eq(UsrUserPermission::getIUserId, newId)); + assertThat(grants).isEqualTo(2L); + } + + @Test + void ac5NonAdminForbidden() throws Exception { + String userName = "it_user_ac5"; + + // 普通用户 token → 40301 + mockMvc.perform(post("/api/usr/users") + .header("Authorization", normalToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(baseBody(userName)))) + .andExpect(jsonPath("$.code").value(40301)); + + // 无 token → 401(安全链拦截,未认证) + mockMvc.perform(post("/api/usr/users") + .contentType("application/json") + .content(objectMapper.writeValueAsString(baseBody(userName)))) + .andExpect(status().isUnauthorized()); + + long created = usrUserMapper.selectCount(Wrappers.lambdaQuery() + .eq(UsrUser::getSUserName, userName)); + assertThat(created).isZero(); + } + + @Test + void ac7ResponseHasNoPassword() throws Exception { + String userName = "it_user_ac7"; + MvcResult result = mockMvc.perform(post("/api/usr/users") + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(baseBody(userName)))) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) + .andExpect(jsonPath("$.data.password").doesNotExist()) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + assertThat(responseBody).doesNotContain("666666"); + assertThat(responseBody.toLowerCase()).doesNotContain("password"); + } +} -- libgit2 0.22.2