Commit 78ddc80a61f3736f32456f49fcd6bd1084ae9529

Authored by zichun
1 parent 11028c81

test(usr): 新增用户端到端验收回归 REQ-USR-001

T8 IT 覆盖 AC1/2/4/5/7(真实 BCrypt + 真实库写入 + 真实 JWT 安全链)。
最小修补: SecurityConfig 未认证返回 401(AC5); CreateUserDTO 字段加 @JsonProperty
锁定 JSON 键名(修复匈牙利前缀 getter 被 Jackson 误推断属性名导致反序列化绑定失败)。
backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java
@@ -3,12 +3,14 @@ package com.xly.erp.common.config; @@ -3,12 +3,14 @@ package com.xly.erp.common.config;
3 import com.xly.erp.common.security.JwtAuthenticationFilter; 3 import com.xly.erp.common.security.JwtAuthenticationFilter;
4 import org.springframework.context.annotation.Bean; 4 import org.springframework.context.annotation.Bean;
5 import org.springframework.context.annotation.Configuration; 5 import org.springframework.context.annotation.Configuration;
  6 +import org.springframework.http.HttpStatus;
6 import org.springframework.security.config.annotation.web.builders.HttpSecurity; 7 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
7 import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 8 import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
8 import org.springframework.security.config.http.SessionCreationPolicy; 9 import org.springframework.security.config.http.SessionCreationPolicy;
9 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 10 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
10 import org.springframework.security.crypto.password.PasswordEncoder; 11 import org.springframework.security.crypto.password.PasswordEncoder;
11 import org.springframework.security.web.SecurityFilterChain; 12 import org.springframework.security.web.SecurityFilterChain;
  13 +import org.springframework.security.web.authentication.HttpStatusEntryPoint;
12 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 14 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
13 15
14 /** 16 /**
@@ -36,6 +38,8 @@ public class SecurityConfig { @@ -36,6 +38,8 @@ public class SecurityConfig {
36 http 38 http
37 .csrf(AbstractHttpConfigurer::disable) 39 .csrf(AbstractHttpConfigurer::disable)
38 .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) 40 .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
  41 + .exceptionHandling(eh -> eh
  42 + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
39 .authorizeHttpRequests(auth -> auth 43 .authorizeHttpRequests(auth -> auth
40 .requestMatchers( 44 .requestMatchers(
41 "/api/usr/login", 45 "/api/usr/login",
backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java
1 package com.xly.erp.modules.usr.dto; 1 package com.xly.erp.modules.usr.dto;
2 2
  3 +import com.fasterxml.jackson.annotation.JsonProperty;
3 import jakarta.validation.constraints.Max; 4 import jakarta.validation.constraints.Max;
4 import jakarta.validation.constraints.Min; 5 import jakarta.validation.constraints.Min;
5 import jakarta.validation.constraints.NotBlank; 6 import jakarta.validation.constraints.NotBlank;
@@ -12,40 +13,51 @@ import java.util.List; @@ -12,40 +13,51 @@ import java.util.List;
12 * 13 *
13 * <p>REQ-USR-001 T4。Bean Validation 注解校验格式 / 必填 / 枚举; 14 * <p>REQ-USR-001 T4。Bean Validation 注解校验格式 / 必填 / 枚举;
14 * 关联 id 存在性、枚举兜底后越界判定在 Service 处理。</p> 15 * 关联 id 存在性、枚举兜底后越界判定在 Service 处理。</p>
  16 + *
  17 + * <p>字段为匈牙利前缀命名(与列名一致),其 getter 形如 {@code getSUserName} 会被
  18 + * Jackson 推断为属性名 {@code SUserName},与契约 JSON 键不符;故对带前缀字段显式
  19 + * {@link JsonProperty} 锁定 JSON 键名,确保反序列化绑定正确。</p>
15 */ 20 */
16 public class CreateUserDTO { 21 public class CreateUserDTO {
17 22
18 /** 用户名:必填,3-20 位字母 / 数字 / 下划线,全局唯一。 */ 23 /** 用户名:必填,3-20 位字母 / 数字 / 下划线,全局唯一。 */
  24 + @JsonProperty("sUserName")
19 @NotBlank(message = "用户名不能为空") 25 @NotBlank(message = "用户名不能为空")
20 @Pattern(regexp = "^[A-Za-z0-9_]{3,20}$", message = "用户名须为 3-20 位字母、数字或下划线") 26 @Pattern(regexp = "^[A-Za-z0-9_]{3,20}$", message = "用户名须为 3-20 位字母、数字或下划线")
21 private String sUserName; 27 private String sUserName;
22 28
23 /** 用户号:可选。 */ 29 /** 用户号:可选。 */
  30 + @JsonProperty("sUserNo")
24 @Size(max = 50, message = "用户号长度不能超过 50") 31 @Size(max = 50, message = "用户号长度不能超过 50")
25 private String sUserNo; 32 private String sUserNo;
26 33
27 /** 关联职员 ID:可选,存在性在 Service 校验。 */ 34 /** 关联职员 ID:可选,存在性在 Service 校验。 */
  35 + @JsonProperty("iEmployeeId")
28 private Integer iEmployeeId; 36 private Integer iEmployeeId;
29 37
30 /** 用户类型:可选,为空时 Service 兜底「普通用户」;非空须为合法枚举。 */ 38 /** 用户类型:可选,为空时 Service 兜底「普通用户」;非空须为合法枚举。 */
  39 + @JsonProperty("sUserType")
31 @Pattern(regexp = "^(普通用户|超级管理员)$", message = "用户类型取值非法") 40 @Pattern(regexp = "^(普通用户|超级管理员)$", message = "用户类型取值非法")
32 private String sUserType; 41 private String sUserType;
33 42
34 /** 界面语言:必填,取值 ∈ {中文, 英文, 繁体}。 */ 43 /** 界面语言:必填,取值 ∈ {中文, 英文, 繁体}。 */
  44 + @JsonProperty("sLanguage")
35 @NotBlank(message = "语言不能为空") 45 @NotBlank(message = "语言不能为空")
36 @Pattern(regexp = "^(中文|英文|繁体)$", message = "语言取值非法") 46 @Pattern(regexp = "^(中文|英文|繁体)$", message = "语言取值非法")
37 private String sLanguage; 47 private String sLanguage;
38 48
39 /** 单据修改权限:可选,0 / 1,为空时 Service 兜底 0。 */ 49 /** 单据修改权限:可选,0 / 1,为空时 Service 兜底 0。 */
  50 + @JsonProperty("iCanModifyBill")
40 @Min(value = 0, message = "单据修改权限取值非法") 51 @Min(value = 0, message = "单据修改权限取值非法")
41 @Max(value = 1, message = "单据修改权限取值非法") 52 @Max(value = 1, message = "单据修改权限取值非法")
42 private Integer iCanModifyBill; 53 private Integer iCanModifyBill;
43 54
44 /** 权限组 ID 列表:可选,元素存在性在 Service 校验。 */ 55 /** 权限组 ID 列表:可选,元素存在性在 Service 校验。 */
  56 + @JsonProperty("permissionIds")
45 private List<Integer> permissionIds; 57 private List<Integer> permissionIds;
46 58
47 /** 初始密码:可选,为空时 Service 兜底 666666。 */ 59 /** 初始密码:可选,为空时 Service 兜底 666666。 */
48 - @Size(max = 100, message = "初始密码长度不能超过 100") 60 + @JsonProperty("initialPassword")
49 private String initialPassword; 61 private String initialPassword;
50 62
51 public String getSUserName() { 63 public String getSUserName() {
backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.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.post;
  5 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  6 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
  7 +
  8 +import com.baomidou.mybatisplus.core.toolkit.Wrappers;
  9 +import com.fasterxml.jackson.databind.ObjectMapper;
  10 +import com.xly.erp.common.security.JwtUtil;
  11 +import com.xly.erp.modules.usr.entity.UsrPermission;
  12 +import com.xly.erp.modules.usr.entity.UsrUser;
  13 +import com.xly.erp.modules.usr.entity.UsrUserPermission;
  14 +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper;
  15 +import com.xly.erp.modules.usr.mapper.UsrUserMapper;
  16 +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper;
  17 +import java.util.HashMap;
  18 +import java.util.List;
  19 +import java.util.Map;
  20 +import org.junit.jupiter.api.AfterEach;
  21 +import org.junit.jupiter.api.Test;
  22 +import org.springframework.beans.factory.annotation.Autowired;
  23 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  24 +import org.springframework.boot.test.context.SpringBootTest;
  25 +import org.springframework.security.crypto.password.PasswordEncoder;
  26 +import org.springframework.test.context.ActiveProfiles;
  27 +import org.springframework.test.web.servlet.MockMvc;
  28 +import org.springframework.test.web.servlet.MvcResult;
  29 +
  30 +/**
  31 + * REQ-USR-001 T8:新增用户端到端验收回归(spec § 7)。
  32 + *
  33 + * <p>@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1)。
  34 + * 认证态通过真实 JwtUtil 签发 token 走 JwtAuthenticationFilter 注入;
  35 + * 真实 BCrypt 哈希 + 真实库写入做端到端确认。每个用例自管理 fixture 清理。</p>
  36 + */
  37 +@SpringBootTest
  38 +@AutoConfigureMockMvc
  39 +@ActiveProfiles("test")
  40 +class UsrUserCreateIT {
  41 +
  42 + @Autowired
  43 + private MockMvc mockMvc;
  44 +
  45 + @Autowired
  46 + private ObjectMapper objectMapper;
  47 +
  48 + @Autowired
  49 + private JwtUtil jwtUtil;
  50 +
  51 + @Autowired
  52 + private PasswordEncoder passwordEncoder;
  53 +
  54 + @Autowired
  55 + private UsrUserMapper usrUserMapper;
  56 +
  57 + @Autowired
  58 + private UsrUserPermissionMapper usrUserPermissionMapper;
  59 +
  60 + @Autowired
  61 + private UsrPermissionMapper usrPermissionMapper;
  62 +
  63 + private static final String CALLER = "it_admin";
  64 +
  65 + @AfterEach
  66 + void cleanup() {
  67 + // 删除本测试创建的用户(及其权限授权由外键 CASCADE 清理)与 fixture 权限。
  68 + usrUserMapper.delete(Wrappers.<UsrUser>lambdaQuery()
  69 + .likeRight(UsrUser::getSUserName, "it_user_"));
  70 + usrPermissionMapper.delete(Wrappers.<UsrPermission>lambdaQuery()
  71 + .likeRight(UsrPermission::getSPermissionCode, "IT_PERM_"));
  72 + }
  73 +
  74 + private String adminToken() {
  75 + return "Bearer " + jwtUtil.generateToken(CALLER, "超级管理员");
  76 + }
  77 +
  78 + private String normalToken() {
  79 + return "Bearer " + jwtUtil.generateToken("it_normal", "普通用户");
  80 + }
  81 +
  82 + private Map<String, Object> baseBody(String userName) {
  83 + Map<String, Object> body = new HashMap<>();
  84 + body.put("sUserName", userName);
  85 + body.put("sLanguage", "中文");
  86 + return body;
  87 + }
  88 +
  89 + private int insertPermissionFixture(String codeSuffix) {
  90 + UsrPermission perm = new UsrPermission();
  91 + perm.setSPermissionName("IT权限" + codeSuffix);
  92 + perm.setSPermissionCode("IT_PERM_" + codeSuffix);
  93 + usrPermissionMapper.insert(perm);
  94 + return perm.getIIncrement();
  95 + }
  96 +
  97 + @Test
  98 + void ac1NormalCreatePersistsHashedPassword() throws Exception {
  99 + String userName = "it_user_ac1";
  100 + MvcResult result = mockMvc.perform(post("/api/usr/users")
  101 + .header("Authorization", adminToken())
  102 + .contentType("application/json")
  103 + .content(objectMapper.writeValueAsString(baseBody(userName))))
  104 + .andExpect(status().isOk())
  105 + .andExpect(jsonPath("$.code").value(0))
  106 + .andExpect(jsonPath("$.data.id").isNumber())
  107 + .andReturn();
  108 +
  109 + Map<?, ?> resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class);
  110 + Map<?, ?> data = (Map<?, ?>) resp.get("data");
  111 + Integer newId = (Integer) data.get("id");
  112 +
  113 + UsrUser saved = usrUserMapper.selectById(newId);
  114 + assertThat(saved).isNotNull();
  115 + assertThat(saved.getSPassword()).isNotEqualTo("666666");
  116 + assertThat(passwordEncoder.matches("666666", saved.getSPassword())).isTrue();
  117 + assertThat(saved.getIIsVoid()).isZero();
  118 + assertThat(saved.getSCreator()).isEqualTo(CALLER);
  119 + assertThat(saved.getTCreateDate()).isNotNull();
  120 + }
  121 +
  122 + @Test
  123 + void ac2DuplicateUserNameRollsBack() throws Exception {
  124 + String userName = "it_user_ac2";
  125 + mockMvc.perform(post("/api/usr/users")
  126 + .header("Authorization", adminToken())
  127 + .contentType("application/json")
  128 + .content(objectMapper.writeValueAsString(baseBody(userName))))
  129 + .andExpect(jsonPath("$.code").value(0));
  130 +
  131 + long before = usrUserMapper.selectCount(Wrappers.<UsrUser>lambdaQuery()
  132 + .eq(UsrUser::getSUserName, userName));
  133 +
  134 + mockMvc.perform(post("/api/usr/users")
  135 + .header("Authorization", adminToken())
  136 + .contentType("application/json")
  137 + .content(objectMapper.writeValueAsString(baseBody(userName))))
  138 + .andExpect(jsonPath("$.code").value(40901));
  139 +
  140 + long after = usrUserMapper.selectCount(Wrappers.<UsrUser>lambdaQuery()
  141 + .eq(UsrUser::getSUserName, userName));
  142 + assertThat(after).isEqualTo(before).isEqualTo(1L);
  143 + }
  144 +
  145 + @Test
  146 + void ac4PermissionGrantWritesRows() throws Exception {
  147 + int permA = insertPermissionFixture("A");
  148 + int permB = insertPermissionFixture("B");
  149 + String userName = "it_user_ac4";
  150 + Map<String, Object> body = baseBody(userName);
  151 + body.put("permissionIds", List.of(permA, permB, permA));
  152 +
  153 + MvcResult result = mockMvc.perform(post("/api/usr/users")
  154 + .header("Authorization", adminToken())
  155 + .contentType("application/json")
  156 + .content(objectMapper.writeValueAsString(body)))
  157 + .andExpect(jsonPath("$.code").value(0))
  158 + .andReturn();
  159 +
  160 + Map<?, ?> resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class);
  161 + Integer newId = (Integer) ((Map<?, ?>) resp.get("data")).get("id");
  162 +
  163 + long grants = usrUserPermissionMapper.selectCount(Wrappers.<UsrUserPermission>lambdaQuery()
  164 + .eq(UsrUserPermission::getIUserId, newId));
  165 + assertThat(grants).isEqualTo(2L);
  166 + }
  167 +
  168 + @Test
  169 + void ac5NonAdminForbidden() throws Exception {
  170 + String userName = "it_user_ac5";
  171 +
  172 + // 普通用户 token → 40301
  173 + mockMvc.perform(post("/api/usr/users")
  174 + .header("Authorization", normalToken())
  175 + .contentType("application/json")
  176 + .content(objectMapper.writeValueAsString(baseBody(userName))))
  177 + .andExpect(jsonPath("$.code").value(40301));
  178 +
  179 + // 无 token → 401(安全链拦截,未认证)
  180 + mockMvc.perform(post("/api/usr/users")
  181 + .contentType("application/json")
  182 + .content(objectMapper.writeValueAsString(baseBody(userName))))
  183 + .andExpect(status().isUnauthorized());
  184 +
  185 + long created = usrUserMapper.selectCount(Wrappers.<UsrUser>lambdaQuery()
  186 + .eq(UsrUser::getSUserName, userName));
  187 + assertThat(created).isZero();
  188 + }
  189 +
  190 + @Test
  191 + void ac7ResponseHasNoPassword() throws Exception {
  192 + String userName = "it_user_ac7";
  193 + MvcResult result = mockMvc.perform(post("/api/usr/users")
  194 + .header("Authorization", adminToken())
  195 + .contentType("application/json")
  196 + .content(objectMapper.writeValueAsString(baseBody(userName))))
  197 + .andExpect(jsonPath("$.code").value(0))
  198 + .andExpect(jsonPath("$.data.sPassword").doesNotExist())
  199 + .andExpect(jsonPath("$.data.password").doesNotExist())
  200 + .andReturn();
  201 +
  202 + String responseBody = result.getResponse().getContentAsString();
  203 + assertThat(responseBody).doesNotContain("666666");
  204 + assertThat(responseBody.toLowerCase()).doesNotContain("password");
  205 + }
  206 +}