Commit 78ddc80a61f3736f32456f49fcd6bd1084ae9529
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 误推断属性名导致反序列化绑定失败)。
Showing
3 changed files
with
223 additions
and
1 deletions
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 | +} |