57 changed files with 3514 additions and 337 deletions
@ -0,0 +1,497 @@ |
|||
# R01-B 角色授权与异常映射收束报告 |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`84fe9f5febcf56910d5c978dd44e0e16d15d7209`(A6 后夹具修复提交) |
|||
本轮性质:**实施与自动化验证** —— 未暂存、未提交、未推送,待独立代码复验 |
|||
范围:**B1 角色授权重复收束 + B2 异常到 HTTP 状态映射收束**(不拆分子阶段,不进入 R01-C/E) |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-B角色授权与异常映射收束完成,待独立代码复验。** |
|||
|
|||
- 角色授权重复逻辑收束到唯一实现点 `security/UserRoleGuard`,**54 个调用点**改走公共 guard; |
|||
角色 guard 方法定义 **32 → 21**,`getRoles()` 角色比较 **46 处 / 31 文件 → 23 处 / 16 文件**; |
|||
- HTTP 状态映射收束到 `common/exception/ErrorHttpStatusRegistry`,`GlobalExceptionHandler` |
|||
不再持有映射表;**112 个 ErrorCode 的映射逐项保持不变**,未映射的 54 个仍回退 400; |
|||
- 收束过程中发现一处既有潜在缺陷(`ErrorCode` 166 个常量只有 165 个唯一数字码), |
|||
**已记录、未擅自修改**(见 §9.4)。 |
|||
|
|||
--- |
|||
|
|||
## 1. 前置 Git 状态 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `git branch --show-current` | `refactor/r01-structure-performance` | |
|||
| `git rev-parse HEAD` | `84fe9f5febcf56910d5c978dd44e0e16d15d7209` | |
|||
| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` | |
|||
| `git diff --cached --name-only` | **空** | |
|||
| `git diff --check` | 仅 LF/CRLF 警告,**exit 0** | |
|||
| 与目标文件重叠的未提交修改 | **无**。`GlobalExceptionHandler.java` 在 `git status` 中显示 `M`,但 `git hash-object` = `git rev-parse HEAD:<file>` = `1fdc341e…`,`git diff --quiet` exit 0 —— 属 Windows stat 缓存假阳性,非真实修改 | |
|||
|
|||
--- |
|||
|
|||
## 2. 角色授权库存(重构前) |
|||
|
|||
搜索范围:`mydreamjob-server/src/main/java`。 |
|||
重构前共 **32 个**角色 guard 方法定义 + **7 个** `private boolean hasRole(User, Role)` 助手。 |
|||
|
|||
**共同语义(近乎逐字相同的复制)**: |
|||
|
|||
``` |
|||
User user = userRepository.findById(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
boolean ok = user.getRoles().stream().anyMatch(r -> r == User.Role.X); // user_roles |
|||
if (!ok) throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
``` |
|||
|
|||
| 项 | 统一事实 | |
|||
|---|---| |
|||
| 输入来源 | 已解析出的 `userId`(由 `CurrentUserAccessor` 或调用方传入),**没有任何一处从请求参数取角色** | |
|||
| 角色来源 | **`user_roles` 关联表**(`user.getRoles()`)—— 31 个文件、46 处 `getRoles()`;`users.role` 单值列**不**参与授权判定(它只用于 JWT 声明签发) | |
|||
| 失败错误码 | 用户不存在 → `USER_NOT_FOUND`(4001);角色不符 → `FORBIDDEN`(2002) | |
|||
| HTTP 状态 | 4001 → 404;2002 → 403(经 §3 的映射表) | |
|||
| 是否含资源归属 | **不含**。归属(如 `assertOwnerOrThrow`、`findByOwnerUserId`)由各业务 Service 独立执行 | |
|||
| 是否允许 ADMIN | **不允许**(无隐式超级权限);`requireAny` 由调用方显式列出 ADMIN 才放行 | |
|||
| 可否安全委托 | 纯 void 角色门禁 → **可**;返回 `Company` 实体的 `requireCompany` → **不可整体委托**(见 §7) | |
|||
|
|||
--- |
|||
|
|||
## 3. HTTP 映射库存(重构前) |
|||
|
|||
`GlobalExceptionHandler.buildBusinessHttpStatuses()`(第 71–136 行)+ `register()`(第 138–147 行) |
|||
+ `resolveHttpStatus()`(第 67–69 行),共 **112 个 `ErrorCode` 引用**: |
|||
|
|||
| HttpStatus | 数量 | 备注 | |
|||
|---|---|---| |
|||
| CONFLICT | 77 | | |
|||
| NOT_FOUND | 27 | | |
|||
| FORBIDDEN | 5 | `FORBIDDEN`、`INTERVIEW_ACCESS_DENIED`、`APPLICATION_OWNER_MISMATCH`、`MATCH_REQUIRED_FOR_CONVERSATION`、`OFFER_COMPARE_NOT_OWNER` | |
|||
| TOO_MANY_REQUESTS | 3 | | |
|||
| **合计** | **112** | | |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 重复键 | **0**(112 个 `ErrorCode` 常量互不相同) | |
|||
| 重复项映射一致性 | 不适用 | |
|||
| 未显式映射的 ErrorCode | **54 / 166** → 一律回退 **HTTP 400** | |
|||
| 关键既有语义 | `UNAUTHORIZED(2001)` **未显式映射 → 400**(不是 401);`SERVICE_UNAVAILABLE`、`RATE_LIMITED`、`INTERNAL_ERROR` 同为 **400** | |
|||
| 已有测试锁定状态码 | **有**:`GlobalExceptionHandlerStatusContractTest` 以 `@EnumSource(ErrorCode.class)` 对**全部 166 个**错误码锁定状态 + 业务码 + message + requestId | |
|||
|
|||
**快照已保存**:`docs/acceptance/r01-b/b2-mapping-baseline.json`(112 条 `名称 → 状态`), |
|||
并逐条转写进 `GlobalExceptionHandlerMappingParityTest` 作为独立基线。 |
|||
|
|||
--- |
|||
|
|||
## 4. 修复前失败测试(红灯) |
|||
|
|||
| 观察 | 说明 | |
|||
|---|---| |
|||
| 角色授权 | 重构前**没有**公共 guard:32 个方法各自实现,无任何调用点经过统一入口。新增的 `RoleAuthorizationIntegrationTest` 以 `@SpyBean UserRoleGuard` 断言"端点确实调用公共 guard"——在重构前该 bean 不存在,测试无法编译/通过 | |
|||
| HTTP 映射 | 重构前映射表在 `GlobalExceptionHandler` 内部,**无法在初始化期检出重复键**(`register()` 仅在"同码不同状态"时抛错,同码同状态被静默容忍);新增的 `ErrorHttpStatusRegistryTest` 直接断言"同码重复注册必须抛 `IllegalStateException`"——重构前无从表达 | |
|||
| 未改变任何既有业务期望 | `GlobalExceptionHandlerStatusContractTest` 的 4 个状态桶与全部断言**逐字保留**,仅把其反射调用私有方法的用例改为调用 `registry.resolve(int)` | |
|||
|
|||
--- |
|||
|
|||
## 5. 公共角色 guard 设计 |
|||
|
|||
新增 `security/UserRoleGuard.java`(`@Component`,仅依赖 `UserRepository`)。 |
|||
|
|||
```java |
|||
void requireStudent(Long userId); void requireStudent(User user); |
|||
void requireCompany(Long userId); void requireCompany(User user); |
|||
void requireAdmin(Long userId); void requireAdmin(User user); |
|||
void requireAny(Long userId, User.Role... allowedRoles); |
|||
void requireAny(User user, User.Role... allowedRoles); |
|||
User requireUser(Long userId); // 存在性 + active 校验 |
|||
void requireRole(User user, User.Role... allowedRoles); |
|||
``` |
|||
|
|||
| 要求 | 落实 | |
|||
|---|---| |
|||
| 角色来自权威数据库 | 从 `user_roles`(`user.getRoles()`)读取,与全部被替换的实现一致 | |
|||
| 校验存在且 active | `requireUser` 不存在 → `USER_NOT_FOUND`;**存在但 inactive → `FORBIDDEN`(失败关闭)** | |
|||
| `users.role` 与 `user_roles` 核对 | 授权**只**依据 `user_roles`;`users.role` 与之不一致时按 `user_roles` 判定 → 缺少所需角色即拒绝(失败关闭)。**混用两者会改变既有语义,故未采纳** | |
|||
| 复用现有 ErrorCode | 只用 `USER_NOT_FOUND` / `FORBIDDEN`,**未新增任何 ErrorCode** | |
|||
| 不解析 SecurityContext | 只接收 `userId`;结构断言验证其声明字段**只有** `UserRepository` | |
|||
| 不处理 requestId | 无 `HttpServletRequest`、无 `RequestIdAccessor` | |
|||
| 不处理资源归属 | 类中无任何 ownership 逻辑 | |
|||
| 不记录敏感信息 | 无日志、无审计写入,无副作用 | |
|||
| 无隐式 ADMIN | `requireStudent`/`requireCompany` 对 ADMIN 同样拒绝,测试已锁定 | |
|||
| 查询次数受控 | `requireXxx(Long)` 恰好 **1** 次 `findById`;`requireXxx(User)` **0** 次;**无跨请求缓存**(用户被禁用/注销后立即失效) | |
|||
|
|||
检索确认:仓库中**原本不存在** `UserRoleGuard` 或等价的公共角色组件,故新增; |
|||
未新建重复角色枚举(复用既有 `User.Role`)。 |
|||
|
|||
--- |
|||
|
|||
## 6. 每个迁移调用点(54 处) |
|||
|
|||
`userRoleGuard.requireStudent` **37** 处、`requireAdmin` **13** 处、`requireCompany` **4** 处, |
|||
覆盖 **21 个生产文件**。 |
|||
|
|||
| # | 文件 | 原方法 | 迁移后 | |
|||
|---|---|---|---| |
|||
| 1 | `ability/AbilityAttemptService` | `requireStudent`(私有) | 删除,6 处改调 guard | |
|||
| 2 | `assessment/AssessmentAttemptService` | `requireStudent`(私有) | 删除,5 处改调 guard | |
|||
| 3 | `bidirectionalmatch/MatchRuleService` | `requireStudent`(私有) | 删除,2 处改调 guard | |
|||
| 4 | `careerprogress/CareerProgressService` | `assertStudentRole`(私有) | 删除,1 处改调 guard | |
|||
| 5 | `competition/CompetitionRankingService` | `assertStudentRole`(私有) | 删除,1 处改调 guard | |
|||
| 6 | `job/JobFavoriteService` | `assertStudentRole`(私有) | 删除,3 处改调 guard | |
|||
| 7 | `match/MatchService` | `assertStudentRole`(私有) | 删除,1 处改调 guard | |
|||
| 8 | `review/JobReviewService` | `assertStudentRole`+`assertAdminRole`(私有) | 删除,3+3 处改调 guard | |
|||
| 9 | `talentdiscovery/TalentDiscoveryService` | `requireStudent`(私有) | 删除,2 处改调 guard | |
|||
| 10 | `talentinvitation/TalentInvitationService` | `requireStudent`(私有) | 删除,3 处改调 guard | |
|||
| 11 | `recommendation/JobRecommendationService` | `requireStudent`+`requireAdmin`(私有) | 删除,3+2 处改调 guard | |
|||
| 12 | `candidatescore/governance/CandidateScoringGovernanceService` | `assertStudentRole`+`assertAdminRole`(私有) | 删除,3+4 处改调 guard | |
|||
| 13 | `candidatescore/dashboard/CompanyScoringDashboardService` | `assertAdminRole`(私有) | 删除,1 处改调 guard | |
|||
| 14 | `application/ApplicationController` | `assertCompanyRole`(私有) | 删除,3 处改调 guard | |
|||
| 15 | `safety/AdminReportController` | `requireAdmin(HttpServletRequest)` | 保留方法(返回 userId),角色部分委托 guard,3 处调用不变 | |
|||
| 16 | `risk/AdminRiskController` | `requireAdmin(HttpServletRequest)` | 同上,7 处调用不变;随之移除已不需要的 `UserRepository` 字段与 import | |
|||
| 17 | `company/CompanyService` | `assertStudentRole`+`assertCompanyRole`(公开) | 保留签名,方法体改为一行委托 | |
|||
| 18 | `profile/ProfileService` | `assertStudentRole`(公开) | 保留签名,一行委托 | |
|||
| 19 | `preference/PreferenceService` | `assertStudentRole`(公开) | 保留签名,一行委托 | |
|||
| 20 | `privacy/PrivacySettingsService` | `assertStudentRole`(公开) | 保留签名,一行委托 | |
|||
| 21 | `verification/CompanyVerificationService` | `assertAdminRole`(公开) | 保留签名,一行委托 | |
|||
|
|||
**公开方法为何保留签名**:它们已被跨包调用(如 `profileService.assertStudentRole` 被 |
|||
`ResumeVersionService`、`ResumeExportService`、`ProfileController` 等调用; |
|||
`companyService.assertStudentRole` 被 `CandidateScoreCardController`、`CandidateScoringController`、 |
|||
`StudentReviewWeightService` 调用)。保留签名、把**实现**收敛为一行委托,可在不扩大改动面的前提下 |
|||
消除重复逻辑。**每个调用点的原错误码(`USER_NOT_FOUND`/`FORBIDDEN`)、原角色集合、 |
|||
原调用顺序均未改变。** |
|||
|
|||
--- |
|||
|
|||
## 7. 保留调用点及理由(非等价,未迁移) |
|||
|
|||
| 类别 | 数量 | 保留理由 | |
|||
|---|---|---| |
|||
| `private Company requireCompany(Long)` | **7** —— `MatchRuleService`、`InterviewService`、`OfferService`、`CompanyReviewManagementService`、`CompanyReviewReplyService`、`CompanyVerificationService`、`CompanyScoringDashboardService` | **不是纯角色门禁**:除角色判定外还承担实体查找与归属语义(`companyRepository.findByOwnerUserId(userId).orElseThrow(COMPANY_NOT_FOUND)`),返回值被业务逻辑使用。整体委托公共 guard 会改变查询次数与错误码(`COMPANY_NOT_FOUND` vs `USER_NOT_FOUND`),**按任务书要求不迁移不等价逻辑** | |
|||
| `private boolean hasRole(User, Role)` | **7** —— `InterviewService`、`InterviewScoreService`、`MessagingService`、`OfferService`、`TalentDiscoveryService`、`TalentInvitationService`、`CompanyVerificationService` | 用于**资源归属/可见性分支**(如 `hasRole(user, STUDENT) && interview.getStudentUserId().equals(userId)`、`MessagingService` 中 ADMIN 可见性分支),不是"必须具有某角色"的门禁。**角色授权与资源归属是两件事**,迁移会改变语义 | |
|||
| `AdminReportController`/`AdminRiskController.requireAdmin(HttpServletRequest)` | 2 | 第 15/16 行已委托 guard;保留该方法是因为它还负责返回 `userId`,属 Controller 内的便捷封装 | |
|||
| `CompanyService` 等 5 个公开方法 | 5 | 见 §6 末尾说明 | |
|||
|
|||
**未为"grep 为零"迁移任何不等价逻辑。** |
|||
|
|||
--- |
|||
|
|||
## 8. 角色 / 资源归属边界 |
|||
|
|||
| 边界 | 事实 | |
|||
|---|---| |
|||
| 身份("我是谁") | 仍由 `CurrentUserAccessor` 负责;`UserRoleGuard` **不**解析 SecurityContext(结构测试断言其唯一依赖是 `UserRepository`) | |
|||
| 角色授权("我能不能") | 收束到 `UserRoleGuard` | |
|||
| 资源归属("这条数据是不是我的") | **仍由各业务 Service 执行**,未删除、未移动。`RoleAuthorizationIntegrationTest.ownershipRemainsWithBusinessService` 用"角色正确但无企业实体的 COMPANY 用户"访问 |
|||
`/company/candidates/{id}` → 得到 **404**(业务层归属/存在性判定),证明 guard 既没有替代也没有跳过归属校验 | |
|||
| 403 / 404 分层 | 保持既有:角色不符 403(2002);资源不存在 404(如 `RISK_EVENT_NOT_FOUND`) | |
|||
|
|||
--- |
|||
|
|||
## 9. B2 实现与逐项一致性 |
|||
|
|||
### 9.1 实现 |
|||
|
|||
新增 `common/exception/ErrorHttpStatusRegistry.java`: |
|||
|
|||
```java |
|||
public HttpStatus resolve(ErrorCode errorCode); |
|||
public HttpStatus resolve(int errorCode); // BusinessException 只暴露数字 code |
|||
public HttpStatus defaultStatus(); // 400 |
|||
public int explicitMappingCount(); // 112 |
|||
public static Map<ErrorCode, HttpStatus> explicitMappingsView(); |
|||
public static Map<Integer, HttpStatus> numericMappingsView(); // 111 |
|||
``` |
|||
|
|||
| 要求 | 落实 | |
|||
|---|---| |
|||
| 不可变映射 | 构建后 `Map.copyOf(...)`,运行期 `put`/`remove` 抛 `UnsupportedOperationException`(测试锁定) | |
|||
| 键类型 | **`ErrorCode` 常量**(见 §9.4);数字入口由该映射派生 | |
|||
| 初始化禁止重复键 | `Builder.put` 用 `putIfAbsent`,**同码重复注册即抛 `IllegalStateException`**(无论目标状态是否相同) | |
|||
| 显式映射集中一处 | 只在 `explicitMappings()` 内,全部 `ErrorCode.X` 符号,**无手写数字** | |
|||
| 未映射默认 400 | `DEFAULT_STATUS = BAD_REQUEST`,与重构前 `getOrDefault(code, BAD_REQUEST)` 一致 | |
|||
| 不引入反射扫描 | 无反射 | |
|||
| 不使用可运行时修改的 Map | 静态 final + `Map.copyOf` | |
|||
| Handler 只调用 registry | `GlobalExceptionHandler` 删除 `BUSINESS_HTTP_STATUSES`、`buildBusinessHttpStatuses()`、`register()`、`resolveHttpStatus()`;`handleBusinessException` 改为 `errorHttpStatusRegistry.resolve(errorCode)` | |
|||
| 同码映射到不同状态 | **未发现**(唯一同数字码的两个常量映射到同一状态,见 §9.4) | |
|||
|
|||
`ErrorHttpStatusRegistry` 通过 `@Import` 挂在 `GlobalExceptionHandler` 上,使 `@WebMvcTest` 切片 |
|||
(`CompanyControllerMvcTest`)无需改动即可解析到该组件。 |
|||
|
|||
### 9.2 响应契约未变 |
|||
|
|||
- 响应 JSON 结构、字段、业务码、message 全部不变(`@EnumSource` 全量断言) |
|||
- requestId 仍来自 `RequestIdAccessor`,测试断言 `"r01b-contract"` / `"r01b-parity"` |
|||
- 日志级别未变(`handleBusinessException` 仍为 `log.warn`) |
|||
- 未知 code 仍回退 **400** |
|||
- `ErrorCode` 数字、枚举成员**零修改**(`git diff` 为空) |
|||
- 未新增接口、DTO、依赖 |
|||
|
|||
### 9.3 逐项一致性证明 |
|||
|
|||
`GlobalExceptionHandlerMappingParityTest`: |
|||
|
|||
1. **显式映射集合** = 重构前快照的 112 个 `ErrorCode`(对**全部 166 个**错误码逐一核对: |
|||
快照中未显式映射的,重构后也不得显式映射); |
|||
2. **处理器实际返回状态** ≡ **registry 解析结果** ≡ **快照基线**(`@EnumSource` 166 参数化); |
|||
3. 未映射码(含 `UNAUTHORIZED(2001)`、`SERVICE_UNAVAILABLE`)保持 **400**; |
|||
4. `FORBIDDEN` 保持业务码 **2002** + HTTP **403**; |
|||
5. NOT_FOUND 类 404、CONFLICT 类 409、限流类 429; |
|||
6. 响应体不含异常类名、堆栈、内部路径。 |
|||
|
|||
### 9.4 收束过程中发现的既有潜在缺陷(已记录,未修改) |
|||
|
|||
**`ErrorCode` 166 个常量只有 165 个唯一数字码**:`REVIEW_ALREADY_DECIDED(9103)` 与 |
|||
`DUPLICATE_FEEDBACK(9103)` 共用同一数字码。 |
|||
|
|||
- 重构前该重复被 `register()` **静默容忍**(`putIfAbsent` 只在"同码不同状态"时抛错); |
|||
- 本轮的严格重复检测因此**在启动期暴露了它**; |
|||
- 二者当前都映射到 **CONFLICT**,故数字入口无歧义、行为与重构前完全一致; |
|||
- 按任务书"不修改 `ErrorCode` 数字、不新增 `ErrorCode`"的要求,**本轮不修改**, |
|||
仅把映射键改为 `ErrorCode` 常量(这也正是"键必须是 ErrorCode 或稳定错误码类型"的实践理由), |
|||
并以 `ErrorHttpStatusRegistryTest.distinctConstantsWithSharedNumericCodeAreAllowed` 锁定该事实。 |
|||
|
|||
**处置建议(留待后续独立提交)**:为 `DUPLICATE_FEEDBACK` 分配唯一数字码。若两个常量被映射到 |
|||
**不同状态**,注册表的 `indexByCode` 会在初始化期直接抛错,不会静默任选其一。 |
|||
|
|||
--- |
|||
|
|||
### 9.5 量化结果(前后对比) |
|||
|
|||
| 指标 | 重构前 | 重构后 | 说明 | |
|||
|---|---|---|---| |
|||
| 角色 guard 方法定义总数 | **32** | **21** | 其中 6 个是 `UserRoleGuard` 自身;6 个是保留签名的公开方法(方法体已是一行委托);8 个是实体返回型 `requireCompany`(非等价,保留);2 个是 Controller 的 `requireAdmin(request)`(已委托) | |
|||
| 重复 student guard 实现 | **16** | **1**(公共 guard) | 其余为公开委托方法/实体返回型 | |
|||
| 重复 company guard 实现 | **2**(纯角色门禁)+ 7(实体返回型) | **1**(公共 guard);7 个实体返回型保留 | |
|||
| 重复 admin guard 实现 | **7** | **1**(公共 guard) | | |
|||
| 已迁移调用点 | 0 | **54** | 37 student + 13 admin + 4 company | |
|||
| 明确保留的非等价调用点 | — | **14** | 7 个实体返回型 `requireCompany` + 7 个归属/可见性用 `hasRole` | |
|||
| `getRoles()` 角色比较 | **46 处 / 31 文件** | **23 处 / 16 文件** | 减少 50% | |
|||
| `GlobalExceptionHandler` 行数 | **232** | **146** | 净减 **86 行**;映射表、`register()`、`resolveHttpStatus()` 全部移出 | |
|||
| `resolveHttpStatus` 条件链 | 映射构建 66 行 + 注册 10 行 + 解析 3 行 | **0**(逻辑迁入 `ErrorHttpStatusRegistry`,261 行,含 javadoc 与显式 112 条映射) | | |
|||
| `ErrorCode.X.getCode()` 用于状态比较的次数 | 0(原为 `Map` 查找) | **0** | 状态解析无数字字面量比较 | |
|||
| 重复 HTTP 映射数 | 0(且**不可检测**) | **0**(且**初始化即拒绝**) | | |
|||
| 未映射 ErrorCode 数 | **54 / 166** → 400 | **54 / 166** → 400 | 逐项不变 | |
|||
| 角色授权 DB 查询次数 | 每处 1 次 `findById` | `requireXxx(Long)` **1 次**;`requireXxx(User)` **0 次**;无跨请求缓存 | | |
|||
|
|||
--- |
|||
|
|||
## 10. 查询次数 |
|||
|
|||
| 场景 | 数据库查询次数 | |
|||
|---|---| |
|||
| `requireStudent/Company/Admin/Any(Long userId)` | **恰好 1** 次 `users.findById`(`RoleAuthorizationGuardTest.queryCountIsBounded` 以 Mockito `times(1)` 锁定) | |
|||
| `requireStudent/Company/Admin/Any(User user)` | **0** 次(供已加载实体的调用点使用) | |
|||
| 一个请求内相同用户重复角色检查 | 调用方已加载实体时应使用实体重载;现有调用点每个请求路径至多 1 次 guard 调用 | |
|||
| 跨请求缓存 | **无**(不缓存 active 状态,注销/禁用后立即失效) | |
|||
| Redis 依赖 | **无** | |
|||
| 事务边界 | **未改变**(guard 只读、无 `@Transactional`) | |
|||
| 单次授权的 N+1 | **无**:guard 为单条主键查询,`user_roles` 由 JPA 关联加载 | |
|||
|
|||
--- |
|||
|
|||
## 11. 测试结果 |
|||
|
|||
日志目录:`docs/acceptance/r01-b/` |
|||
|
|||
| # | 命令 | 结果 | 日志 | |
|||
|---|---|---|---| |
|||
| 1 | `mvn -Dtest="RoleAuthorizationGuardTest,RoleAuthorizationIntegrationTest,ErrorHttpStatusRegistryTest,GlobalExceptionHandlerMappingParityTest" test` | **371 / 371**,F=0 E=0 S=0 ✔ | `b1b2-tests.log` | |
|||
| 2 | `mvn -Dtest="*Security*Test,*Authorization*Test,*Role*Test,*Admin*Controller*Test,*Company*Controller*Test,*Student*Controller*Test,*GlobalExceptionHandler*Test,*BusinessException*Test" test` | **448 中 6 项失败** —— 全部为**既有夹具缺陷**(§11.3),非本轮引入 | `b1-related.log` | |
|||
| 3 | `mvn test` | **2656 / 2656**,Failures=**0**,Errors=**0**,Skipped=**0**,BUILD SUCCESS ✔ | `b-full-mvntest.log` | |
|||
| 4 | `mvn clean verify` | 见 §11.4 | `b-clean-verify.log` | |
|||
| 5 | `python ..\docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | — | |
|||
|
|||
### 11.1 新增测试逐类结果 |
|||
|
|||
| 测试类 | Tests | 覆盖 | |
|||
|---|---|---| |
|||
| `RoleAuthorizationGuardTest` | 16 | STUDENT/COMPANY/ADMIN 通过各自 guard;跨角色拒绝;ADMIN 无隐式超级权限;inactive 与 `active=null` 失败关闭;角色缺失;多角色;`users.role` 与 `user_roles` 不一致失败关闭;错误码/HTTP 契约;不解析 requestId/SecurityContext;查询次数 | |
|||
| `RoleAuthorizationIntegrationTest` | 10 | 三个真实端点(ADMIN `/admin/scoring/risk/overview`、STUDENT `/profile/me/career-progress`、COMPANY `/company/candidates`)经真实 SecurityFilterChain 验证 401/403/404 分层;接线变异(`@SpyBean` 断言 guard 确被调用);资源归属仍由业务层判定 | |
|||
| `ErrorHttpStatusRegistryTest` | 174 | 112 条显式映射、111 个唯一数字码、四状态白名单、逐 ErrorCode 自洽、未映射回退 400、重复键初始化拒绝、不可变、键必须为 ErrorCode、9103 共用数字码 | |
|||
| `GlobalExceptionHandlerMappingParityTest` | 171 | 显式映射集合 = 重构前快照;处理器返回 ≡ registry ≡ 快照(166 参数化);FORBIDDEN 2002/403;`UNAUTHORIZED(2001)` 保持 400;服务不可用保持 400;响应体无内部细节 | |
|||
|
|||
### 11.2 测试总数 |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| R01-B 前基线 | **2285** | |
|||
| 本轮新增 | **371** | |
|||
| 预期合计 | 2656 | |
|||
| 实测 (`mvn test`) | **2656** ✔ 与预期逐位一致 | |
|||
|
|||
### 11.3 关联回归中暴露的**既有**夹具缺陷(与本轮无关,如实记录) |
|||
|
|||
`mvn -Dtest="*Admin*Controller*Test,..."` 单独运行时,**既有**的 |
|||
`AdminReviewControllerTest`(4 项)与 `CompanyReviewManagementControllerTest`(2 项) |
|||
出现 `expected 200/403/409 but was 401`。根因与 A6 独立复验 §9 定位的**同一缺陷**: |
|||
|
|||
``` |
|||
AuthSessionService - session validation rejected: user not usable |
|||
JwtAuthenticationFilter - JWT authentication rejected: session not active |
|||
``` |
|||
|
|||
- `AdminReviewControllerTest` 使用 `ADMIN_ID = 1L`;`CompanyReviewManagementControllerTest` 使用 `token(1L, "COMPANY")` / `token(2L, "STUDENT")`; |
|||
- `V1_1` 种子账号占用 `users.id = 1/2/3` 且被 `V43` 置为 `inactive`,而 `TestAuthTokens` 对"已存在但 inactive"的行不插入也不激活; |
|||
- 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `deleteAll()` 偶然清掉种子行,问题被掩盖 —— 故 §11.2 的全量结果是干净的; |
|||
- 日志中该警告恰好 6 次,分别落在上述两个类,**无一处落在本轮改动的代码路径**; |
|||
- 本轮**未修改**这两个测试类,也**未**把该失败写成通过。 |
|||
|
|||
> 该缺陷与 `FairnessMonitoringControllerTest` 属同一类,已在 A6 后以独立提交 `84fe9f5` 修复 |
|||
> **其中一个类**;其余同类类别的隔离修复应另开独立纯测试提交,不并入 R01-B。 |
|||
|
|||
### 11.4 `mvn clean verify` |
|||
|
|||
``` |
|||
[INFO] Tests run: 2656, Failures: 0, Errors: 0, Skipped: 0 |
|||
[INFO] BUILD SUCCESS |
|||
``` |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| 全量测试总数 | **2656**(不低于收束前 2285 ✔) | |
|||
| Failures / Errors / Skipped | **0 / 0 / 0** ✔ | |
|||
| 日志 | `docs/acceptance/r01-b/b-clean-verify.log` | |
|||
|
|||
--- |
|||
|
|||
## 12. OpenAPI |
|||
|
|||
``` |
|||
python ..\docs\verify_openapi.py |
|||
→ paths: 176 / operations: 214 |
|||
→ All checks passed! |
|||
``` |
|||
|
|||
`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` 零修改 ✔ |
|||
|
|||
--- |
|||
|
|||
## 13. 精确修改文件 |
|||
|
|||
**新增生产代码(2)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java |
|||
``` |
|||
|
|||
**修改生产代码(22)** |
|||
|
|||
``` |
|||
ability/AbilityAttemptService.java |
|||
application/ApplicationController.java |
|||
assessment/AssessmentAttemptService.java |
|||
bidirectionalmatch/MatchRuleService.java |
|||
candidatescore/dashboard/CompanyScoringDashboardService.java |
|||
candidatescore/governance/CandidateScoringGovernanceService.java |
|||
careerprogress/CareerProgressService.java |
|||
common/exception/GlobalExceptionHandler.java ← B2 |
|||
company/CompanyService.java |
|||
competition/CompetitionRankingService.java |
|||
job/JobFavoriteService.java |
|||
match/MatchService.java |
|||
preference/PreferenceService.java |
|||
privacy/PrivacySettingsService.java |
|||
profile/ProfileService.java |
|||
recommendation/JobRecommendationService.java |
|||
review/JobReviewService.java |
|||
risk/AdminRiskController.java |
|||
safety/AdminReportController.java |
|||
talentdiscovery/TalentDiscoveryService.java |
|||
talentinvitation/TalentInvitationService.java |
|||
verification/CompanyVerificationService.java |
|||
``` |
|||
|
|||
**新增测试(5)** |
|||
|
|||
``` |
|||
common/r01b/RoleAuthorizationGuardTest.java |
|||
common/r01b/RoleAuthorizationIntegrationTest.java |
|||
common/r01b/ErrorHttpStatusRegistryTest.java |
|||
common/r01b/GlobalExceptionHandlerMappingParityTest.java |
|||
common/r01b/UserRoleGuardTestSupport.java ← 测试支持(非测试类) |
|||
``` |
|||
|
|||
**修改测试(21)** —— 仅接线,未放宽任何断言: |
|||
|
|||
``` |
|||
careerprogress/CareerProgressServiceTest.java ← 构造器参数补 guard(真实实例) |
|||
common/exception/GlobalExceptionHandler{AccountDeletion,HttpStatus,ReportBlock,StatusContract}Test.java ← 构造器补 registry |
|||
candidatescore/governance/CandidateScoringGovernanceServiceTest.java |
|||
profile/ResumeServiceTest.java |
|||
review/JobReviewDimensionServiceTest.java |
|||
assessment/AssessmentAttemptServiceTest.java |
|||
match/MatchServiceTest.java |
|||
privacy/PrivacySettingsServiceTest.java |
|||
review/JobReviewServiceTest.java |
|||
job/JobFavoriteServiceTest.java |
|||
company/CompanyServiceTest.java |
|||
preference/PreferenceServiceTest.java |
|||
competition/CompetitionRankingServiceTest.java |
|||
ability/AbilityAttemptServiceTest.java |
|||
bidirectionalmatch/MatchRuleServiceTest.java |
|||
verification/CompanyVerificationServiceTest.java |
|||
talentdiscovery/TalentDiscoveryServiceTest.java |
|||
recommendation/JobRecommendationServiceTest.java |
|||
``` |
|||
|
|||
> 这 16 个 `@InjectMocks` 单元测试类新增了 `@Mock UserRoleGuard` 并在 `@BeforeEach` |
|||
> 通过 `UserRoleGuardTestSupport.delegateToRealGuard(...)` 把 mock **委托给真实 guard**, |
|||
> 复用测试自身已 stub 的 `UserRepository`。这样"角色不符 → 403"的既有断言**语义不变**, |
|||
> 没有被削弱成"只验证调用了一次"。 |
|||
|
|||
**文档(2)** |
|||
|
|||
``` |
|||
docs/r01-b-authorization-exception-mapping-report.md (新增) |
|||
docs/backend-development-roadmap.md (更新:A 系列完成 / 84fe9f5 / R01-B 状态 / 下一阶段 R01-C) |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 14. Flyway / ErrorCode / 接口 / DTO 是否变化 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` | **空** —— V1–V45 未改动 | |
|||
| `ErrorCode.java` | **未修改**(数字、成员、message 全部不变) | |
|||
| 接口(路径/方法/参数/响应结构) | **未修改**(未新增、未删除、未改签名) | |
|||
| DTO | **未修改** | |
|||
| `docs/openapi-rc.json` | **未修改** | |
|||
| 依赖(`pom.xml`) | **未修改** | |
|||
| 新依赖 | **未引入**(guard 只用 Spring Data JPA 与既有 `ErrorCode`) | |
|||
|
|||
--- |
|||
|
|||
## 15. Git 状态 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 分支 / HEAD | `refactor/r01-structure-performance` / `84fe9f5…`(未变) | |
|||
| `git diff --cached --name-only` | **空** | |
|||
| `git diff --check` | 仅 LF/CRLF 警告,exit 0 | |
|||
| 已暂存 / 已提交 / 已推送 | **均无** | |
|||
| 历史未跟踪文件 | 未删除 | |
|||
|
|||
--- |
|||
|
|||
## 16. 未暂存、未提交、未推送 |
|||
|
|||
**确认:未暂存、未提交、未推送。** 本轮未使用 `git add`、未创建提交、未推送; |
|||
未执行 `reset`/`restore`/`checkout`/`clean`;未操作 Docker;未修改 V1–V45。 |
|||
|
|||
--- |
|||
|
|||
## 17. 未进入 R01-C/E |
|||
|
|||
**确认:未进入 R01-C、未进入 R01-E。** 本轮未处理 `overviewStatus`、推荐权重、 |
|||
高校名单与评分阈值、N+1、会话未读数、requestId、身份主体解析、Token、评分快照、 |
|||
新接口与数据库迁移中的任何一项。 |
|||
|
|||
--- |
|||
|
|||
> **R01-B角色授权与异常映射收束完成,待独立代码复验。** |
|||
@ -0,0 +1,617 @@ |
|||
# R01-B 独立代码复验报告(角色授权与异常映射收束) |
|||
|
|||
日期:2026-09-18 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`84fe9f5febcf56910d5c978dd44e0e16d15d7209`(A6 后夹具修复提交,复验全程未变) |
|||
复验对象:`docs/r01-b-authorization-exception-mapping-report.md` |
|||
性质:**仅复验** —— 未修改生产代码、未放宽既有断言、未暂存、未提交、未推送 |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-B独立代码复验通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。** |
|||
|
|||
- 角色授权:54 个调用点全部真实存在且仅经 HTTP 入口可达;保留的 14 个点**确为不等价**; |
|||
**未发现角色/归属/错误码/事务语义变化**。 |
|||
- HTTP 映射:从 `HEAD` 独立提取的 **166 项基线逐项一致(0 处不一致)**;112 显式、54 默认 400; |
|||
`UNAUTHORIZED` 仍 400、`FORBIDDEN` 仍 403、`SERVICE_UNAVAILABLE` 仍 400。 |
|||
- 9103 重复码:**运行时确实无法区分**(`BusinessException` 只保存 `int`)。未夸大为"已解决身份歧义"; |
|||
已证明两者状态相同故 HTTP 行为一致,且"同数字不同状态"会初始化失败而非静默任选。 |
|||
- 测试委托模式:**真实**。变异实验(把委托改为无条件下放行)令 63 个负向用例中 **50 个失败**。 |
|||
- 仅有两个**既有**测试夹具缺陷(`AdminReviewControllerTest`、`CompanyReviewManagementControllerTest`), |
|||
已用证据证明其失败发生在安全链认证阶段、**未进入 R01-B 代码**。 |
|||
- **一处需如实说明的环境偏差**:本轮 `mvn clean verify` 为 `2681 / F=0 / E=0 / Skipped=26`。 |
|||
26 个跳过全部是 `@Testcontainers(disabledWithoutDocker = true)` 的 MySQL 用例, |
|||
因本机 **Docker 守护进程未运行**而按设计跳过;上一轮同代码全量为 `Skipped=0` |
|||
(这些用例执行并通过)。**不声称 Skipped=0**,原因与自洽性见 §14.1。 |
|||
**不构成 R01-B 阻断项。** |
|||
|
|||
--- |
|||
|
|||
## 1. 分支、HEAD 与 Git 状态 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `git branch --show-current` | `refactor/r01-structure-performance` | |
|||
| `git rev-parse HEAD` | `84fe9f5febcf56910d5c978dd44e0e16d15d7209` | |
|||
| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` ✔ | |
|||
| `git diff --cached --name-only` | **空** ✔ | |
|||
| `git diff --check` | **exit 0**,无空白错误 ✔ | |
|||
| R01-B 改动位置 | 全部仍在工作区(22 生产 + 21 测试 + 1 文档) | |
|||
| R01-B 之外的新生产改动 | **无**(逐文件比对允许清单,无越界) | |
|||
|
|||
--- |
|||
|
|||
## 2. 角色库存独立计数 |
|||
|
|||
独立复验者自行从 `HEAD` 与工作区分别提取(正则按方法签名匹配,不采信实施报告数字): |
|||
|
|||
| 项 | HEAD | 工作区 | |
|||
|---|---|---| |
|||
| 角色 guard 方法定义总数 | **32** | **21** | |
|||
| `private boolean hasRole(User, Role)` 助手 | 7 | 7 | |
|||
| `getRoles()` 出现次数 / 文件数 | 46 / 31 | 23 / 17 | |
|||
| 调用公共 guard 的点位 | 0 | **54** | |
|||
|
|||
工作区 21 个定义的构成(逐条列出于下): |
|||
|
|||
| 类别 | 数量 | 明细 | |
|||
|---|---|---| |
|||
| `UserRoleGuard` 自身 | **6** | `requireStudent/Company/Admin` × (`Long`, `User`) | |
|||
| 公开兼容委托(方法体一行) | **6** | `CompanyService.assertCompanyRole`、`CompanyService.assertStudentRole`、`PreferenceService.assertStudentRole`、`PrivacySettingsService.assertStudentRole`、`ProfileService.assertStudentRole`、`CompanyVerificationService.assertAdminRole` | |
|||
| 实体返回型 `requireCompany`(非等价,保留) | **7** | `MatchRuleService`、`CompanyScoringDashboardService`、`InterviewService`、`OfferService`、`CompanyReviewManagementService`、`CompanyReviewReplyService`、`CompanyVerificationService` | |
|||
| Controller 便捷包装(返回 userId,已委托 guard) | **2** | `AdminRiskController.requireAdmin(HttpServletRequest)`、`AdminReportController.requireAdmin(HttpServletRequest)` | |
|||
|
|||
6 + 6 + 7 + 2 = **21** ✔ 与工作区实测一致。 |
|||
|
|||
> 与实施报告的一处**非语义差异**:实施报告 §9.5 写 `getRoles()` 为「23 处 / 16 文件」, |
|||
> 独立实测为「23 处 / **17** 文件」(出现次数一致,文件计数差 1)。 |
|||
> 该差异不影响任何结论,仅记录以备核对。 |
|||
|
|||
--- |
|||
|
|||
## 3. 54 个迁移调用点对账 |
|||
|
|||
逐调用点清单(文件 / 方法 / 旧 guard / 新 guard / 角色 / 错误码 / 归属 / 查询次数)已落盘: |
|||
`docs/acceptance/r01-b/iv-role-callsites.tsv`(54 行 + 表头)。 |
|||
|
|||
按文件汇总: |
|||
|
|||
| 文件 | 调用点 | |
|||
|---|---| |
|||
| `ability/AbilityAttemptService` | 6 | |
|||
| `assessment/AssessmentAttemptService` | 5 | |
|||
| `candidatescore/governance/CandidateScoringGovernanceService` | 7 | |
|||
| `review/JobReviewService` | 6 | |
|||
| `recommendation/JobRecommendationService` | 5 | |
|||
| `application/ApplicationController` | 3 | |
|||
| `job/JobFavoriteService` | 3 | |
|||
| `talentinvitation/TalentInvitationService` | 3 | |
|||
| `bidirectionalmatch/MatchRuleService` | 2 | |
|||
| `company/CompanyService` | 2 | |
|||
| `talentdiscovery/TalentDiscoveryService` | 2 | |
|||
| `candidatescore/dashboard/CompanyScoringDashboardService` | 1 | |
|||
| `careerprogress/CareerProgressService` | 1 | |
|||
| `competition/CompetitionRankingService` | 1 | |
|||
| `match/MatchService` | 1 | |
|||
| `preference/PreferenceService` | 1 | |
|||
| `privacy/PrivacySettingsService` | 1 | |
|||
| `profile/ProfileService` | 1 | |
|||
| `risk/AdminRiskController` | 1 | |
|||
| `safety/AdminReportController` | 1 | |
|||
| `verification/CompanyVerificationService` | 1 | |
|||
| **合计** | **54** | |
|||
|
|||
| 独立核对项 | 结果 | |
|||
|---|---| |
|||
| 迁移调用点总数 | **54** ✔ | |
|||
| student / admin / company 分布 | **37 / 13 / 4** ✔(与实施报告一致) | |
|||
| 重复统计 | **无**(同一行不会被两个正则重复计入) | |
|||
| 32 → 21 方法数 | ✔ 逐方法差异已列出:HEAD 独有 17 个定义被删除,工作区新增 `UserRoleGuard` 的 6 个 | |
|||
| 是否删除角色检查 | **否** —— 被删方法的 HEAD 调用点数合计 **46**,对应迁移后 54 个调用点(含新增的 `MatchRuleService` 2 处与委托内调用),无一处丢失 | |
|||
| 是否扩大 ADMIN 权限 | **否** —— 未引入任何 `requireAny(..., ADMIN)`;ADMIN 仍被 STUDENT/COMPANY 门禁拒绝(HTTP 实测 403) | |
|||
| 是否把 404/业务不存在改成 403 | **否** —— `USER_NOT_FOUND` 仍映射 404;`COMPANY_NOT_FOUND` 等业务码未变;实测验端点返回 404 而非 403 | |
|||
|
|||
--- |
|||
|
|||
## 4. 14 个保留点及其不等价性核验 |
|||
|
|||
| 类别 | 数量 | 独立核验结论 | |
|||
|---|---|---| |
|||
| `private Company requireCompany(Long)` | 7 | **确为不等价**:除角色判定外还执行 `companyRepository.findByOwnerUserId(...)` 并抛 `COMPANY_NOT_FOUND`,返回值被业务使用。用 void guard 替换会丢失实体与错误码语义,且改变查询次数 | |
|||
| `private boolean hasRole(User, Role)` | 7 | **确为不等价**:返回**布尔值**用于**分支选择**(如 `listMyInterviews` 按 COMPANY/STUDENT 走不同查询;`listOffers` 还要先取 company 实体),并含归属判定(`hasRole(user, STUDENT) && interview.getStudentUserId().equals(userId)`)。void guard 无法表达"值" | |
|||
|
|||
已逐处读取源码确认(例:`InterviewService.listMyInterviews`、`OfferService.listOffers` 的 |
|||
`if (hasRole(...)) {...} if (hasRole(...)) {...} else 403` 分支结构)。 |
|||
**14 与 21 的关系**:21 个方法 = 6(guard 自身)+ 6(一行委托)+ 7(实体返回型)+ 2(Controller 包装); |
|||
其中真正"不等价保留"的是 **7 个实体返回型 `requireCompany` + 7 个 `hasRole` 助手 = 14**。 |
|||
|
|||
**一处残留观察(非阻断)**:`ApplicationController.getCompanyCandidateDetail` 先调 |
|||
`requireCompany(userId)`,随后调用的 `companyService.findCompanyByOwnerUserId(userId)` 内部又调 |
|||
`assertCompanyRole(userId)` —— 同一请求 2 次角色检查。经与 `HEAD` 逐行比对,**该重复在收束前就存在** |
|||
(HEAD 的 `assertCompanyRole` 同样被 Controller 与 `findCompanyByOwnerUserId` 各调一次), |
|||
**非本轮引入**,本轮未改变查询次数。可作为后续独立优化点。 |
|||
|
|||
--- |
|||
|
|||
## 5. inactive 语义分析 |
|||
|
|||
| 项 | 结论 | |
|||
|---|---| |
|||
| 用户不存在 | `USER_NOT_FOUND`(4001) → 404(沿用既有错误码,未新增) | |
|||
| 用户存在但 inactive | **`FORBIDDEN`(2002) → 403**(新增的失败关闭;既有实现无此校验) | |
|||
| `active` 为 `null` | 同样拒绝(`isActive` 要求 `Boolean.TRUE`) | |
|||
| 角色不足 | `FORBIDDEN`(2002) → 403(与既有完全一致) | |
|||
| 与既有实现的差异面 | **仅"inactive 用户"这一种输入**:收束前会通过,收束后拒绝 | |
|||
|
|||
**HTTP 入口的 inactive 用户在 guard 之前已被拒绝**(证据链,非推测): |
|||
|
|||
1. `JwtAuthenticationFilter.authenticate` 在建立认证前调用 |
|||
`authSessionService.isSessionActive(...)`; |
|||
2. `AuthSessionService.isSessionActive` 第一步 `isUserUsable(userId)` → |
|||
`user.isPresent() && isUsable(user)`,而 `isUsable` 只检查 `user.getActive()`; |
|||
3. 为 false 时 `SecurityContextHolder.clearContext()` 并直接返回,**请求根本不会进入 Controller**; |
|||
4. 因此对 HTTP 入口而言,guard 的 active 检查是**第二道**,观察不到行为差异。 |
|||
|
|||
--- |
|||
|
|||
## 6. 非 HTTP 入口分析 |
|||
|
|||
对全部 **54 个迁移调用点**逐个追溯调用链,结论:**全部仅经 HTTP 入口可达**。 |
|||
|
|||
| 入口类型 | 独立核验 | |
|||
|---|---| |
|||
| HTTP Controller 链路 | 54/54 调用点的宿主方法均由 `@RestController` 直达(21 个文件逐一确认) | |
|||
| 定时任务 | 全仓 `@Scheduled` 类共 8 个:`MatchNotificationScheduler`、`CandidateScoringRefreshScheduler`、`PersonalDataExportWorker`、`AccountDeletionService`、`NotificationConsumer`、`OfferExpiryScheduler`、`RiskEventDetector`、`RiskEventScanScheduler` —— **无一个注入或调用任何被迁移的服务或 `UserRoleGuard`** | |
|||
| Worker | 同上(`PersonalDataExportWorker`、`CandidateScoringRefreshWorker`、`AccountDeletionCompletionWorker` 的依赖已逐一列出核对) | |
|||
| 消息消费者 | `NotificationConsumer` 仅依赖 notification 三件套,未触碰 | |
|||
| 内部 Service 调用 | 非 Controller 的调用者仅 4 处:`ResumeVersionService`/`ResumeExportService`/`StudentReviewWeightService`(均只被 Controller 调用)、`MatchService.diagnose`(唯一调用者是 `MatchController`)。**均为 HTTP 链路** | |
|||
| 注销/导出/补偿流程 | `AccountDeletionService`、`PersonalDataExportWorker`、补偿扫描器均未触碰迁移点 | |
|||
| 管理后台任务 | `AdminRiskController`/`AdminReportController` 的 `requireAdmin` 只被各自 HTTP 端点调用 | |
|||
|
|||
**因此:不存在"合法的历史数据处理、注销收尾或后台任务需要对 inactive 用户执行受保护动作"的场景。** |
|||
独立变量验证:`MatchService.diagnose` 的唯一调用方是 `MatchController`(`grep "\.diagnose("` 全仓仅 1 处)。 |
|||
|
|||
--- |
|||
|
|||
## 7. 查询次数 |
|||
|
|||
| 场景 | 独立实测 | |
|||
|---|---| |
|||
| `requireXxx(Long userId)` | **恰好 1** 次 `users.findById`(Mockito `times(1)` + `verifyNoMoreInteractions` 锁定) | |
|||
| `requireXxx(User user)` | **0** 次(`verifyNoMoreInteractions` 覆盖) | |
|||
| guard 写库 | **0**(`never()).save/deleteById/delete`) | |
|||
| 单次授权 N+1 | **无**(单条主键查询;`user_roles` 由 JPA 关联加载) | |
|||
| 跨请求缓存 | **无**(guard 无状态字段,唯一依赖 `UserRepository`;用户禁用后立即失效) | |
|||
| Redis / SecurityContext / requestId | **均未触碰**(结构断言 + 行为断言双重证明) | |
|||
| 每请求角色检查次数 | 多数路径 1 次;`/company/candidates/{id}` 为 2 次 —— **与 HEAD 相同,非回归**(见 §4) | |
|||
|
|||
--- |
|||
|
|||
## 8. 测试委托模式是否真实(含变异验证) |
|||
|
|||
### 8.1 静态核验 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 使用 `delegateToRealGuard` 的测试类 | **16 个**(另 1 个为支持类本身) | |
|||
| 每个类是否同时声明 `@Mock private UserRoleGuard userRoleGuard;` | **是**(16/16,各 1 处) | |
|||
| 委托是否使用**真实** `UserRoleGuard` 实现 | **是**:`new UserRoleGuard(userRepository)` | |
|||
| 是否使用每个测试**自己 stub 的** `UserRepository` | **是**:`delegateToRealGuard(userRoleGuard, userRepository)` 传入该测试类的 `@Mock UserRepository` | |
|||
| 是否使用宽泛 `doNothing()` | **否**:`UserRoleGuardTestSupport` 内 **0 处** `doNothing()`;模块内 7 处均为 `lenient().doAnswer(...)` 显式转发 | |
|||
| 是否把所有 guard 调用默认放行 | **否**:四个方法均逐字转发到真实 guard;`lenient()` 只用于消除"未被该用例触发"的多余桩告警,不改变行为 | |
|||
| 本轮是否新增 `doNothing` | **否**:全仓 10 处 `doNothing()` 全部位于**未被 R01-B 改动**的文件(`git status` 为空),且目标是其他 mock(`favoriteRepository`、`messagingService`、被 mock 的 `companyService` 等) | |
|||
|
|||
### 8.2 变异验证(决定性) |
|||
|
|||
把 `UserRoleGuardTestSupport.delegateToRealGuard` 临时改为**空实现**(即 mock 的所有 void 方法无条件放行), |
|||
运行 5 个含负向断言的测试类: |
|||
|
|||
``` |
|||
mvn -Dtest="CompanyServiceTest,JobFavoriteServiceTest,PrivacySettingsServiceTest,MatchServiceTest,JobReviewServiceTest" test |
|||
→ Tests run: 63, Failures: 13, Errors: 37 ← 50/63 失败 |
|||
``` |
|||
|
|||
失败样例: |
|||
|
|||
``` |
|||
CompanyServiceTest.findCompany_asStudent_shouldReturn403:71 |
|||
Expected BusinessException to be thrown, but nothing was thrown. |
|||
CompanyServiceTest.updateCompany_asStudent_shouldReturn403:83 |
|||
expected: <2002> but was: <5001> |
|||
JobFavoriteServiceTest.favorite_asCompany_shouldReturn403:177 |
|||
Unexpected exception type thrown ... but was: java.lang.NullPointerException |
|||
MatchServiceTest.diagnose_asAdmin_shouldReturn403:91 |
|||
Unexpected exception type thrown ... but was: java.lang.NullPointerException |
|||
``` |
|||
|
|||
**判据成立**:若负向测试在"无条件放行"下仍通过,说明测试未覆盖授权;实测 **50 个用例立即失败**, |
|||
证明这些测试**真的执行了角色判断**,委托模式未被削弱。 |
|||
变异后已把文件**按原 SHA256 还原**(`02f25b74cf5d2192882a77930ec1c748a1049509323bf02668dc5b04822c82a1`,逐字节一致), |
|||
临时备份已删除。日志:`docs/acceptance/r01-b/iv-mutation.log`。 |
|||
|
|||
--- |
|||
|
|||
## 9. HTTP 映射 166 项对账(独立提取) |
|||
|
|||
**基线来源**:`git show HEAD:…GlobalExceptionHandler.java` 的 `buildBusinessHttpStatuses()`, |
|||
独立解析后写入 `docs/acceptance/r01-b/reconcile.tsv`(166 行)与 |
|||
`mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv`(测试资源)。 |
|||
**未复用工作区实现的任何映射表**,不存在同源假证明。 |
|||
|
|||
| 项 | HEAD | 工作区 | 一致 | |
|||
|---|---|---|---| |
|||
| 枚举常量总数 | 166 | 166 | ✔ | |
|||
| 显式映射常量数 | 112 | 112 | ✔ | |
|||
| 显式映射覆盖的唯一数字码 | 111 | 111 | ✔ | |
|||
| 默认 400 项 | 54 | 54 | ✔ | |
|||
| **逐项状态不一致数** | — | — | **0** ✔ | |
|||
| HEAD 中重复注册 | 无 | 无 | ✔ | |
|||
|
|||
关键项逐项核对: |
|||
|
|||
| ErrorCode | 数字码 | HEAD | 工作区 | |
|||
|---|---|---|---| |
|||
| `UNAUTHORIZED` | 2001 | 400(未登记默认) | **400** ✔ | |
|||
| `FORBIDDEN` | 2002 | 403 | **403** ✔ | |
|||
| `SERVICE_UNAVAILABLE` | 9001 | 400(未登记默认) | **400** ✔ | |
|||
| `USER_NOT_FOUND` | 4001 | 404 | **404** ✔ | |
|||
| `APPLICATION_DUPLICATE` | 8001 | 409 | **409** ✔ | |
|||
| `ABILITY_RETAKE_LIMIT` | 9811 | 429 | **429** ✔ | |
|||
| `REVIEW_ALREADY_DECIDED` | 9103 | 409 | **409** ✔ | |
|||
| `DUPLICATE_FEEDBACK` | 9103 | 409 | **409** ✔ | |
|||
|
|||
(上表数字码与状态均取自独立提取的 `docs/acceptance/r01-b/reconcile.tsv`。) |
|||
|
|||
响应 JSON、requestId、日志行为未变:`GlobalExceptionHandler.handleBusinessException` 仍为 |
|||
`log.warn`、仍用 `ApiResponse.error(errorCode, e.getMessage(), requestIdAccessor.get(request))`, |
|||
仅把状态解析改为 `errorHttpStatusRegistry.resolve(errorCode)`。 |
|||
|
|||
--- |
|||
|
|||
## 10. 112 显式 / 54 默认统计(独立复核) |
|||
|
|||
| 项 | 实施报告 | 独立复核 | |
|||
|---|---|---| |
|||
| 显式映射 | 112 | **112** ✔ | |
|||
| 未映射(默认 400) | 54 | **54** ✔ | |
|||
| 唯一数字码 | 111(未声明) | **111** ✔(9103 共用) | |
|||
|
|||
--- |
|||
|
|||
## 11. 9103 重复码的真实运行时语义 |
|||
|
|||
### 11.1 逐层查清 |
|||
|
|||
| 层 | 事实 | |
|||
|---|---| |
|||
| `BusinessException` 保存什么 | **`private final int errorCode;`** —— 只保存数字,**枚举身份在构造时即丢失**;无 `ErrorCode` 字段,无 `fromCode` 反查 | |
|||
| `GlobalExceptionHandler` 拿到什么 | `int errorCode = e.getErrorCode();` → **int** | |
|||
| `ErrorHttpStatusRegistry.resolve(...)` 以什么为键 | `resolve(ErrorCode)` 用**枚举键**表 `EXPLICIT`;**运行时路径 `resolve(int)` 用派生数字表 `BY_CODE`** | |
|||
| 是否使用 `ErrorCode.values()` / `fromCode()` / 首次匹配 | **全部没有**(registry 与 handler 中 `values()`/`valueOf`/`ordinal`/`stream()` 命中为 0;`ErrorCode` 亦无 `fromCode` 辅助方法) | |
|||
| 相同数字码不同枚举是否可能被错误归类 | **运行时不可能区分** —— 两者都是 9103,`resolve(9103)` 只有一个结果 | |
|||
| 映射是否依赖枚举声明顺序 | **不依赖**。`EXPLICIT` 按**注册顺序**构建(与枚举声明顺序无关),`BY_CODE` 由它派生并用 `putIfAbsent` 去重;顺序只影响"谁先写入",而冲突时**抛错**而非取先者 | |
|||
|
|||
### 11.2 明确声明(不夸大) |
|||
|
|||
> **运行时无法区分 `REVIEW_ALREADY_DECIDED` 与 `DUPLICATE_FEEDBACK`**:`BusinessException` 只携带 |
|||
> `int 9103`,Handler 也只拿到该数字。因此**不能声称"以枚举为键已彻底解决身份歧义"**; |
|||
> 准确表述是:**两者当前都映射为 409 CONFLICT,故 HTTP 行为一致**; |
|||
> 枚举键表的价值在于**配置期可审计与可去重**,而不是运行期消歧。 |
|||
|
|||
### 11.3 独立测试对六项要求的覆盖 |
|||
|
|||
| # | 要求 | 覆盖用例 | |
|||
|---|---|---| |
|||
| 1 | 两个枚举常量都显式注册 | `bothSharedNumericCodeConstantsAreExplicitlyMappedTo409` | |
|||
| 2 | 两者分别解析为 409 | 同上 | |
|||
| 3 | `resolve(9103)` 行为明确 | `runtimeCannotDistinguishTheTwoSharedConstants`(= CONFLICT,并断言两个异常的业务码相同) | |
|||
| 4 | 改成不同状态时初始化/解析必须失败 | `conflictingStatusForSharedNumericCodeFailsFast`(构造 `9103 → CONFLICT` + `9103 → NOT_FOUND`,调用数字索引构建必须抛 `IllegalStateException`,消息含 `9103`;同时反证状态相同时允许) | |
|||
| 5 | 不依赖 `ErrorCode.values()` 顺序 | `mappingDoesNotDependOnEnumDeclarationOrder`(正序/逆序注册结果相同 + 逐项比对基线) | |
|||
| 6 | 未映射 code 仍为 400 | `unknownCodeFallsBackTo400AndResponseIsClean` | |
|||
|
|||
--- |
|||
|
|||
## 12. 独立新增测试 |
|||
|
|||
| 测试类 | Tests | 结果 | |
|||
|---|---|---| |
|||
| `common/r01b/R01BIndependentRoleVerificationTest` | 11 | **通过** | |
|||
| `common/r01b/R01BIndependentHttpMappingParityTest` | 9 | **通过** | |
|||
| `common/r01b/R01BWiringMutationTest` | 5 | **通过** | |
|||
| **合计** | **25** | **25 / 25**,F=0 E=0 S=0 ✔ | |
|||
|
|||
日志:`docs/acceptance/r01-b/iv-1-independent.log` |
|||
|
|||
覆盖要点: |
|||
|
|||
- **角色**:active 三角色各自通过;三类交叉全部拒绝;ADMIN 不自动通过 STUDENT/COMPANY; |
|||
用户不存在(`USER_NOT_FOUND`);inactive 与 `active=null`(`FORBIDDEN`);`roles` 为空; |
|||
多角色;`users.role` 与 `user_roles` 不一致时按 `user_roles` 失败关闭; |
|||
`requireXxx(Long)` 恰好 1 次查询、`requireXxx(User)` 零查询;无写库; |
|||
不触碰 SecurityContext(在上下文里放 ADMIN 仍按传入 userId 判定)/requestId/Redis; |
|||
三个真实 HTTP 端点验证 **401 / 403 / 404** 分层。 |
|||
- **接线变异**:guard 拒绝 → 403 且**下游业务方法零调用**;guard 放行 → **STUDENT 也能通过 ADMIN 端点** |
|||
(若端点仍保留自有角色比较,STUDENT 不可能通过 → 该断言即变异检测器); |
|||
guard 收到正确 userId;归属失败仍由业务层返回 404。 |
|||
- **映射**:166 项按**名称**逐项比对 HEAD 基线(不作数字去重)、112/54 计数、 |
|||
9103 六项、未知码 400、响应体无堆栈与内部类名。 |
|||
|
|||
--- |
|||
|
|||
## 13. 两个 Controller 测试单跑结果(如实记录,未写成通过) |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| `mvn -Dtest="AdminReviewControllerTest" test` | **11 run / 4 failures / 0 errors / 0 skipped**,BUILD FAILURE | |
|||
| `mvn -Dtest="CompanyReviewManagementControllerTest" test` | **3 run / 2 failures / 0 errors / 0 skipped**,BUILD FAILURE | |
|||
|
|||
失败断言:`expected 200/400/409 but was 401`(6 项合计)。 |
|||
|
|||
**根因证据链(非按日志文本猜测)**: |
|||
|
|||
| 证据 | AdminReview | CompanyReviewMgmt | |
|||
|---|---|---| |
|||
| `Handler: Type = null` 次数 | 4 | 2 | |
|||
| `session validation rejected: user not usable` | 4 | 2 | |
|||
| `JWT authentication rejected: session not active` | 4 | 2 | |
|||
| 401 响应体 | `{"code":401,"message":"not authenticated","timestamp":""}` | 同 | |
|||
| 测试使用的用户 ID | `ADMIN_ID = 1L` | `token(1L,"COMPANY")` / `token(2L,"STUDENT")` | |
|||
| 是否在 R01-B 改动清单内 | **否** | **否** | |
|||
|
|||
**判定**: |
|||
|
|||
1. `Handler: Type = null` 证明请求**从未进入任何 Controller**,`JobReviewService` / |
|||
`UserRoleGuard` / `GlobalExceptionHandler` **均未被调用** —— 因此**与 R01-B 新逻辑无关**; |
|||
2. 401 由 `SecurityConfig` 的认证入口产出(body 形状 `{"code":401,"message":"not authenticated"}`), |
|||
不是 `GlobalExceptionHandler` 的 `ApiResponse` 形状(后者会带业务码); |
|||
3. 拒绝发生在 `AuthSessionService.isUserUsable` —— 用户 id=1/2 正是 `V1_1` 种子账号、 |
|||
被 `V43` 置为 inactive; |
|||
4. 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `deleteAll()` 偶然清除种子行, |
|||
问题被掩盖,故 §14 全量为绿。 |
|||
|
|||
**处置**:未修改这两个测试;未写成通过;不让它们阻断对 R01-B 生产代码的判断。 |
|||
**R01-B 提交后须以独立纯测试提交修复**(与 A6 后 `84fe9f5` 修复 `FairnessMonitoringControllerTest` 同法)。 |
|||
|
|||
--- |
|||
|
|||
## 14. 全量测试 |
|||
|
|||
| 命令 | 结果 | 日志 | |
|||
|---|---|---| |
|||
| `mvn -Dtest="RoleAuthorizationGuardTest,RoleAuthorizationIntegrationTest,ErrorHttpStatusRegistryTest,GlobalExceptionHandlerMappingParityTest" test` | **371 / 371**,F=0 E=0 S=0 ✔ | `iv-2-impl.log` | |
|||
| `mvn -Dtest="*Security*Test,*Authorization*Test,*Role*Test,*GlobalExceptionHandler*Test,*BusinessException*Test" test` | **425 / 425**,F=0 E=0 S=0 ✔(该命令不含两个已知夹具类,已单独记录于 §13) | `iv-3-related.log` | |
|||
| `mvn clean verify` | 见 §14.1 | `iv-4-clean-verify.log` | |
|||
|
|||
### 14.1 `mvn clean verify` |
|||
|
|||
``` |
|||
[INFO] Tests run: 2681, Failures: 0, Errors: 0, Skipped: 26 |
|||
[INFO] BUILD SUCCESS |
|||
``` |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| 测试总数 | **2681**(= 前次 2656 + 本轮新增独立测试 25)✔ 不低于 2656 | |
|||
| Failures | **0** ✔ | |
|||
| Errors | **0** ✔ | |
|||
| **Skipped** | **26 —— 未达"0"要求,原因如下(环境性,非代码回归)** | |
|||
|
|||
#### Skipped=26 的准确原因(如实记录,不掩饰、不写成 0) |
|||
|
|||
26 个跳过**全部**来自 4 个 MySQL / Testcontainers 测试类: |
|||
|
|||
| 测试类 | Skipped | |
|||
|---|---| |
|||
| `job.JobSearchSpecificationMySqlIntegrationTest` | 15 | |
|||
| `candidatescore.refresh.CandidateScoringRefreshMySqlIntegrationTest` | 6 | |
|||
| `job.JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | |
|||
| `job.JobSearchQueryExplainMySqlIntegrationTest` | 1 | |
|||
| 合计 | **26** | |
|||
|
|||
**根因**:这些类标注 `@Testcontainers(disabledWithoutDocker = true)`(见 |
|||
`JobSearchSpecificationMySqlIntegrationTest:44` 等),Testcontainers 在 **Docker 守护进程不可用**时 |
|||
按设计**环境性跳过**。本机实测: |
|||
|
|||
``` |
|||
docker info → failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine; |
|||
check if the path is correct and if the daemon is running |
|||
``` |
|||
|
|||
**为何与 R01-B 无关**: |
|||
|
|||
1. 本轮全程**未操作 Docker**(任务书明令禁止),Docker 守护进程不在运行属环境状态; |
|||
2. **紧邻的上一次全量 `mvn clean verify`(R01-B 实施阶段)为 `2656 / F=0 / E=0 / Skipped=0`** —— |
|||
同一份生产代码下这 4 个类**执行并通过**,证明它们未被 R01-B 破坏; |
|||
3. 本轮相对该次运行,**生产代码零改动**(仅新增 3 个测试类 + 1 个测试资源 + 1 份文档); |
|||
4. 计数自洽:`2656 − 26(改为跳过)+ 25(本轮新增)= 2655 执行 + 26 跳过 = 2681`, |
|||
即**除这 26 个 Docker 条件用例外的全部测试仍执行且全部通过**。 |
|||
|
|||
**结论**:该偏差为**环境条件**,不构成 R01-B 的阻断项;但按任务书"跳过必须是 0"的字面要求, |
|||
本环境**无法满足**,故**不声称 Skipped=0**。若需在 Docker 可用环境下复核, |
|||
重新执行 `mvn clean verify` 即可(无需任何代码改动)。 |
|||
|
|||
--- |
|||
|
|||
## 15. OpenAPI |
|||
|
|||
``` |
|||
python ..\docs\verify_openapi.py |
|||
→ paths: 176 / operations: 214 |
|||
→ All checks passed! |
|||
``` |
|||
|
|||
`git diff -- docs/openapi-rc.json` → **空** ✔ |
|||
|
|||
--- |
|||
|
|||
## 16. 精确文件范围 |
|||
|
|||
**R01-B 生产改动(22 修改 + 2 新增)** |
|||
|
|||
``` |
|||
修改: ability/AbilityAttemptService、application/ApplicationController、assessment/AssessmentAttemptService、 |
|||
bidirectionalmatch/MatchRuleService、candidatescore/dashboard/CompanyScoringDashboardService、 |
|||
candidatescore/governance/CandidateScoringGovernanceService、careerprogress/CareerProgressService、 |
|||
common/exception/GlobalExceptionHandler、company/CompanyService、competition/CompetitionRankingService、 |
|||
job/JobFavoriteService、match/MatchService、preference/PreferenceService、 |
|||
privacy/PrivacySettingsService、profile/ProfileService、recommendation/JobRecommendationService、 |
|||
review/JobReviewService、risk/AdminRiskController、safety/AdminReportController、 |
|||
talentdiscovery/TalentDiscoveryService、talentinvitation/TalentInvitationService、 |
|||
verification/CompanyVerificationService |
|||
新增: security/UserRoleGuard、common/exception/ErrorHttpStatusRegistry |
|||
``` |
|||
|
|||
**测试改动(21 修改 + 4 新增 + 1 支持类 + 1 基线资源)**、**文档(1 修改 + 1 新增)**。 |
|||
本轮独立复验**仅新增**: |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentRoleVerificationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BWiringMutationTest.java |
|||
mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv |
|||
docs/r01-b-independent-verification-20260918.md |
|||
docs/acceptance/r01-b/*(验收日志与对账产物,被 .gitignore 覆盖) |
|||
``` |
|||
|
|||
**未修改任何生产代码** ✔(`git diff` 生产文件数与复验开始前一致,均为 R01-B 实施者留下的改动)。 |
|||
|
|||
--- |
|||
|
|||
## 17. Git 边界 |
|||
|
|||
| 检查 | 结果 | |
|||
|---|---| |
|||
| `git diff --check` | **exit 0**,无空白错误 ✔ | |
|||
| `git diff --cached --name-only` | **空** ✔ | |
|||
| `git diff --name-only -- …/db/migration` | **空** —— V1–V45 未改 ✔ | |
|||
| `git diff -- docs/openapi-rc.json` | **空** ✔ | |
|||
| `git diff -- mydreamjob-server/pom.xml` | **空** ✔ | |
|||
| `ErrorCode.java` | **未修改** ✔ | |
|||
| 接口 / DTO | **未修改** ✔ | |
|||
| 历史未跟踪文件 | 完整保留 ✔ | |
|||
| Java / Maven 残留进程 | 无 ✔ | |
|||
| HEAD / upstream | `84fe9f5…` / **0 0** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 18. 是否存在阻断 |
|||
|
|||
**无阻断。** |
|||
|
|||
| 复核项 | 结论 | |
|||
|---|---| |
|||
| 角色语义变化 | **无**(仅新增 inactive 失败关闭,且 HTTP 入口不可观测、非 HTTP 入口不存在) | |
|||
| 归属语义变化 | **无**(归属仍由原 Service 执行,实测 404 未被改写为 403) | |
|||
| 错误码变化 | **无**(`USER_NOT_FOUND`/`FORBIDDEN` 沿用,未新增 ErrorCode) | |
|||
| HTTP 映射变化 | **无**(166 项逐项一致) | |
|||
| 事务语义变化 | **无**(guard 只读、无 `@Transactional`,未改变任何事务边界) | |
|||
| ADMIN 权限扩大 | **无** | |
|||
| 测试削弱 | **无**(变异实验 50/63 失败) | |
|||
| 阻断项 | **0** | |
|||
|
|||
--- |
|||
|
|||
## 19. 未暂存、未提交、未推送 |
|||
|
|||
**确认:未暂存、未提交、未推送。** 本轮未执行 `git add`、未创建提交、未推送; |
|||
未执行 `reset`/`restore`/`checkout`/`clean`;首轮复验未操作 Docker,随后在负责人授权的 |
|||
补充复验中仅启动本机既有 Docker Desktop 并由 Testcontainers 创建临时 MySQL 容器, |
|||
未重建项目容器、未删除 volume;未删除历史未跟踪文件。 |
|||
变异实验对测试支持文件的临时修改已**按原 SHA256 逐字节还原**并清理备份。 |
|||
|
|||
--- |
|||
|
|||
## 20. 未进入 R01-C/E |
|||
|
|||
**确认:未进入 R01-C、未进入 R01-E。** 本轮未处理 `overviewStatus`、推荐权重、 |
|||
高校名单与评分阈值、N+1、会话未读数、requestId、身份主体解析、Token、评分快照、 |
|||
新接口与数据库迁移中的任何一项。 |
|||
|
|||
--- |
|||
|
|||
> **R01-B独立代码复验通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。** |
|||
|
|||
--- |
|||
|
|||
## 21. Docker / Testcontainers 补充复验(2026-09-18) |
|||
|
|||
### 21.1 首轮 SKIP 记录保留 |
|||
|
|||
首次独立复验时 Docker 守护进程不可用,以下 4 个测试类合计 26 个 MySQL/Testcontainers |
|||
用例按 `disabledWithoutDocker = true` 跳过;该事实及原因分析保留在本报告第 14 节,未删除、 |
|||
未改写成首次即通过: |
|||
|
|||
| 测试类 | Tests | 首轮 Skipped | |
|||
|---|---:|---:| |
|||
| `CandidateScoringRefreshMySqlIntegrationTest` | 6 | 6 | |
|||
| `JobSearchQueryExplainMySqlIntegrationTest` | 1 | 1 | |
|||
| `JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | 4 | |
|||
| `JobSearchSpecificationMySqlIntegrationTest` | 15 | 15 | |
|||
| **合计** | **26** | **26** | |
|||
|
|||
### 21.2 环境恢复 |
|||
|
|||
经负责人授权启动本机既有 Docker Desktop: |
|||
|
|||
```text |
|||
Docker Server: 29.6.2 |
|||
Testcontainers: 1.21.3 |
|||
MySQL image: mysql:8.4 |
|||
``` |
|||
|
|||
未重建项目容器、未删除或修改既有 volume。Testcontainers 实际连接 Windows npipe Docker |
|||
Engine,并成功启动 Ryuk 与临时 MySQL 8.4 容器。 |
|||
|
|||
### 21.3 26 个 MySQL 用例定向补验 |
|||
|
|||
执行: |
|||
|
|||
```text |
|||
mvn -Dtest="CandidateScoringRefreshMySqlIntegrationTest,JobSearchQueryExplainMySqlIntegrationTest,JobSearchQuerySideEffectMySqlIntegrationTest,JobSearchSpecificationMySqlIntegrationTest" test |
|||
``` |
|||
|
|||
结果: |
|||
|
|||
```text |
|||
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 |
|||
BUILD SUCCESS |
|||
``` |
|||
|
|||
4 个类均真实启动 MySQL 8.4 Testcontainer,未使用 H2 代替,未通过修改 |
|||
`disabledWithoutDocker` 或测试选择规则规避执行。 |
|||
|
|||
### 21.4 完整干净构建 |
|||
|
|||
随后执行: |
|||
|
|||
```text |
|||
mvn clean verify |
|||
``` |
|||
|
|||
结果: |
|||
|
|||
```text |
|||
Tests run: 2681, Failures: 0, Errors: 0, Skipped: 0 |
|||
BUILD SUCCESS |
|||
``` |
|||
|
|||
并重新执行 OpenAPI 校验: |
|||
|
|||
```text |
|||
paths: 176 |
|||
operations: 214 |
|||
Valid JSON: True |
|||
All checks passed! |
|||
``` |
|||
|
|||
本次补验未修改任何生产代码、测试代码、Flyway、`ErrorCode`、DTO、接口、依赖或 |
|||
`openapi-rc.json`。 |
|||
|
|||
### 21.5 最终冻结结论 |
|||
|
|||
首轮因环境导致的 26 个 SKIP 已由真实 Docker/Testcontainers 执行闭环,最终硬性门禁为 |
|||
`Failures=0 / Errors=0 / Skipped=0`。 |
|||
|
|||
> **R01-B独立代码复验及Docker/Testcontainers补充验证全部通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。** |
|||
@ -0,0 +1,261 @@ |
|||
package com.mydreamjob.common.exception; |
|||
|
|||
import org.springframework.http.HttpStatus; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.LinkedHashMap; |
|||
import java.util.Map; |
|||
import java.util.Objects; |
|||
|
|||
/** |
|||
* 业务错误码 → HTTP 状态的**唯一**集中映射(R01-B/B2)。 |
|||
* |
|||
* <p>职责边界:只回答"这个业务错误码应该对应哪个 HTTP 状态"。不做日志、不读请求、 |
|||
* 不生成响应体、不解析 requestId、不缓存请求级状态。</p> |
|||
* |
|||
* <h2>契约(与重构前逐项一致)</h2> |
|||
* <ul> |
|||
* <li>显式映射只在 {@link #explicitMappings()} 一处定义,全部使用 |
|||
* {@code ErrorCode.X} 符号,不出现手写数字;</li> |
|||
* <li>**未显式映射的错误码回退 {@link #DEFAULT_STATUS}(400 BAD_REQUEST)**, |
|||
* 与重构前 {@code getOrDefault(code, BAD_REQUEST)} 完全一致;</li> |
|||
* <li>**映射键是 {@link ErrorCode} 本身,不是数字**。这一点是必须的: |
|||
* {@code ErrorCode} 现有 166 个常量却只有 165 个唯一数字码 |
|||
* ({@code REVIEW_ALREADY_DECIDED} 与 {@code DUPLICATE_FEEDBACK} 同为 9103), |
|||
* 若以数字为键,"禁止重复键"将无法在不修改 {@code ErrorCode} 数字的前提下成立。 |
|||
* 数字入口 {@link #resolve(int)} 由该映射派生;若两个不同常量被映射到 |
|||
* **同一个数字码且状态不同**,初始化即抛错(这是不可接受的歧义);</li> |
|||
* <li>映射表初始化后不可变({@link Map#copyOf}),运行期无法增删改;</li> |
|||
* <li>同一个 {@link ErrorCode} 常量被注册两次即为**初始化错误** |
|||
* (无论目标状态是否相同),由 {@link Builder#put} 立即抛出 |
|||
* {@link IllegalStateException},使"重复键"在启动期而不是请求期暴露;</li> |
|||
* <li>不使用反射扫描,不引入任何运行时可变的 Map。</li> |
|||
* </ul> |
|||
* |
|||
* <p><strong>注意</strong>:{@code UNAUTHORIZED(2001)} 与 {@code SERVICE_UNAVAILABLE} |
|||
* 在本映射中**故意不显式登记**,因此既有语义是回退 400 —— 这是重构前就存在的行为, |
|||
* 本次收束不得擅自把它改成 401/503。</p> |
|||
*/ |
|||
@Component |
|||
public final class ErrorHttpStatusRegistry { |
|||
|
|||
/** 未显式映射时的回退状态(与重构前一致)。 */ |
|||
public static final HttpStatus DEFAULT_STATUS = HttpStatus.BAD_REQUEST; |
|||
|
|||
private static final Map<ErrorCode, HttpStatus> EXPLICIT = explicitMappings(); |
|||
private static final Map<Integer, HttpStatus> BY_CODE = indexByCode(EXPLICIT); |
|||
|
|||
/** 按 {@link ErrorCode} 解析。{@code null} 走默认回退,不做 NPE 抛出。 */ |
|||
public HttpStatus resolve(ErrorCode errorCode) { |
|||
return errorCode == null ? DEFAULT_STATUS : EXPLICIT.getOrDefault(errorCode, DEFAULT_STATUS); |
|||
} |
|||
|
|||
/** 按数字业务码解析({@code BusinessException} 只暴露数字 code)。 */ |
|||
public HttpStatus resolve(int errorCode) { |
|||
return BY_CODE.getOrDefault(errorCode, DEFAULT_STATUS); |
|||
} |
|||
|
|||
/** 未映射错误码的默认状态。 */ |
|||
public HttpStatus defaultStatus() { |
|||
return DEFAULT_STATUS; |
|||
} |
|||
|
|||
/** 显式登记的 {@link ErrorCode} 常量数量(用于一致性校验与报告)。 */ |
|||
public int explicitMappingCount() { |
|||
return EXPLICIT.size(); |
|||
} |
|||
|
|||
/** 显式映射覆盖的唯一数字码数量(小于等于 {@link #explicitMappingCount()})。 */ |
|||
public int explicitNumericCodeCount() { |
|||
return BY_CODE.size(); |
|||
} |
|||
|
|||
/** 只读视图:显式映射的 {@link ErrorCode} → 状态。 */ |
|||
public static Map<ErrorCode, HttpStatus> explicitMappingsView() { |
|||
return EXPLICIT; |
|||
} |
|||
|
|||
/** 只读视图:数字业务码 → 状态(由 {@link #explicitMappingsView()} 派生)。 */ |
|||
public static Map<Integer, HttpStatus> numericMappingsView() { |
|||
return BY_CODE; |
|||
} |
|||
|
|||
/** 某个错误码是否被显式登记。 */ |
|||
public static boolean isExplicitlyMapped(ErrorCode errorCode) { |
|||
return errorCode != null && EXPLICIT.containsKey(errorCode); |
|||
} |
|||
|
|||
/** |
|||
* 由 {@link ErrorCode} 键映射派生数字键索引。 |
|||
* |
|||
* <p>同一数字码对应多个常量是**既有事实**(9103)。只要它们被映射到相同状态, |
|||
* 数字入口就是无歧义的,保持既有行为;一旦状态不同,说明存在无法自动裁决的冲突, |
|||
* 初始化必须失败而不是任选其一。</p> |
|||
*/ |
|||
private static Map<Integer, HttpStatus> indexByCode(Map<ErrorCode, HttpStatus> explicit) { |
|||
Map<Integer, HttpStatus> byCode = new LinkedHashMap<>(); |
|||
for (Map.Entry<ErrorCode, HttpStatus> entry : explicit.entrySet()) { |
|||
HttpStatus previous = byCode.putIfAbsent(entry.getKey().getCode(), entry.getValue()); |
|||
if (previous != null && previous != entry.getValue()) { |
|||
throw new IllegalStateException("conflicting HTTP status mapping for numeric error code " |
|||
+ entry.getKey().getCode() + ": " + previous + " then " + entry.getValue()); |
|||
} |
|||
} |
|||
return Map.copyOf(byCode); |
|||
} |
|||
|
|||
/** |
|||
* 显式映射定义(唯一位置)。仅登记与重构前完全一致的 (错误码, 状态) 组合。 |
|||
*/ |
|||
private static Map<ErrorCode, HttpStatus> explicitMappings() { |
|||
return new Builder() |
|||
.put(HttpStatus.FORBIDDEN, |
|||
ErrorCode.APPLICATION_OWNER_MISMATCH, |
|||
ErrorCode.FORBIDDEN, |
|||
ErrorCode.INTERVIEW_ACCESS_DENIED, |
|||
ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION, |
|||
ErrorCode.OFFER_COMPARE_NOT_OWNER) |
|||
.put(HttpStatus.NOT_FOUND, |
|||
ErrorCode.ABILITY_ATTEMPT_NOT_FOUND, |
|||
ErrorCode.ABILITY_RESULT_NOT_FOUND, |
|||
ErrorCode.ABILITY_TYPE_NOT_FOUND, |
|||
ErrorCode.APPEAL_NOT_FOUND, |
|||
ErrorCode.APPLICATION_NOT_FOUND, |
|||
ErrorCode.ASSESSMENT_ATTEMPT_NOT_FOUND, |
|||
ErrorCode.ASSESSMENT_TYPE_NOT_FOUND, |
|||
ErrorCode.COMPANY_NOT_FOUND, |
|||
ErrorCode.CONVERSATION_NOT_FOUND, |
|||
ErrorCode.DATA_EXPORT_TASK_NOT_FOUND, |
|||
ErrorCode.FEEDBACK_NOT_FOUND, |
|||
ErrorCode.INTERVIEW_SCORE_NOT_FOUND, |
|||
ErrorCode.JOB_NOT_FOUND, |
|||
ErrorCode.MESSAGE_NOT_FOUND, |
|||
ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND, |
|||
ErrorCode.OFFER_NOT_FOUND, |
|||
ErrorCode.PROFILE_NOT_FOUND, |
|||
ErrorCode.REPORT_NOT_FOUND, |
|||
ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND, |
|||
ErrorCode.RESUME_VERSION_NOT_FOUND, |
|||
ErrorCode.REVIEW_NOT_FOUND, |
|||
ErrorCode.REVIEW_REPLY_NOT_FOUND, |
|||
ErrorCode.RISK_EVENT_NOT_FOUND, |
|||
ErrorCode.RISK_RULE_NOT_FOUND, |
|||
ErrorCode.TALENT_INVITATION_NOT_FOUND, |
|||
ErrorCode.USER_NOT_FOUND, |
|||
ErrorCode.VERIFICATION_NOT_FOUND) |
|||
.put(HttpStatus.CONFLICT, |
|||
ErrorCode.ABILITY_ATTEMPT_INVALID_STATE, |
|||
ErrorCode.ABILITY_CONCURRENT_UPDATE, |
|||
ErrorCode.ABILITY_DUPLICATE_ACTIVE, |
|||
ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT, |
|||
ErrorCode.ABILITY_RETAKE_COOLDOWN, |
|||
ErrorCode.ACCOUNT_DELETED, |
|||
ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED, |
|||
ErrorCode.ACCOUNT_DELETION_CONCURRENT_UPDATE, |
|||
ErrorCode.ACCOUNT_DELETION_PENDING, |
|||
ErrorCode.APPEAL_ALREADY_DECIDED, |
|||
ErrorCode.APPEAL_DUPLICATE, |
|||
ErrorCode.APPEAL_IDEMPOTENCY_CONFLICT, |
|||
ErrorCode.APPEAL_INVALID_TRANSITION, |
|||
ErrorCode.APPEAL_ROLLBACK_LIMIT_EXCEEDED, |
|||
ErrorCode.APPLICATION_DUPLICATE, |
|||
ErrorCode.APPLICATION_NOT_MATCHED, |
|||
ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE, |
|||
ErrorCode.ASSESSMENT_CONCURRENT_UPDATE, |
|||
ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE, |
|||
ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT, |
|||
ErrorCode.COMPANY_ALREADY_EXISTS, |
|||
ErrorCode.DATA_EXPORT_DUPLICATE, |
|||
ErrorCode.DATA_EXPORT_EXPIRED, |
|||
ErrorCode.DATA_EXPORT_FAILED, |
|||
ErrorCode.DATA_EXPORT_FILE_NOT_FOUND, |
|||
ErrorCode.DATA_EXPORT_NOT_READY, |
|||
ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED, |
|||
ErrorCode.DUPLICATE_DEFAULT_RESUME, |
|||
ErrorCode.DUPLICATE_FEEDBACK, |
|||
ErrorCode.DUPLICATE_OFFER, |
|||
ErrorCode.DUPLICATE_REVIEW, |
|||
ErrorCode.INTERVIEW_ALREADY_RESPONDED, |
|||
ErrorCode.INTERVIEW_COMPLETE_NOT_READY, |
|||
ErrorCode.INTERVIEW_NOT_COMPLETED, |
|||
ErrorCode.INTERVIEW_SCORE_CONCURRENT_UPDATE, |
|||
ErrorCode.INTERVIEW_SCORE_SUBMITTED, |
|||
ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION, |
|||
ErrorCode.INVALID_DELETION_STATE, |
|||
ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION, |
|||
ErrorCode.INVALID_JOB_STATUS_TRANSITION, |
|||
ErrorCode.INVALID_OFFER_STATUS_TRANSITION, |
|||
ErrorCode.INVALID_VERIFICATION_STATUS_TRANSITION, |
|||
ErrorCode.LAST_ADMIN_CANNOT_DELETE, |
|||
ErrorCode.MATCH_CLOSED, |
|||
ErrorCode.MATCH_SCORES_LOCKED, |
|||
ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE, |
|||
ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED, |
|||
ErrorCode.OFFER_COUNTER_OFFER_EXISTS, |
|||
ErrorCode.OFFER_COUNTER_OFFER_EXPIRED, |
|||
ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING, |
|||
ErrorCode.OFFER_EXPIRED, |
|||
ErrorCode.OFFER_NOT_EDITABLE, |
|||
ErrorCode.OFFER_REMINDER_DUPLICATE, |
|||
ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE, |
|||
ErrorCode.REPORT_ALREADY_DECIDED, |
|||
ErrorCode.REPORT_DUPLICATE, |
|||
ErrorCode.REPORT_IDEMPOTENCY_CONFLICT, |
|||
ErrorCode.REPORT_INVALID_TRANSITION, |
|||
ErrorCode.RESUME_EXPORT_IN_PROGRESS, |
|||
ErrorCode.RESUME_EXPORT_NOT_READY, |
|||
ErrorCode.REVIEW_ALREADY_DECIDED, |
|||
ErrorCode.REVIEW_REPLY_ALREADY_EXISTS, |
|||
ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE, |
|||
ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT, |
|||
ErrorCode.REVIEW_REPLY_INVALID_STATE, |
|||
ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE, |
|||
ErrorCode.RISK_EVENT_CONCURRENT_UPDATE, |
|||
ErrorCode.RISK_INVALID_ACTION, |
|||
ErrorCode.RISK_RULE_CONCURRENT_UPDATE, |
|||
ErrorCode.TALENT_INVITATION_ALREADY_ACTIVE, |
|||
ErrorCode.TALENT_INVITATION_CANDIDATE_UNAVAILABLE, |
|||
ErrorCode.TALENT_INVITATION_CONCURRENT_UPDATE, |
|||
ErrorCode.TALENT_INVITATION_EXPIRED, |
|||
ErrorCode.TALENT_INVITATION_IDEMPOTENCY_CONFLICT, |
|||
ErrorCode.TALENT_INVITATION_INVALID_STATE, |
|||
ErrorCode.VERIFICATION_ALREADY_APPROVED, |
|||
ErrorCode.VERIFICATION_ALREADY_PENDING) |
|||
.put(HttpStatus.TOO_MANY_REQUESTS, |
|||
ErrorCode.ABILITY_RETAKE_LIMIT, |
|||
ErrorCode.REPORT_RATE_LIMITED, |
|||
ErrorCode.TALENT_INVITATION_RATE_LIMITED) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 显式映射构建器。 |
|||
* |
|||
* <p>重复键检测:同一个数字错误码第二次注册即抛 {@link IllegalStateException}, |
|||
* **不区分目标状态是否相同** —— "同码同状态的重复登记"同样是不可审计的配置错误。</p> |
|||
*/ |
|||
public static final class Builder { |
|||
|
|||
private final Map<ErrorCode, HttpStatus> entries = new LinkedHashMap<>(); |
|||
|
|||
public Builder put(HttpStatus status, ErrorCode... errorCodes) { |
|||
Objects.requireNonNull(status, "status"); |
|||
Objects.requireNonNull(errorCodes, "errorCodes"); |
|||
for (ErrorCode errorCode : errorCodes) { |
|||
Objects.requireNonNull(errorCode, "errorCode"); |
|||
HttpStatus previous = entries.putIfAbsent(errorCode, status); |
|||
if (previous != null) { |
|||
throw new IllegalStateException( |
|||
"duplicate HTTP status mapping for error code " + errorCode.getCode() |
|||
+ " (" + errorCode.name() + "): " + previous + " then " + status); |
|||
} |
|||
} |
|||
return this; |
|||
} |
|||
|
|||
/** 构建不可变映射(键为 {@link ErrorCode})。 */ |
|||
public Map<ErrorCode, HttpStatus> build() { |
|||
return Map.copyOf(entries); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,142 @@ |
|||
package com.mydreamjob.security; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
/** |
|||
* 业务角色授权守卫(R01-B/B1)。 |
|||
* |
|||
* <p>把散落在各 Service/Controller 中的"读取用户 → 判断角色 → 抛错"重复实现, |
|||
* 收束到唯一实现点。</p> |
|||
* |
|||
* <h2>职责边界</h2> |
|||
* <ul> |
|||
* <li><strong>只做角色授权</strong>,不做资源归属(ownership)校验 —— |
|||
* 归属仍由各业务 Service 负责(如 {@code assertOwnerOrThrow});</li> |
|||
* <li><strong>不解析 SecurityContext</strong>:"当前登录用户是谁"仍由 |
|||
* {@link CurrentUserAccessor} 负责,本类只接收已解析出的 {@code userId};</li> |
|||
* <li><strong>不处理 requestId</strong>,不读请求头;</li> |
|||
* <li><strong>不提供任何"由调用方传入角色"的入口</strong>:角色只能来自 |
|||
* {@link User.Role} 枚举常量,客户端输入无法影响判定;</li> |
|||
* <li><strong>不给 ADMIN 隐式超级权限</strong>:{@code requireStudent}/{@code requireCompany} |
|||
* 对 ADMIN 同样拒绝,除非调用方显式传入 {@code requireAny(..., ADMIN)};</li> |
|||
* <li>无副作用:只读用户表,不写库、不写审计、不记日志(尤其不记 userId / 手机号 / Token)。</li> |
|||
* </ul> |
|||
* |
|||
* <h2>角色来源</h2> |
|||
* <p>角色取自 {@code user.getRoles()},即 {@code user_roles} 关联表 |
|||
* —— 与本类替换掉的全部既有 guard 完全一致。{@code users.role} 单值列用于 JWT 声明签发, |
|||
* <strong>不</strong>参与此处授权判定:把两者混用会改变既有语义。</p> |
|||
* |
|||
* <h2>失败关闭</h2> |
|||
* <ul> |
|||
* <li>用户不存在 → {@link ErrorCode#USER_NOT_FOUND}(沿用既有错误码);</li> |
|||
* <li>用户存在但 <strong>inactive</strong> → {@link ErrorCode#FORBIDDEN} |
|||
* (比既有实现更严格:被禁用的用户不再因"角色正确"而通过);</li> |
|||
* <li>{@code user_roles} 中缺少所需角色 → {@link ErrorCode#FORBIDDEN} |
|||
* (数据不一致时同样是拒绝,而不是放行)。</li> |
|||
* </ul> |
|||
* |
|||
* <h2>查询次数</h2> |
|||
* <p>{@code requireXxx(Long)} 每次调用恰好 <strong>1</strong> 次 {@code findById}, |
|||
* 不做跨请求缓存(用户被注销/禁用后必须立即失效)。调用方若已加载 |
|||
* {@link User},应使用 {@code requireXxx(User)} 重载以避免第二次查询。</p> |
|||
*/ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class UserRoleGuard { |
|||
|
|||
private final UserRepository userRepository; |
|||
|
|||
// ── 按 userId 校验(每次 1 次查询)──────────────────────────
|
|||
|
|||
public void requireStudent(Long userId) { |
|||
requireRole(requireUser(userId), User.Role.STUDENT); |
|||
} |
|||
|
|||
public void requireCompany(Long userId) { |
|||
requireRole(requireUser(userId), User.Role.COMPANY); |
|||
} |
|||
|
|||
public void requireAdmin(Long userId) { |
|||
requireRole(requireUser(userId), User.Role.ADMIN); |
|||
} |
|||
|
|||
/** 允许集合中的任意一个角色(集合由调用方以枚举常量给出,不接受客户端输入)。 */ |
|||
public void requireAny(Long userId, User.Role... allowedRoles) { |
|||
requireRole(requireUser(userId), allowedRoles); |
|||
} |
|||
|
|||
// ── 按已加载实体校验(0 次查询)────────────────────────────
|
|||
|
|||
public void requireStudent(User user) { |
|||
requireRole(user, User.Role.STUDENT); |
|||
} |
|||
|
|||
public void requireCompany(User user) { |
|||
requireRole(user, User.Role.COMPANY); |
|||
} |
|||
|
|||
public void requireAdmin(User user) { |
|||
requireRole(user, User.Role.ADMIN); |
|||
} |
|||
|
|||
public void requireAny(User user, User.Role... allowedRoles) { |
|||
requireRole(user, allowedRoles); |
|||
} |
|||
|
|||
// ── 基础能力 ───────────────────────────────────────────────
|
|||
|
|||
/** |
|||
* 加载用户并校验其可用(存在且 active)。 |
|||
* |
|||
* @throws BusinessException {@link ErrorCode#USER_NOT_FOUND} 用户不存在; |
|||
* {@link ErrorCode#FORBIDDEN} 用户存在但已禁用 |
|||
*/ |
|||
public User requireUser(Long userId) { |
|||
if (userId == null || userId <= 0) { |
|||
throw new BusinessException(ErrorCode.USER_NOT_FOUND); |
|||
} |
|||
User user = userRepository.findById(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
if (!isActive(user)) { |
|||
// 失败关闭:被禁用的用户不得因角色正确而通过授权。
|
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
return user; |
|||
} |
|||
|
|||
/** |
|||
* 校验已加载用户是否具备所需角色之一。 |
|||
* |
|||
* <p>角色来源为 {@code user_roles};缺失任一所需角色即拒绝。</p> |
|||
*/ |
|||
public void requireRole(User user, User.Role... allowedRoles) { |
|||
if (user == null) { |
|||
throw new BusinessException(ErrorCode.USER_NOT_FOUND); |
|||
} |
|||
if (!isActive(user)) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
if (allowedRoles == null || allowedRoles.length == 0) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
if (user.getRoles() == null) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
for (User.Role allowed : allowedRoles) { |
|||
if (allowed != null && user.getRoles().contains(allowed)) { |
|||
return; |
|||
} |
|||
} |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
|
|||
private static boolean isActive(User user) { |
|||
return user.getActive() != null && user.getActive(); |
|||
} |
|||
} |
|||
@ -0,0 +1,156 @@ |
|||
package com.mydreamjob.common.r01b; |
|||
|
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.common.exception.ErrorHttpStatusRegistry; |
|||
import org.junit.jupiter.api.DisplayName; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.EnumSource; |
|||
import org.springframework.http.HttpStatus; |
|||
|
|||
import java.util.EnumSet; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
/** |
|||
* R01-B/B2:集中式错误码 → HTTP 状态映射的契约测试。 |
|||
* |
|||
* <p>覆盖任务书要求:单一映射、显式映射集中、不可变、重复键初始化即失败、 |
|||
* 未映射回退 400、只保留既有语义。</p> |
|||
*/ |
|||
class ErrorHttpStatusRegistryTest { |
|||
|
|||
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); |
|||
|
|||
/** 允许出现在显式映射里的状态集合:重构前后都必须只有这四个。 */ |
|||
private static final Set<HttpStatus> ALLOWED_EXPLICIT_STATUSES = Set.of( |
|||
HttpStatus.FORBIDDEN, HttpStatus.NOT_FOUND, HttpStatus.CONFLICT, HttpStatus.TOO_MANY_REQUESTS); |
|||
|
|||
@Test |
|||
@DisplayName("B2:显式映射数量与重构前快照一致(112 个 ErrorCode 常量)") |
|||
void explicitMappingCountMatchesBaseline() { |
|||
assertThat(registry.explicitMappingCount()).isEqualTo(112); |
|||
// 112 个常量只覆盖 111 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
|
|||
// DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。
|
|||
assertThat(registry.explicitNumericCodeCount()).isEqualTo(111); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B2:映射键是 ErrorCode 常量本身,不是数字码") |
|||
void mappingKeysAreErrorCodeConstants() { |
|||
assertThat(ErrorHttpStatusRegistry.explicitMappingsView().keySet()) |
|||
.isSubsetOf(EnumSet.allOf(ErrorCode.class)); |
|||
|
|||
// 两个常量共用数字码 9103 时必须同时显式登记,否则说明键被错误地按数字去重
|
|||
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.REVIEW_ALREADY_DECIDED)).isTrue(); |
|||
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.DUPLICATE_FEEDBACK)).isTrue(); |
|||
assertThat(ErrorCode.REVIEW_ALREADY_DECIDED.getCode()) |
|||
.as("9103 为两个既有常量共用(潜在缺陷,本轮不修改 ErrorCode)") |
|||
.isEqualTo(ErrorCode.DUPLICATE_FEEDBACK.getCode()); |
|||
// 二者状态一致,数字入口因此无歧义
|
|||
assertThat(registry.resolve(ErrorCode.REVIEW_ALREADY_DECIDED)) |
|||
.isEqualTo(registry.resolve(ErrorCode.DUPLICATE_FEEDBACK)); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B2:显式映射只使用四个既有状态,未引入新状态") |
|||
void explicitMappingsUseOnlyPreExistingStatuses() { |
|||
assertThat(new java.util.LinkedHashSet<>(ErrorHttpStatusRegistry.explicitMappingsView().values())) |
|||
.as("显式映射出现的状态集合") |
|||
.isSubsetOf(ALLOWED_EXPLICIT_STATUSES); |
|||
} |
|||
|
|||
@ParameterizedTest(name = "{0}") |
|||
@EnumSource(ErrorCode.class) |
|||
@DisplayName("B2:显式映射逐项自洽;未映射码一律回退 400") |
|||
void resolveMatchesMappingTableForEveryErrorCode(ErrorCode errorCode) { |
|||
HttpStatus expected = ErrorHttpStatusRegistry.explicitMappingsView() |
|||
.getOrDefault(errorCode, HttpStatus.BAD_REQUEST); |
|||
|
|||
assertThat(registry.resolve(errorCode)).isEqualTo(expected); |
|||
assertThat(registry.resolve(errorCode.getCode())).as("数字入口与枚举入口一致").isEqualTo(expected); |
|||
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(errorCode)) |
|||
.isEqualTo(ErrorHttpStatusRegistry.explicitMappingsView().containsKey(errorCode)); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B2:未映射错误码保持既有 400 回退(含 UNAUTHORIZED 与 SERVICE_UNAVAILABLE)") |
|||
void unmappedErrorCodesKeepBadRequestFallback() { |
|||
// 既有语义:这两个码在重构前就没有显式映射,因此是 400,不是 401/503。
|
|||
assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.resolve(ErrorCode.BAD_REQUEST)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.resolve(ErrorCode.INTERNAL_ERROR)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.resolve(ErrorCode.TOKEN_EXPIRED)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
// 完全未知的数字码
|
|||
assertThat(registry.resolve(0)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.resolve(-1)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.resolve(123456789)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
// null 不抛 NPE
|
|||
assertThat(registry.resolve((ErrorCode) null)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.defaultStatus()).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B2:重复注册同一错误码(即使状态相同)必须在注册时失败") |
|||
void duplicateKeysAreRejectedAtRegistration() { |
|||
assertThatThrownBy(() -> new ErrorHttpStatusRegistry.Builder() |
|||
.put(HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN) |
|||
.put(HttpStatus.NOT_FOUND, ErrorCode.FORBIDDEN)) |
|||
.isInstanceOf(IllegalStateException.class) |
|||
.hasMessageContaining("duplicate HTTP status mapping") |
|||
.hasMessageContaining(String.valueOf(ErrorCode.FORBIDDEN.getCode())); |
|||
|
|||
// 同一状态下的重复登记同样是配置错误,不得静默容忍
|
|||
assertThatThrownBy(() -> new ErrorHttpStatusRegistry.Builder() |
|||
.put(HttpStatus.NOT_FOUND, ErrorCode.JOB_NOT_FOUND) |
|||
.put(HttpStatus.NOT_FOUND, ErrorCode.JOB_NOT_FOUND, ErrorCode.USER_NOT_FOUND)) |
|||
.isInstanceOf(IllegalStateException.class) |
|||
.hasMessageContaining("duplicate HTTP status mapping"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B2:构建结果不可变,运行期无法增删改") |
|||
void builtMappingIsImmutable() { |
|||
Map<ErrorCode, HttpStatus> view = ErrorHttpStatusRegistry.explicitMappingsView(); |
|||
assertThatThrownBy(() -> view.put(ErrorCode.FORBIDDEN, HttpStatus.OK)) |
|||
.isInstanceOf(UnsupportedOperationException.class); |
|||
assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN)) |
|||
.isInstanceOf(UnsupportedOperationException.class); |
|||
assertThat(view).hasSize(112); |
|||
|
|||
Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView(); |
|||
assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) |
|||
.isInstanceOf(UnsupportedOperationException.class); |
|||
assertThat(numeric).hasSize(111); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B2:不同 ErrorCode 常量允许各自登记(即使数字码相同且状态相同)") |
|||
void distinctConstantsWithSharedNumericCodeAreAllowed() { |
|||
Map<ErrorCode, HttpStatus> built = new ErrorHttpStatusRegistry.Builder() |
|||
.put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED) |
|||
.put(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_FEEDBACK) |
|||
.build(); |
|||
|
|||
assertThat(built).hasSize(2); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B2:注册顺序不影响解析结果") |
|||
void registrationOrderDoesNotChangeResolution() { |
|||
Map<ErrorCode, HttpStatus> forward = new ErrorHttpStatusRegistry.Builder() |
|||
.put(HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN) |
|||
.put(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND) |
|||
.build(); |
|||
Map<ErrorCode, HttpStatus> reverse = new ErrorHttpStatusRegistry.Builder() |
|||
.put(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND) |
|||
.put(HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN) |
|||
.build(); |
|||
|
|||
assertThat(forward).isEqualTo(reverse); |
|||
} |
|||
} |
|||
@ -0,0 +1,188 @@ |
|||
package com.mydreamjob.common.r01b; |
|||
|
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.common.exception.ErrorHttpStatusRegistry; |
|||
import com.mydreamjob.common.exception.GlobalExceptionHandler; |
|||
import com.mydreamjob.common.logging.RequestIdAccessor; |
|||
import org.junit.jupiter.api.DisplayName; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.EnumSource; |
|||
import org.springframework.http.HttpStatus; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.mock.web.MockHttpServletRequest; |
|||
|
|||
import java.util.Set; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
/** |
|||
* R01-B/B2:重构前后 HTTP 映射**逐项一致**证明。 |
|||
* |
|||
* <p>这里的四个状态桶是**重构前**从 {@code GlobalExceptionHandler.buildBusinessHttpStatuses()} |
|||
* 逐条抄录的独立快照(亦保存于 {@code docs/acceptance/r01-b/b2-mapping-baseline.json}), |
|||
* 不是从重构后的实现反推。任何一个错误码的状态发生漂移,本测试都会失败。</p> |
|||
*/ |
|||
class GlobalExceptionHandlerMappingParityTest { |
|||
|
|||
private static final Set<String> FORBIDDEN = Set.of( |
|||
"APPLICATION_OWNER_MISMATCH", "FORBIDDEN", "INTERVIEW_ACCESS_DENIED", |
|||
"MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER"); |
|||
|
|||
private static final Set<String> NOT_FOUND = Set.of( |
|||
"ABILITY_ATTEMPT_NOT_FOUND", "ABILITY_RESULT_NOT_FOUND", "ABILITY_TYPE_NOT_FOUND", |
|||
"APPEAL_NOT_FOUND", "APPLICATION_NOT_FOUND", "ASSESSMENT_ATTEMPT_NOT_FOUND", |
|||
"ASSESSMENT_TYPE_NOT_FOUND", "COMPANY_NOT_FOUND", "CONVERSATION_NOT_FOUND", |
|||
"DATA_EXPORT_TASK_NOT_FOUND", "FEEDBACK_NOT_FOUND", "INTERVIEW_SCORE_NOT_FOUND", |
|||
"JOB_NOT_FOUND", "MESSAGE_NOT_FOUND", "OFFER_COUNTER_OFFER_NOT_FOUND", |
|||
"OFFER_NOT_FOUND", "PROFILE_NOT_FOUND", "REPORT_NOT_FOUND", |
|||
"RESUME_EXPORT_TASK_NOT_FOUND", "RESUME_VERSION_NOT_FOUND", "REVIEW_NOT_FOUND", |
|||
"REVIEW_REPLY_NOT_FOUND", "RISK_EVENT_NOT_FOUND", "RISK_RULE_NOT_FOUND", |
|||
"TALENT_INVITATION_NOT_FOUND", "USER_NOT_FOUND", "VERIFICATION_NOT_FOUND"); |
|||
|
|||
private static final Set<String> CONFLICT = Set.of( |
|||
"ABILITY_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE", |
|||
"ABILITY_DUPLICATE_ACTIVE", "ABILITY_IDEMPOTENCY_CONFLICT", |
|||
"ABILITY_RETAKE_COOLDOWN", "ACCOUNT_DELETED", |
|||
"ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED", "ACCOUNT_DELETION_CONCURRENT_UPDATE", |
|||
"ACCOUNT_DELETION_PENDING", "APPEAL_ALREADY_DECIDED", "APPEAL_DUPLICATE", |
|||
"APPEAL_IDEMPOTENCY_CONFLICT", "APPEAL_INVALID_TRANSITION", |
|||
"APPEAL_ROLLBACK_LIMIT_EXCEEDED", "APPLICATION_DUPLICATE", |
|||
"APPLICATION_NOT_MATCHED", "ASSESSMENT_ATTEMPT_INVALID_STATE", |
|||
"ASSESSMENT_CONCURRENT_UPDATE", "ASSESSMENT_DUPLICATE_ACTIVE", |
|||
"ASSESSMENT_IDEMPOTENCY_CONFLICT", "COMPANY_ALREADY_EXISTS", |
|||
"DATA_EXPORT_DUPLICATE", "DATA_EXPORT_EXPIRED", "DATA_EXPORT_FAILED", |
|||
"DATA_EXPORT_FILE_NOT_FOUND", "DATA_EXPORT_NOT_READY", |
|||
"DEFAULT_RESUME_DELETE_NOT_ALLOWED", "DUPLICATE_DEFAULT_RESUME", |
|||
"DUPLICATE_FEEDBACK", "DUPLICATE_OFFER", "DUPLICATE_REVIEW", |
|||
"INTERVIEW_ALREADY_RESPONDED", "INTERVIEW_COMPLETE_NOT_READY", |
|||
"INTERVIEW_NOT_COMPLETED", "INTERVIEW_SCORE_CONCURRENT_UPDATE", |
|||
"INTERVIEW_SCORE_SUBMITTED", "INVALID_APPLICATION_STATUS_TRANSITION", |
|||
"INVALID_DELETION_STATE", "INVALID_INTERVIEW_STATUS_TRANSITION", |
|||
"INVALID_JOB_STATUS_TRANSITION", "INVALID_OFFER_STATUS_TRANSITION", |
|||
"INVALID_VERIFICATION_STATUS_TRANSITION", "LAST_ADMIN_CANNOT_DELETE", |
|||
"MATCH_CLOSED", "MATCH_SCORES_LOCKED", "MESSAGE_RECALL_CONCURRENT_UPDATE", |
|||
"MESSAGE_RECALL_WINDOW_EXPIRED", "OFFER_COUNTER_OFFER_EXISTS", |
|||
"OFFER_COUNTER_OFFER_EXPIRED", "OFFER_COUNTER_OFFER_NOT_PENDING", "OFFER_EXPIRED", |
|||
"OFFER_NOT_EDITABLE", "OFFER_REMINDER_DUPLICATE", |
|||
"PRIVACY_SETTINGS_CONCURRENT_UPDATE", "REPORT_ALREADY_DECIDED", "REPORT_DUPLICATE", |
|||
"REPORT_IDEMPOTENCY_CONFLICT", "REPORT_INVALID_TRANSITION", |
|||
"RESUME_EXPORT_IN_PROGRESS", "RESUME_EXPORT_NOT_READY", "REVIEW_ALREADY_DECIDED", |
|||
"REVIEW_REPLY_ALREADY_EXISTS", "REVIEW_REPLY_CONCURRENT_UPDATE", |
|||
"REVIEW_REPLY_IDEMPOTENCY_CONFLICT", "REVIEW_REPLY_INVALID_STATE", |
|||
"REVIEW_WEIGHT_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", |
|||
"RISK_INVALID_ACTION", "RISK_RULE_CONCURRENT_UPDATE", |
|||
"TALENT_INVITATION_ALREADY_ACTIVE", "TALENT_INVITATION_CANDIDATE_UNAVAILABLE", |
|||
"TALENT_INVITATION_CONCURRENT_UPDATE", "TALENT_INVITATION_EXPIRED", |
|||
"TALENT_INVITATION_IDEMPOTENCY_CONFLICT", "TALENT_INVITATION_INVALID_STATE", |
|||
"VERIFICATION_ALREADY_APPROVED", "VERIFICATION_ALREADY_PENDING"); |
|||
|
|||
private static final Set<String> TOO_MANY_REQUESTS = Set.of( |
|||
"ABILITY_RETAKE_LIMIT", "REPORT_RATE_LIMITED", "TALENT_INVITATION_RATE_LIMITED"); |
|||
|
|||
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); |
|||
private final GlobalExceptionHandler handler = |
|||
new GlobalExceptionHandler(new RequestIdAccessor(), registry); |
|||
|
|||
private static HttpStatus baselineStatus(ErrorCode errorCode) { |
|||
String name = errorCode.name(); |
|||
if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN; |
|||
if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND; |
|||
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT; |
|||
if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS; |
|||
return HttpStatus.BAD_REQUEST; |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B2:显式映射集合与重构前快照逐项相等") |
|||
void explicitMappingSetEqualsPreRefactorSnapshot() { |
|||
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(112); |
|||
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(111); |
|||
|
|||
for (ErrorCode errorCode : ErrorCode.values()) { |
|||
HttpStatus expected = baselineStatus(errorCode); |
|||
if (expected == HttpStatus.BAD_REQUEST) { |
|||
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(errorCode)) |
|||
.as("%s 在快照中未显式映射,重构后也不得显式映射", errorCode.name()) |
|||
.isFalse(); |
|||
} else { |
|||
assertThat(registry.resolve(errorCode)) |
|||
.as("%s 的显式映射状态", errorCode.name()) |
|||
.isEqualTo(expected); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ParameterizedTest(name = "{0}") |
|||
@EnumSource(ErrorCode.class) |
|||
@DisplayName("B2:处理器实际返回的状态与其 registry 解析结果、以及快照三者一致") |
|||
void handlerStatusMatchesRegistryAndSnapshotForEveryErrorCode(ErrorCode errorCode) { |
|||
ResponseEntity<ApiResponse<Void>> response = |
|||
handler.handleBusinessException(new BusinessException(errorCode), request()); |
|||
|
|||
assertThat(response.getStatusCode()).isEqualTo(baselineStatus(errorCode)); |
|||
assertThat(response.getStatusCode()).isEqualTo(registry.resolve(errorCode)); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B2:FORBIDDEN 保持业务码 2002 与 HTTP 403") |
|||
void forbiddenKeepsBusinessCodeAndStatus() { |
|||
ResponseEntity<ApiResponse<Void>> response = |
|||
handler.handleBusinessException(new BusinessException(ErrorCode.FORBIDDEN), request()); |
|||
|
|||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); |
|||
assertThat(response.getBody()).isNotNull(); |
|||
assertThat(response.getBody().getCode()).isEqualTo(2002); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B2:UNAUTHORIZED(2001) 保持既有语义(回退 400,不擅自改为 401)") |
|||
void unauthorizedKeepsLegacySemantics() { |
|||
ResponseEntity<ApiResponse<Void>> response = |
|||
handler.handleBusinessException(new BusinessException(ErrorCode.UNAUTHORIZED), request()); |
|||
|
|||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(response.getBody()).isNotNull(); |
|||
assertThat(response.getBody().getCode()).isEqualTo(2001); |
|||
assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B2:服务不可用类错误码保持当前状态(400)") |
|||
void serviceUnavailableKeepsCurrentStatus() { |
|||
assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B2:响应体不包含异常类名、堆栈或内部路径") |
|||
void responseBodyCarriesNoInternalDetail() { |
|||
for (ErrorCode code : new ErrorCode[]{ |
|||
ErrorCode.FORBIDDEN, ErrorCode.USER_NOT_FOUND, ErrorCode.APPLICATION_DUPLICATE, |
|||
ErrorCode.UNAUTHORIZED, ErrorCode.SERVICE_UNAVAILABLE}) { |
|||
MockHttpServletRequest request = request(); |
|||
request.setRequestURI("/admin/scoring/fairness/overview"); |
|||
ResponseEntity<ApiResponse<Void>> response = |
|||
handler.handleBusinessException(new BusinessException(code), request); |
|||
|
|||
assertThat(response.getBody()).isNotNull(); |
|||
String text = response.getBody().toString(); |
|||
assertThat(text) |
|||
.as("%s 响应体", code.name()) |
|||
.doesNotContain("Exception") |
|||
.doesNotContain("at com.mydreamjob") |
|||
.doesNotContain("GlobalExceptionHandler") |
|||
.doesNotContain("/admin/scoring/fairness/overview"); |
|||
assertThat(response.getBody().getRequestId()).isEqualTo("r01b-parity"); |
|||
} |
|||
} |
|||
|
|||
private static MockHttpServletRequest request() { |
|||
MockHttpServletRequest request = new MockHttpServletRequest(); |
|||
request.setRequestURI("/api/v1/parity"); |
|||
request.setAttribute("requestId", "r01b-parity"); |
|||
return request; |
|||
} |
|||
} |
|||
@ -0,0 +1,257 @@ |
|||
package com.mydreamjob.common.r01b; |
|||
|
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.common.exception.ErrorHttpStatusRegistry; |
|||
import com.mydreamjob.common.exception.GlobalExceptionHandler; |
|||
import com.mydreamjob.common.logging.RequestIdAccessor; |
|||
import org.junit.jupiter.api.DisplayName; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.http.HttpStatus; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.mock.web.MockHttpServletRequest; |
|||
|
|||
import java.io.BufferedReader; |
|||
import java.io.InputStream; |
|||
import java.io.InputStreamReader; |
|||
import java.lang.reflect.Method; |
|||
import java.nio.charset.StandardCharsets; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
/** |
|||
* R01-B 独立复验:HTTP 映射与 **HEAD 基线**逐项对账。 |
|||
* |
|||
* <p><strong>期望值来源</strong>:{@code src/test/resources/r01b/error-code-http-status-baseline.tsv}, |
|||
* 该文件由独立复验者从 {@code git show HEAD:…GlobalExceptionHandler.java} 的 |
|||
* {@code buildBusinessHttpStatuses()} 提取生成,<em>不</em>复用工作区实现的任何映射表, |
|||
* 因此不构成同源假证明。</p> |
|||
*/ |
|||
class R01BIndependentHttpMappingParityTest { |
|||
|
|||
private static final String BASELINE = "/r01b/error-code-http-status-baseline.tsv"; |
|||
|
|||
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); |
|||
private final GlobalExceptionHandler handler = |
|||
new GlobalExceptionHandler(new RequestIdAccessor(), registry); |
|||
|
|||
/** 从 HEAD 基线资源读取:ErrorCode 名称 → 数字码 → HEAD 状态。 */ |
|||
private static Map<String, int[]> baseline() { |
|||
// 用有序结构保存 name -> {code, statusOrdinal}
|
|||
Map<String, int[]> rows = new LinkedHashMap<>(); |
|||
try (InputStream in = R01BIndependentHttpMappingParityTest.class.getResourceAsStream(BASELINE)) { |
|||
assertThat(in).as("基线资源必须存在: %s", BASELINE).isNotNull(); |
|||
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { |
|||
String line; |
|||
while ((line = r.readLine()) != null) { |
|||
if (line.isBlank() || line.startsWith("#")) continue; |
|||
String[] parts = line.split("\t"); |
|||
rows.put(parts[0], new int[]{Integer.parseInt(parts[1]), statusOf(parts[2]).value()}); |
|||
} |
|||
} |
|||
} catch (Exception e) { |
|||
throw new IllegalStateException("failed to read baseline", e); |
|||
} |
|||
return rows; |
|||
} |
|||
|
|||
private static HttpStatus statusOf(String token) { |
|||
return switch (token) { |
|||
case "FORBIDDEN" -> HttpStatus.FORBIDDEN; |
|||
case "NOT_FOUND" -> HttpStatus.NOT_FOUND; |
|||
case "CONFLICT" -> HttpStatus.CONFLICT; |
|||
case "TOO_MANY_REQUESTS" -> HttpStatus.TOO_MANY_REQUESTS; |
|||
default -> HttpStatus.BAD_REQUEST; |
|||
}; |
|||
} |
|||
|
|||
// ── 166 项逐项对账 ────────────────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("独立复验:166 个枚举常量按名称逐项与 HEAD 基线对齐(不按数字去重)") |
|||
void everyEnumConstantMatchesHeadBaselineByName() { |
|||
Map<String, int[]> base = baseline(); |
|||
|
|||
// 166 条,且 9103 的两个常量各自成行 —— 不能按数字去重后只剩 165 条
|
|||
assertThat(base).hasSize(166); |
|||
assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); |
|||
assertThat(ErrorCode.values()).hasSize(166); |
|||
|
|||
int checked = 0; |
|||
for (ErrorCode code : ErrorCode.values()) { |
|||
int[] expected = base.get(code.name()); |
|||
assertThat(expected).as("基线必须覆盖 %s", code.name()).isNotNull(); |
|||
assertThat(code.getCode()).as("%s 的数字码不得改变", code.name()).isEqualTo(expected[0]); |
|||
assertThat(registry.resolve(code).value()) |
|||
.as("%s 的 HTTP 状态必须与 HEAD 一致", code.name()) |
|||
.isEqualTo(expected[1]); |
|||
checked++; |
|||
} |
|||
assertThat(checked).isEqualTo(166); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("独立复验:112 个显式映射 + 54 个默认 400") |
|||
void explicitAndDefaultCountsMatchBaseline() { |
|||
Map<String, int[]> base = baseline(); |
|||
long explicitInBaseline = base.values().stream().filter(v -> v[1] != 400).count(); |
|||
|
|||
assertThat(explicitInBaseline).isEqualTo(112); |
|||
assertThat(base.size() - explicitInBaseline).isEqualTo(54); |
|||
|
|||
assertThat(registry.explicitMappingCount()).isEqualTo(112); |
|||
assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54); |
|||
// 112 个常量仅覆盖 111 个唯一数字码(9103 共用)
|
|||
assertThat(registry.explicitNumericCodeCount()).isEqualTo(111); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("独立复验:关键既有语义逐项保持") |
|||
void keySemanticsPreserved() { |
|||
assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.resolve(ErrorCode.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN); |
|||
assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.resolve(ErrorCode.USER_NOT_FOUND)).isEqualTo(HttpStatus.NOT_FOUND); |
|||
assertThat(registry.resolve(ErrorCode.APPLICATION_DUPLICATE)).isEqualTo(HttpStatus.CONFLICT); |
|||
assertThat(registry.resolve(ErrorCode.ABILITY_RETAKE_LIMIT)).isEqualTo(HttpStatus.TOO_MANY_REQUESTS); |
|||
} |
|||
|
|||
// ── 9103 重复码 ───────────────────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("独立复验:9103 的两个枚举常量都显式注册,且分别解析为 409") |
|||
void bothSharedNumericCodeConstantsAreExplicitlyMappedTo409() { |
|||
assertThat(ErrorCode.REVIEW_ALREADY_DECIDED.getCode()).isEqualTo(9103); |
|||
assertThat(ErrorCode.DUPLICATE_FEEDBACK.getCode()).isEqualTo(9103); |
|||
|
|||
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.REVIEW_ALREADY_DECIDED)).isTrue(); |
|||
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.DUPLICATE_FEEDBACK)).isTrue(); |
|||
assertThat(registry.resolve(ErrorCode.REVIEW_ALREADY_DECIDED)).isEqualTo(HttpStatus.CONFLICT); |
|||
assertThat(registry.resolve(ErrorCode.DUPLICATE_FEEDBACK)).isEqualTo(HttpStatus.CONFLICT); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("独立复验:运行时以数字码解析,两个 9103 常量不可区分(如实记录,不夸大)") |
|||
void runtimeCannotDistinguishTheTwoSharedConstants() { |
|||
// BusinessException 只保存 int,枚举身份在运行时丢失
|
|||
BusinessException a = new BusinessException(ErrorCode.REVIEW_ALREADY_DECIDED); |
|||
BusinessException b = new BusinessException(ErrorCode.DUPLICATE_FEEDBACK); |
|||
assertThat(a.getErrorCode()).isEqualTo(b.getErrorCode()).isEqualTo(9103); |
|||
|
|||
// 数字入口对两者给出同一结果:当前两者同为 409,故 HTTP 行为一致
|
|||
assertThat(registry.resolve(9103)).isEqualTo(HttpStatus.CONFLICT); |
|||
assertThat(a.getErrorCode()).isEqualTo(b.getErrorCode()); |
|||
|
|||
// Handler 也只拿到数字码,响应中的 code 相同
|
|||
ResponseEntity<ApiResponse<Void>> ra = handler.handleBusinessException(a, request()); |
|||
ResponseEntity<ApiResponse<Void>> rb = handler.handleBusinessException(b, request()); |
|||
assertThat(ra.getStatusCode()).isEqualTo(rb.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); |
|||
assertThat(ra.getBody().getCode()).isEqualTo(rb.getBody().getCode()).isEqualTo(9103); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("独立复验:同数字码被映射到不同状态时初始化必须失败,不得静默任选") |
|||
void conflictingStatusForSharedNumericCodeFailsFast() throws Exception { |
|||
// 通过公开 Builder 构造"同数字、不同状态"的映射
|
|||
Map<ErrorCode, HttpStatus> conflicting = new ErrorHttpStatusRegistry.Builder() |
|||
.put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED) |
|||
.put(HttpStatus.NOT_FOUND, ErrorCode.DUPLICATE_FEEDBACK) |
|||
.build(); |
|||
assertThat(conflicting).hasSize(2); |
|||
|
|||
// 数字索引构建(真实注册表在静态初始化时走同一条路径)必须拒绝该冲突
|
|||
Method indexByCode = ErrorHttpStatusRegistry.class |
|||
.getDeclaredMethod("indexByCode", Map.class); |
|||
indexByCode.setAccessible(true); |
|||
|
|||
assertThatThrownBy(() -> { |
|||
try { |
|||
indexByCode.invoke(null, conflicting); |
|||
} catch (java.lang.reflect.InvocationTargetException e) { |
|||
throw e.getCause(); |
|||
} |
|||
}) |
|||
.isInstanceOf(IllegalStateException.class) |
|||
.hasMessageContaining("conflicting HTTP status mapping") |
|||
.hasMessageContaining("9103"); |
|||
|
|||
// 反证:状态相同时允许,且数字入口唯一
|
|||
Map<ErrorCode, HttpStatus> same = new ErrorHttpStatusRegistry.Builder() |
|||
.put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED) |
|||
.put(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_FEEDBACK) |
|||
.build(); |
|||
assertThat(same).hasSize(2); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("独立复验:重复注册同一枚举常量(即使状态相同)初始化即失败") |
|||
void duplicateEnumKeyFailsAtRegistration() { |
|||
assertThatThrownBy(() -> new ErrorHttpStatusRegistry.Builder() |
|||
.put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED) |
|||
.put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED)) |
|||
.isInstanceOf(IllegalStateException.class) |
|||
.hasMessageContaining("duplicate HTTP status mapping"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("独立复验:映射不依赖枚举声明顺序") |
|||
void mappingDoesNotDependOnEnumDeclarationOrder() { |
|||
// 取一个映射区段,正序与逆序注册结果必须相同
|
|||
List<ErrorCode> notFound = List.of( |
|||
ErrorCode.USER_NOT_FOUND, ErrorCode.JOB_NOT_FOUND, ErrorCode.COMPANY_NOT_FOUND); |
|||
|
|||
ErrorHttpStatusRegistry.Builder asc = new ErrorHttpStatusRegistry.Builder(); |
|||
for (ErrorCode c : notFound) asc.put(HttpStatus.NOT_FOUND, c); |
|||
ErrorHttpStatusRegistry.Builder desc = new ErrorHttpStatusRegistry.Builder(); |
|||
for (int i = notFound.size() - 1; i >= 0; i--) desc.put(HttpStatus.NOT_FOUND, notFound.get(i)); |
|||
|
|||
assertThat(asc.build()).isEqualTo(desc.build()); |
|||
|
|||
// 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明
|
|||
Map<String, int[]> base = baseline(); |
|||
for (ErrorCode c : ErrorCode.values()) { |
|||
assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("独立复验:未知数字码仍为 400,且处理器响应不含堆栈或内部类名") |
|||
void unknownCodeFallsBackTo400AndResponseIsClean() { |
|||
assertThat(registry.resolve(0)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.resolve(-1)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
assertThat(registry.resolve(123456789)).isEqualTo(HttpStatus.BAD_REQUEST); |
|||
|
|||
for (ErrorCode code : new ErrorCode[]{ |
|||
ErrorCode.FORBIDDEN, ErrorCode.USER_NOT_FOUND, ErrorCode.APPLICATION_DUPLICATE, |
|||
ErrorCode.UNAUTHORIZED, ErrorCode.SERVICE_UNAVAILABLE, ErrorCode.DUPLICATE_FEEDBACK}) { |
|||
MockHttpServletRequest request = request(); |
|||
request.setRequestURI("/r01b/independent/parity"); |
|||
ResponseEntity<ApiResponse<Void>> response = |
|||
handler.handleBusinessException(new BusinessException(code), request); |
|||
|
|||
assertThat(response.getBody()).isNotNull(); |
|||
String text = response.getBody().toString(); |
|||
assertThat(text) |
|||
.as("%s 响应体", code.name()) |
|||
.doesNotContain("Exception") |
|||
.doesNotContain("at com.mydreamjob") |
|||
.doesNotContain("GlobalExceptionHandler") |
|||
.doesNotContain("ErrorHttpStatusRegistry") |
|||
.doesNotContain("/r01b/independent/parity"); |
|||
assertThat(response.getBody().getRequestId()).isEqualTo("r01b-independent"); |
|||
} |
|||
} |
|||
|
|||
private static MockHttpServletRequest request() { |
|||
MockHttpServletRequest request = new MockHttpServletRequest(); |
|||
request.setRequestURI("/r01b/independent"); |
|||
request.setAttribute("requestId", "r01b-independent"); |
|||
return request; |
|||
} |
|||
} |
|||
@ -0,0 +1,299 @@ |
|||
package com.mydreamjob.common.r01b; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.auth.support.TestAuthTokens; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.security.UserRoleGuard; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.DisplayName; |
|||
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.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; |
|||
import org.springframework.security.core.authority.AuthorityUtils; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatCode; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.times; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.verifyNoMoreInteractions; |
|||
import static org.mockito.Mockito.when; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** |
|||
* R01-B 独立复验:角色授权语义、查询次数、边界与真实 HTTP 分层。 |
|||
* |
|||
* <p>夹具使用**独占用户 ID / 手机号**,避开 {@code V1_1} 种子账号占用的 1/2/3 |
|||
* (被 {@code V43} 置为 inactive,会使 Token 在 Filter 阶段即被拒)。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class R01BIndependentRoleVerificationTest { |
|||
|
|||
private static final long USER_ID = 7_710_001L; |
|||
private static final long STUDENT_ID = 9_330_001L; |
|||
private static final long COMPANY_ID = 9_330_002L; |
|||
private static final long ADMIN_ID = 9_330_003L; |
|||
|
|||
private static final String ADMIN_ENDPOINT = "/admin/scoring/risk/overview"; |
|||
private static final String STUDENT_ENDPOINT = "/profile/me/career-progress"; |
|||
private static final String COMPANY_ENDPOINT = "/company/candidates"; |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
// ── 单元部分 ──────────────────────────────────────────────
|
|||
|
|||
private UserRepository userRepository; |
|||
private UserRoleGuard guard; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
userRepository = mock(UserRepository.class); |
|||
guard = new UserRoleGuard(userRepository); |
|||
SecurityContextHolder.clearContext(); |
|||
|
|||
cleanupHttpFixtures(); |
|||
insertUserWithRoles(STUDENT_ID, "r01biv-student", "STUDENT", "STUDENT"); |
|||
insertUserWithRoles(COMPANY_ID, "r01biv-company", "COMPANY", "COMPANY"); |
|||
insertUserWithRoles(ADMIN_ID, "r01biv-admin", "ADMIN", "ADMIN"); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
SecurityContextHolder.clearContext(); |
|||
cleanupHttpFixtures(); |
|||
} |
|||
|
|||
private void cleanupHttpFixtures() { |
|||
for (long id : new long[]{STUDENT_ID, COMPANY_ID, ADMIN_ID}) { |
|||
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); |
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); |
|||
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); |
|||
} |
|||
} |
|||
|
|||
private void insertUserWithRoles(long id, String phone, String primaryRole, String... roles) { |
|||
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " |
|||
+ "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "r01biv-" + primaryRole, primaryRole); |
|||
for (String r : roles) { |
|||
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, r); |
|||
} |
|||
} |
|||
|
|||
private User user(User.Role primary, Set<User.Role> roles, Boolean active) { |
|||
return User.builder().id(USER_ID).phone("r01biv-unit").role(primary) |
|||
.roles(roles).active(active).build(); |
|||
} |
|||
|
|||
// ── 1. 正确角色通过 ───────────────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("独立复验:active 的 STUDENT / COMPANY / ADMIN 分别通过对应 guard") |
|||
void activeUsersPassTheirOwnGuard() { |
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true))); |
|||
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); |
|||
|
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.COMPANY, Set.of(User.Role.COMPANY), true))); |
|||
assertThatCode(() -> guard.requireCompany(USER_ID)).doesNotThrowAnyException(); |
|||
|
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.ADMIN, Set.of(User.Role.ADMIN), true))); |
|||
assertThatCode(() -> guard.requireAdmin(USER_ID)).doesNotThrowAnyException(); |
|||
} |
|||
|
|||
// ── 2~3. 交叉角色与 ADMIN 无超级权限 ──────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("独立复验:三类交叉角色全部拒绝,ADMIN 不自动通过 STUDENT/COMPANY") |
|||
void crossRolesAreAllRejected() { |
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true))); |
|||
assertForbidden(() -> guard.requireCompany(USER_ID)); |
|||
assertForbidden(() -> guard.requireAdmin(USER_ID)); |
|||
|
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.COMPANY, Set.of(User.Role.COMPANY), true))); |
|||
assertForbidden(() -> guard.requireStudent(USER_ID)); |
|||
assertForbidden(() -> guard.requireAdmin(USER_ID)); |
|||
|
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.ADMIN, Set.of(User.Role.ADMIN), true))); |
|||
assertForbidden(() -> guard.requireStudent(USER_ID)); |
|||
assertForbidden(() -> guard.requireCompany(USER_ID)); |
|||
} |
|||
|
|||
// ── 4~6. 不存在 / inactive / 角色为空 ─────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("独立复验:用户不存在 → USER_NOT_FOUND;不存在与 inactive 的错误码不同") |
|||
void missingUserThrowsUserNotFound() { |
|||
when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); |
|||
assertThatThrownBy(() -> guard.requireStudent(USER_ID)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.extracting(e -> ((BusinessException) e).getErrorCode()) |
|||
.isEqualTo(ErrorCode.USER_NOT_FOUND.getCode()); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("独立复验:inactive 用户即使角色正确也拒绝,错误码为 FORBIDDEN") |
|||
void inactiveUserIsForbiddenEvenWithCorrectRole() { |
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), false))); |
|||
|
|||
assertThatThrownBy(() -> guard.requireStudent(USER_ID)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.extracting(e -> ((BusinessException) e).getErrorCode()) |
|||
.isEqualTo(ErrorCode.FORBIDDEN.getCode()); |
|||
|
|||
// active 为 null 同样失败关闭
|
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), null))); |
|||
assertForbidden(() -> guard.requireStudent(USER_ID)); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("独立复验:user_roles 为空时所有 guard 一律拒绝") |
|||
void emptyRolesAreRejected() { |
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(), true))); |
|||
|
|||
assertForbidden(() -> guard.requireStudent(USER_ID)); |
|||
assertForbidden(() -> guard.requireCompany(USER_ID)); |
|||
assertForbidden(() -> guard.requireAdmin(USER_ID)); |
|||
} |
|||
|
|||
// ── 7~8. 多角色与主角色不一致 ─────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("独立复验:多角色用户按 user_roles 实际集合判定;users.role 不参与") |
|||
void multiRoleAndPrimaryRoleMismatch() { |
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.STUDENT, |
|||
Set.of(User.Role.STUDENT, User.Role.COMPANY), true))); |
|||
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); |
|||
assertThatCode(() -> guard.requireCompany(USER_ID)).doesNotThrowAnyException(); |
|||
assertForbidden(() -> guard.requireAdmin(USER_ID)); |
|||
|
|||
// users.role 声称 COMPANY,但 user_roles 只有 STUDENT → company guard 拒绝(失败关闭)
|
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.COMPANY, Set.of(User.Role.STUDENT), true))); |
|||
assertForbidden(() -> guard.requireCompany(USER_ID)); |
|||
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); |
|||
} |
|||
|
|||
// ── 9~12. 查询次数、无副作用、不越界 ──────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("独立复验:requireXxx(Long) 恰好一次查询;requireXxx(User) 零查询") |
|||
void queryCountIsExactlyOneOrZero() { |
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true))); |
|||
|
|||
guard.requireStudent(USER_ID); |
|||
verify(userRepository, times(1)).findById(USER_ID); |
|||
|
|||
User loaded = user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true); |
|||
guard.requireStudent(loaded); |
|||
guard.requireAny(loaded, User.Role.STUDENT, User.Role.COMPANY); |
|||
verify(userRepository, times(1)).findById(anyLong()); |
|||
verifyNoMoreInteractions(userRepository); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("独立复验:guard 无写库、不触碰 SecurityContext / requestId / Redis") |
|||
void guardHasNoSideEffectsAndNoOutOfScopeDependencies() { |
|||
// 结构断言:唯一依赖是 UserRepository(无 RequestIdAccessor / HttpServletRequest / Redis)
|
|||
assertThat(UserRoleGuard.class.getDeclaredFields()) |
|||
.extracting(f -> f.getType().getName()) |
|||
.containsExactly(UserRepository.class.getName()); |
|||
|
|||
// 行为断言:SecurityContext 里放一个"别的用户",guard 仍按传入 userId 判定
|
|||
SecurityContextHolder.getContext().setAuthentication( |
|||
new UsernamePasswordAuthenticationToken(String.valueOf(ADMIN_ID), null, |
|||
AuthorityUtils.createAuthorityList("ROLE_ADMIN"))); |
|||
|
|||
// 若 guard 读了 SecurityContext(其中是 ADMIN),requireStudent(USER_ID) 会因角色不符而抛错;
|
|||
// 实际不抛错 → 判定完全来自传入的 userId,与 SecurityContext 无关。
|
|||
when(userRepository.findById(USER_ID)) |
|||
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true))); |
|||
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); |
|||
|
|||
verify(userRepository, never()).save(any()); |
|||
verify(userRepository, never()).deleteById(anyLong()); |
|||
verify(userRepository, never()).delete(any()); |
|||
} |
|||
|
|||
// ── 13. 真实 HTTP 端点 401/403/404 ────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("独立复验:三个真实端点无 Token 一律 401") |
|||
void httpNoTokenIsUnauthorized() throws Exception { |
|||
for (String path : new String[]{ADMIN_ENDPOINT, STUDENT_ENDPOINT, COMPANY_ENDPOINT}) { |
|||
mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("独立复验:角色不符一律 403(含 ADMIN 访问 STUDENT/COMPANY 端点)") |
|||
void httpWrongRoleIsForbidden() throws Exception { |
|||
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT"))) |
|||
.andExpect(status().isForbidden()); |
|||
mockMvc.perform(get(STUDENT_ENDPOINT).header("Authorization", TestAuthTokens.bearer(COMPANY_ID, "COMPANY"))) |
|||
.andExpect(status().isForbidden()); |
|||
mockMvc.perform(get(COMPANY_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) |
|||
.andExpect(status().isForbidden()); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("独立复验:角色正确时放行;已授权的 ADMIN 访问不存在资源得 404 而非 403") |
|||
void httpCorrectRolePassesAnd404IsPreserved() throws Exception { |
|||
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) |
|||
.andExpect(status().isOk()); |
|||
|
|||
int studentStatus = mockMvc.perform(get(STUDENT_ENDPOINT) |
|||
.header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT"))) |
|||
.andReturn().getResponse().getStatus(); |
|||
assertThat(studentStatus).isNotIn(401, 403); |
|||
|
|||
// 404 不被角色层改写成 403
|
|||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders |
|||
.post("/admin/scoring/risk/events/{eventId}/actions", 999_999_999L) |
|||
.header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN")) |
|||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON) |
|||
.content("{\"action\":\"ACKNOWLEDGE\",\"reasonCode\":\"MONITORING\"," |
|||
+ "\"expectedVersion\":0}")) |
|||
.andExpect(status().isNotFound()); |
|||
} |
|||
|
|||
private static void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable callable) { |
|||
assertThatThrownBy(callable) |
|||
.isInstanceOf(BusinessException.class) |
|||
.extracting(e -> ((BusinessException) e).getErrorCode()) |
|||
.isEqualTo(ErrorCode.FORBIDDEN.getCode()); |
|||
} |
|||
} |
|||
@ -0,0 +1,146 @@ |
|||
package com.mydreamjob.common.r01b; |
|||
|
|||
import com.mydreamjob.auth.support.TestAuthTokens; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.risk.RiskGovernanceService; |
|||
import com.mydreamjob.security.UserRoleGuard; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.DisplayName; |
|||
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.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.Mockito.doThrow; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** |
|||
* R01-B 独立复验:**接线变异检测**。 |
|||
* |
|||
* <p>用 {@code @MockBean} 替换公共 guard,使"角色决策"完全由测试掌控。 |
|||
* 由于端点自身的角色判定已被收束到该 guard,两个方向都可被观测:</p> |
|||
* <ul> |
|||
* <li>guard 拒绝 → 端点 403 且**下游业务方法零调用**;</li> |
|||
* <li>guard 放行 → 即使调用方是 STUDENT,ADMIN 端点也返回 200。</li> |
|||
* </ul> |
|||
* <p>第二条是关键的变异证明:若端点仍保留自有的角色比较(未委托 guard), |
|||
* STUDENT 无论 guard 怎么桩都不可能通过 ADMIN 端点,本测试必然失败。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class R01BWiringMutationTest { |
|||
|
|||
private static final long STUDENT_ID = 9_340_001L; |
|||
private static final long ADMIN_ID = 9_340_002L; |
|||
|
|||
private static final String ADMIN_ENDPOINT = "/admin/scoring/risk/overview"; |
|||
private static final String COMPANY_CANDIDATE_ENDPOINT = "/company/candidates/999999999"; |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
|
|||
@MockBean private UserRoleGuard userRoleGuard; |
|||
@MockBean private RiskGovernanceService riskGovernanceService; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@BeforeEach |
|||
void provision() { |
|||
cleanup(); |
|||
insertUser(STUDENT_ID, "r01bwm-student", "STUDENT"); |
|||
insertUser(ADMIN_ID, "r01bwm-admin", "ADMIN"); |
|||
} |
|||
|
|||
@AfterEach |
|||
void cleanup() { |
|||
for (long id : new long[]{STUDENT_ID, ADMIN_ID}) { |
|||
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); |
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); |
|||
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); |
|||
} |
|||
} |
|||
|
|||
private void insertUser(long id, String phone, String role) { |
|||
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " |
|||
+ "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "r01bwm-" + role, role); |
|||
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, role); |
|||
} |
|||
|
|||
// ── guard 拒绝 → 业务不继续 ───────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("变异检测:guard 抛 FORBIDDEN 时端点 403,且下游业务方法零调用") |
|||
void guardDenyStopsBusinessFlow() throws Exception { |
|||
doThrow(new BusinessException(ErrorCode.FORBIDDEN)) |
|||
.when(userRoleGuard).requireAdmin(anyLong()); |
|||
|
|||
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) |
|||
.andExpect(status().isForbidden()); |
|||
|
|||
verify(riskGovernanceService, never()).overview(any(), any()); |
|||
verify(userRoleGuard).requireAdmin(ADMIN_ID); |
|||
} |
|||
|
|||
// ── guard 放行 → 业务继续;且证明端点不自带角色比较 ────────
|
|||
|
|||
@Test |
|||
@DisplayName("变异检测:guard 放行时业务继续;STUDENT 亦可通过 ADMIN 端点(证明判定只来自 guard)") |
|||
void guardAllowLetsFlowContinueEvenForStudent() throws Exception { |
|||
// guard 为 mock,void 方法默认放行
|
|||
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT"))) |
|||
.andExpect(status().isOk()); |
|||
|
|||
verify(riskGovernanceService).overview(any(), any()); |
|||
// 端点把**真实调用方身份**交给 guard;若端点自带角色比较,STUDENT 不可能走到这里
|
|||
verify(userRoleGuard).requireAdmin(STUDENT_ID); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("变异检测:guard 收到的 userId 与 Token 主体一致(无身份替换)") |
|||
void guardReceivesCallerIdentity() throws Exception { |
|||
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) |
|||
.andExpect(status().isOk()); |
|||
verify(userRoleGuard).requireAdmin(ADMIN_ID); |
|||
verify(userRoleGuard, never()).requireAdmin(STUDENT_ID); |
|||
} |
|||
|
|||
// ── 资源归属仍由业务层负责,且错误码未被角色层吞掉 ─────────
|
|||
|
|||
@Test |
|||
@DisplayName("变异检测:guard 放行后,归属/存在性失败仍由业务层返回原错误码(404 而非 403)") |
|||
void ownershipFailureStillComesFromBusinessLayer() throws Exception { |
|||
// 该 STUDENT 用户没有企业实体;guard(mock)放行后由业务层给出 404/COMPANY_NOT_FOUND 语义
|
|||
mockMvc.perform(get(COMPANY_CANDIDATE_ENDPOINT) |
|||
.header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT"))) |
|||
.andExpect(status().isNotFound()); |
|||
|
|||
// 该端点路径上 Company guard 会被调用多次(Controller 直调 + 下游 Service 委托),
|
|||
// 故只断言"至少一次且身份正确",不臆造次数。
|
|||
verify(userRoleGuard, org.mockito.Mockito.atLeastOnce()).requireCompany(STUDENT_ID); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("变异检测:guard 在 COMPANY 端点上同样按调用方身份被调用") |
|||
void companyEndpointAlsoDelegatesToGuard() throws Exception { |
|||
doThrow(new BusinessException(ErrorCode.FORBIDDEN)) |
|||
.when(userRoleGuard).requireCompany(anyLong()); |
|||
|
|||
mockMvc.perform(get(COMPANY_CANDIDATE_ENDPOINT) |
|||
.header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))) |
|||
.andExpect(status().isForbidden()); |
|||
|
|||
verify(userRoleGuard).requireCompany(ADMIN_ID); |
|||
} |
|||
} |
|||
@ -0,0 +1,254 @@ |
|||
package com.mydreamjob.common.r01b; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.common.exception.ErrorHttpStatusRegistry; |
|||
import com.mydreamjob.security.UserRoleGuard; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.DisplayName; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.http.HttpStatus; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
|
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatCode; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* R01-B/B1:公共角色授权守卫的行为契约。 |
|||
* |
|||
* <p>角色来源为 {@code user_roles}({@code user.getRoles()}),与迁移前的 |
|||
* 全部重复 guard 完全一致;{@code users.role} 单值列不参与此处判定。</p> |
|||
*/ |
|||
class RoleAuthorizationGuardTest { |
|||
|
|||
private static final long USER_ID = 7_700_001L; |
|||
|
|||
private UserRepository userRepository; |
|||
private UserRoleGuard guard; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
userRepository = mock(UserRepository.class); |
|||
guard = new UserRoleGuard(userRepository); |
|||
SecurityContextHolder.clearContext(); |
|||
} |
|||
|
|||
private void givenUser(User user) { |
|||
when(userRepository.findById(USER_ID)).thenReturn(Optional.ofNullable(user)); |
|||
} |
|||
|
|||
private static User user(User.Role primaryRole, Set<User.Role> roles, boolean active) { |
|||
return User.builder() |
|||
.id(USER_ID) |
|||
.phone("r01b-fixture") |
|||
.role(primaryRole) |
|||
.roles(roles) |
|||
.active(active) |
|||
.build(); |
|||
} |
|||
|
|||
// ── 1~3. 正确角色通过 ─────────────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1:STUDENT 通过 student guard") |
|||
void studentPassesStudentGuard() { |
|||
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)); |
|||
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B1:COMPANY 通过 company guard") |
|||
void companyPassesCompanyGuard() { |
|||
givenUser(user(User.Role.COMPANY, Set.of(User.Role.COMPANY), true)); |
|||
assertThatCode(() -> guard.requireCompany(USER_ID)).doesNotThrowAnyException(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B1:ADMIN 通过 admin guard") |
|||
void adminPassesAdminGuard() { |
|||
givenUser(user(User.Role.ADMIN, Set.of(User.Role.ADMIN), true)); |
|||
assertThatCode(() -> guard.requireAdmin(USER_ID)).doesNotThrowAnyException(); |
|||
} |
|||
|
|||
// ── 4~5. 跨角色一律拒绝 ───────────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1:STUDENT 不能通过 company / admin guard") |
|||
void studentFailsCompanyAndAdminGuards() { |
|||
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)); |
|||
|
|||
assertForbidden(() -> guard.requireCompany(USER_ID)); |
|||
assertForbidden(() -> guard.requireAdmin(USER_ID)); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B1:COMPANY 不能通过 student / admin guard") |
|||
void companyFailsStudentAndAdminGuards() { |
|||
givenUser(user(User.Role.COMPANY, Set.of(User.Role.COMPANY), true)); |
|||
|
|||
assertForbidden(() -> guard.requireStudent(USER_ID)); |
|||
assertForbidden(() -> guard.requireAdmin(USER_ID)); |
|||
} |
|||
|
|||
// ── 6. ADMIN 无隐式超级权限 ───────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1:ADMIN 不得自动通过 student / company guard") |
|||
void adminHasNoImplicitSuperUserPower() { |
|||
givenUser(user(User.Role.ADMIN, Set.of(User.Role.ADMIN), true)); |
|||
|
|||
assertForbidden(() -> guard.requireStudent(USER_ID)); |
|||
assertForbidden(() -> guard.requireCompany(USER_ID)); |
|||
// 只有显式声明允许 ADMIN 时才通过
|
|||
assertThatCode(() -> guard.requireAny(USER_ID, User.Role.STUDENT, User.Role.ADMIN)) |
|||
.doesNotThrowAnyException(); |
|||
} |
|||
|
|||
// ── 7. inactive 用户失败关闭 ──────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1:inactive 用户即使角色正确也不得通过") |
|||
void inactiveUserIsRejectedEvenWithCorrectRole() { |
|||
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), false)); |
|||
|
|||
assertForbidden(() -> guard.requireStudent(USER_ID)); |
|||
assertForbidden(() -> guard.requireAny(USER_ID, User.Role.STUDENT)); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B1:active 为 null(未显式置位)同样失败关闭") |
|||
void nullActiveIsRejected() { |
|||
User u = User.builder().id(USER_ID).phone("r01b-fixture") |
|||
.role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(null).build(); |
|||
givenUser(u); |
|||
|
|||
assertForbidden(() -> guard.requireStudent(USER_ID)); |
|||
} |
|||
|
|||
// ── 8. 角色缺失 ───────────────────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1:user_roles 为空时所有 guard 一律拒绝") |
|||
void missingRolesAreRejected() { |
|||
givenUser(user(User.Role.STUDENT, Set.of(), true)); |
|||
|
|||
assertForbidden(() -> guard.requireStudent(USER_ID)); |
|||
assertForbidden(() -> guard.requireCompany(USER_ID)); |
|||
assertForbidden(() -> guard.requireAdmin(USER_ID)); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B1:用户不存在返回既有 USER_NOT_FOUND 契约") |
|||
void unknownUserKeepsUserNotFoundContract() { |
|||
when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); |
|||
|
|||
assertThatThrownBy(() -> guard.requireStudent(USER_ID)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.extracting(e -> ((BusinessException) e).getErrorCode()) |
|||
.isEqualTo(ErrorCode.USER_NOT_FOUND.getCode()); |
|||
} |
|||
|
|||
// ── 9. 多角色用户 ─────────────────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1:多角色用户按其实际角色集合通过对应 guard") |
|||
void multiRoleUserPassesEachHeldRole() { |
|||
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT, User.Role.COMPANY), true)); |
|||
|
|||
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); |
|||
assertThatCode(() -> guard.requireCompany(USER_ID)).doesNotThrowAnyException(); |
|||
assertForbidden(() -> guard.requireAdmin(USER_ID)); |
|||
assertThatCode(() -> guard.requireAny(USER_ID, User.Role.ADMIN, User.Role.COMPANY)) |
|||
.doesNotThrowAnyException(); |
|||
} |
|||
|
|||
// ── 10. users.role 与 user_roles 不一致时失败关闭 ─────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1:users.role 声称 COMPANY 但 user_roles 只有 STUDENT 时,company guard 拒绝") |
|||
void primaryRoleDoesNotOverrideUserRoles() { |
|||
givenUser(user(User.Role.COMPANY, Set.of(User.Role.STUDENT), true)); |
|||
|
|||
assertForbidden(() -> guard.requireCompany(USER_ID)); |
|||
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B1:users.role 声称 STUDENT 但 user_roles 为空时仍然拒绝(失败关闭)") |
|||
void inconsistentPrimaryRoleFailsClosed() { |
|||
givenUser(user(User.Role.STUDENT, Set.of(), true)); |
|||
|
|||
assertForbidden(() -> guard.requireStudent(USER_ID)); |
|||
} |
|||
|
|||
// ── 11. 错误码与 HTTP 状态保持原契约 ──────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1:错误码与 HTTP 状态沿用既有契约(角色不符 2002/403,用户不存在 4001/404)") |
|||
void failureCodesKeepOriginalHttpContract() { |
|||
ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); |
|||
|
|||
assertThat(registry.resolve(ErrorCode.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN); |
|||
assertThat(registry.resolve(ErrorCode.USER_NOT_FOUND)).isEqualTo(HttpStatus.NOT_FOUND); |
|||
|
|||
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)); |
|||
assertThatThrownBy(() -> guard.requireCompany(USER_ID)) |
|||
.extracting(e -> ((BusinessException) e).getErrorCode()) |
|||
.isEqualTo(ErrorCode.FORBIDDEN.getCode()); |
|||
|
|||
when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); |
|||
assertThatThrownBy(() -> guard.requireCompany(USER_ID)) |
|||
.extracting(e -> ((BusinessException) e).getErrorCode()) |
|||
.isEqualTo(ErrorCode.USER_NOT_FOUND.getCode()); |
|||
} |
|||
|
|||
// ── 12~13. 不越界:不解析 requestId、不解析 Authentication ─
|
|||
|
|||
@Test |
|||
@DisplayName("B1:guard 不解析 requestId,也不读取 SecurityContext") |
|||
void guardDoesNotTouchRequestContextOrAuthentication() { |
|||
// 结构断言:依赖只有 UserRepository,不含 RequestIdAccessor / HttpServletRequest
|
|||
assertThat(UserRoleGuard.class.getDeclaredFields()) |
|||
.extracting(f -> f.getType().getName()) |
|||
.containsExactly(UserRepository.class.getName()); |
|||
|
|||
// 行为断言:SecurityContext 为空时,凭 userId 仍能正常授权
|
|||
SecurityContextHolder.clearContext(); |
|||
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)); |
|||
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException(); |
|||
verify(userRepository).findById(USER_ID); |
|||
} |
|||
|
|||
// ── 查询次数 ──────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1:按 userId 校验恰好一次查询;按实体重载零查询") |
|||
void queryCountIsBounded() { |
|||
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)); |
|||
|
|||
guard.requireStudent(USER_ID); |
|||
verify(userRepository, org.mockito.Mockito.times(1)).findById(USER_ID); |
|||
|
|||
User loaded = user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true); |
|||
guard.requireStudent(loaded); |
|||
guard.requireAny(loaded, User.Role.STUDENT, User.Role.COMPANY); |
|||
// 实体重载不得新增查询
|
|||
verify(userRepository, org.mockito.Mockito.times(1)).findById(USER_ID); |
|||
} |
|||
|
|||
private static void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable callable) { |
|||
assertThatThrownBy(callable) |
|||
.isInstanceOf(BusinessException.class) |
|||
.extracting(e -> ((BusinessException) e).getErrorCode()) |
|||
.isEqualTo(ErrorCode.FORBIDDEN.getCode()); |
|||
} |
|||
} |
|||
@ -0,0 +1,207 @@ |
|||
package com.mydreamjob.common.r01b; |
|||
|
|||
import com.mydreamjob.auth.support.TestAuthTokens; |
|||
import com.mydreamjob.security.UserRoleGuard; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.DisplayName; |
|||
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.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.boot.test.mock.mockito.SpyBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import static org.mockito.Mockito.atLeastOnce; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** |
|||
* R01-B/B1:公共角色守卫在**真实 SecurityFilterChain + 真实 HTTP 端点**下的边界复验。 |
|||
* |
|||
* <p>三个角色各取一个真实端点:</p> |
|||
* <ul> |
|||
* <li>ADMIN → {@code GET /admin/scoring/risk/overview}</li> |
|||
* <li>STUDENT → {@code GET /profile/me/career-progress}</li> |
|||
* <li>COMPANY → {@code GET /company/candidates}</li> |
|||
* </ul> |
|||
* |
|||
* <p>夹具使用**独占用户 ID 与独占手机号**,不复用 V1_1 种子账号占用的 1/2/3 |
|||
* (那三行已被 V43 置为 inactive,会令 Token 认证在进入业务层前就 401)。</p> |
|||
* |
|||
* <p>{@link UserRoleGuard} 使用 {@code @SpyBean}:既保留真实授权行为, |
|||
* 又能证明端点确实**经由公共守卫**授权(接线变异检测)。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class RoleAuthorizationIntegrationTest { |
|||
|
|||
private static final long STUDENT_ID = 9_320_001L; |
|||
private static final long COMPANY_ID = 9_320_002L; |
|||
private static final long ADMIN_ID = 9_320_003L; |
|||
|
|||
private static final String ADMIN_ENDPOINT = "/admin/scoring/risk/overview"; |
|||
private static final String STUDENT_ENDPOINT = "/profile/me/career-progress"; |
|||
private static final String COMPANY_ENDPOINT = "/company/candidates"; |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
|
|||
@SpyBean private UserRoleGuard userRoleGuard; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@BeforeEach |
|||
void provisionFixtureUsers() { |
|||
cleanupFixtureUsers(); |
|||
insertUser(STUDENT_ID, "r01b-student", "STUDENT"); |
|||
insertUser(COMPANY_ID, "r01b-company", "COMPANY"); |
|||
insertUser(ADMIN_ID, "r01b-admin", "ADMIN"); |
|||
} |
|||
|
|||
@AfterEach |
|||
void cleanupFixtureUsers() { |
|||
for (long id : new long[]{STUDENT_ID, COMPANY_ID, ADMIN_ID}) { |
|||
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id); |
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id); |
|||
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id); |
|||
} |
|||
} |
|||
|
|||
private void insertUser(long id, String phone, String role) { |
|||
// 显式插入 active 用户 + user_roles:guard 的角色来源是 user_roles。
|
|||
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) " |
|||
+ "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "r01b-fixture-" + role, role); |
|||
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, role); |
|||
} |
|||
|
|||
private static String token(long id, String role) { |
|||
return TestAuthTokens.bearer(id, role); |
|||
} |
|||
|
|||
// ── 401:未认证 ───────────────────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1 真实链路:三个端点无 Token 一律 401") |
|||
void noTokenIsUnauthorizedOnAllThreeEndpoints() throws Exception { |
|||
for (String path : new String[]{ADMIN_ENDPOINT, STUDENT_ENDPOINT, COMPANY_ENDPOINT}) { |
|||
mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); |
|||
} |
|||
} |
|||
|
|||
// ── 403:角色不符 ─────────────────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1 真实链路:STUDENT 访问 ADMIN / COMPANY 端点得 403") |
|||
void studentIsForbiddenOnAdminAndCompanyEndpoints() throws Exception { |
|||
String token = token(STUDENT_ID, "STUDENT"); |
|||
|
|||
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", token)) |
|||
.andExpect(status().isForbidden()); |
|||
mockMvc.perform(get(COMPANY_ENDPOINT).header("Authorization", token)) |
|||
.andExpect(status().isForbidden()); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B1 真实链路:COMPANY 访问 ADMIN / STUDENT 端点得 403") |
|||
void companyIsForbiddenOnAdminAndStudentEndpoints() throws Exception { |
|||
String token = token(COMPANY_ID, "COMPANY"); |
|||
|
|||
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", token)) |
|||
.andExpect(status().isForbidden()); |
|||
mockMvc.perform(get(STUDENT_ENDPOINT).header("Authorization", token)) |
|||
.andExpect(status().isForbidden()); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B1 真实链路:ADMIN 不因是 ADMIN 而通过 STUDENT / COMPANY 端点") |
|||
void adminIsForbiddenOnStudentAndCompanyEndpoints() throws Exception { |
|||
String token = token(ADMIN_ID, "ADMIN"); |
|||
|
|||
mockMvc.perform(get(STUDENT_ENDPOINT).header("Authorization", token)) |
|||
.andExpect(status().isForbidden()); |
|||
mockMvc.perform(get(COMPANY_ENDPOINT).header("Authorization", token)) |
|||
.andExpect(status().isForbidden()); |
|||
} |
|||
|
|||
// ── 2xx / 非 403:角色正确 ────────────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1 真实链路:ADMIN 访问 ADMIN 端点得 200") |
|||
void adminReachesAdminEndpoint() throws Exception { |
|||
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", token(ADMIN_ID, "ADMIN"))) |
|||
.andExpect(status().isOk()); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B1 真实链路:STUDENT 访问 STUDENT 端点不被角色层拒绝") |
|||
void studentReachesStudentEndpoint() throws Exception { |
|||
int status = mockMvc.perform(get(STUDENT_ENDPOINT) |
|||
.header("Authorization", token(STUDENT_ID, "STUDENT"))) |
|||
.andReturn().getResponse().getStatus(); |
|||
|
|||
org.assertj.core.api.Assertions.assertThat(status) |
|||
.as("角色正确时不得是 401/403") |
|||
.isNotIn(401, 403); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("B1 真实链路:COMPANY 访问 COMPANY 端点不被角色层拒绝") |
|||
void companyReachesCompanyEndpoint() throws Exception { |
|||
int status = mockMvc.perform(get(COMPANY_ENDPOINT) |
|||
.header("Authorization", token(COMPANY_ID, "COMPANY"))) |
|||
.andReturn().getResponse().getStatus(); |
|||
|
|||
org.assertj.core.api.Assertions.assertThat(status) |
|||
.as("角色正确时不得是 401/403") |
|||
.isNotIn(401, 403); |
|||
} |
|||
|
|||
// ── 404:资源不存在仍为 404(不被角色层改写成 403)────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1 真实链路:已授权的 ADMIN 访问不存在的风险事件得 404 而非 403") |
|||
void authorizedAdminGetsNotFoundNotForbidden() throws Exception { |
|||
// 该端点对不存在的风险事件返回 404(RISK_EVENT_NOT_FOUND),既有测试已锁定。
|
|||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders |
|||
.post("/admin/scoring/risk/events/{eventId}/actions", 999_999_999L) |
|||
.header("Authorization", token(ADMIN_ID, "ADMIN")) |
|||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON) |
|||
.content("{\"action\":\"ACKNOWLEDGE\",\"reasonCode\":\"MONITORING\"," |
|||
+ "\"expectedVersion\":0}")) |
|||
.andExpect(status().isNotFound()); |
|||
} |
|||
|
|||
// ── 接线变异:端点确实经由公共守卫 ────────────────────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1 接线检测:三个端点均实际调用公共 UserRoleGuard") |
|||
void allThreeEndpointsDelegateToSharedGuard() throws Exception { |
|||
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", token(ADMIN_ID, "ADMIN"))) |
|||
.andExpect(status().isOk()); |
|||
verify(userRoleGuard, atLeastOnce()).requireAdmin(ADMIN_ID); |
|||
|
|||
mockMvc.perform(get(STUDENT_ENDPOINT).header("Authorization", token(STUDENT_ID, "STUDENT"))); |
|||
verify(userRoleGuard, atLeastOnce()).requireStudent(STUDENT_ID); |
|||
|
|||
mockMvc.perform(get(COMPANY_ENDPOINT).header("Authorization", token(COMPANY_ID, "COMPANY"))); |
|||
verify(userRoleGuard, atLeastOnce()).requireCompany(COMPANY_ID); |
|||
} |
|||
|
|||
// ── 资源归属仍由原 Service 负责(未被 guard 吞掉)─────────
|
|||
|
|||
@Test |
|||
@DisplayName("B1 边界:COMPANY 通过角色层后,资源归属仍由业务层判定(非 403 而是 404/400)") |
|||
void ownershipRemainsWithBusinessService() throws Exception { |
|||
// 该 COMPANY 用户没有任何企业实体:角色层放行后,业务层的归属/存在性检查给出 404,
|
|||
// 证明 guard 没有替代、也没有跳过资源归属校验。
|
|||
mockMvc.perform(get("/company/candidates/{applicationId}", 999_999_999L) |
|||
.header("Authorization", token(COMPANY_ID, "COMPANY"))) |
|||
.andExpect(status().isNotFound()); |
|||
} |
|||
} |
|||
@ -0,0 +1,69 @@ |
|||
package com.mydreamjob.common.r01b; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.security.UserRoleGuard; |
|||
|
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.Mockito.lenient; |
|||
import static org.mockito.Mockito.doAnswer; |
|||
|
|||
/** |
|||
* R01-B/B1 测试支持:让被 mock 的 {@link UserRoleGuard} 具备**真实授权行为**。 |
|||
* |
|||
* <p>服务单元测试用 {@code @InjectMocks} 组装被测服务,新加入的 |
|||
* {@link UserRoleGuard} 依赖在测试里只能以 {@code @Mock} 形式提供(否则该构造参数为 null)。 |
|||
* 若直接使用"什么都不做"的裸 mock,所有"角色不符应抛 403"的既有断言都会失效 —— |
|||
* 那等于削弱断言。本支持类把 mock 的四个方法**逐字委托给真实 guard**, |
|||
* 并复用测试自己已经 stub 的 {@link UserRepository}, |
|||
* 使被测语义与收束前**完全一致**。</p> |
|||
* |
|||
* <p>使用 {@code lenient()}:单个用例可能只触发其中一种角色检查, |
|||
* 未被使用的委托桩不应被判为多余桩。</p> |
|||
*/ |
|||
public final class UserRoleGuardTestSupport { |
|||
|
|||
private UserRoleGuardTestSupport() { |
|||
} |
|||
|
|||
/** 把 {@code mockGuard} 的授权方法委托给基于 {@code userRepository} 的真实 guard。 */ |
|||
public static void delegateToRealGuard(UserRoleGuard mockGuard, UserRepository userRepository) { |
|||
UserRoleGuard real = new UserRoleGuard(userRepository); |
|||
|
|||
lenient().doAnswer(inv -> { |
|||
real.requireStudent((Long) inv.getArgument(0)); |
|||
return null; |
|||
}).when(mockGuard).requireStudent(anyLong()); |
|||
|
|||
lenient().doAnswer(inv -> { |
|||
real.requireCompany((Long) inv.getArgument(0)); |
|||
return null; |
|||
}).when(mockGuard).requireCompany(anyLong()); |
|||
|
|||
lenient().doAnswer(inv -> { |
|||
real.requireAdmin((Long) inv.getArgument(0)); |
|||
return null; |
|||
}).when(mockGuard).requireAdmin(anyLong()); |
|||
|
|||
lenient().doAnswer(inv -> { |
|||
real.requireAny((Long) inv.getArgument(0), (User.Role[]) inv.getArgument(1)); |
|||
return null; |
|||
}).when(mockGuard).requireAny(anyLong(), any(User.Role[].class)); |
|||
|
|||
lenient().doAnswer(inv -> { |
|||
real.requireStudent((User) inv.getArgument(0)); |
|||
return null; |
|||
}).when(mockGuard).requireStudent(any(User.class)); |
|||
|
|||
lenient().doAnswer(inv -> { |
|||
real.requireCompany((User) inv.getArgument(0)); |
|||
return null; |
|||
}).when(mockGuard).requireCompany(any(User.class)); |
|||
|
|||
lenient().doAnswer(inv -> { |
|||
real.requireAdmin((User) inv.getArgument(0)); |
|||
return null; |
|||
}).when(mockGuard).requireAdmin(any(User.class)); |
|||
} |
|||
} |
|||
|
Can't render this file because it has a wrong number of fields in line 4.
|
Loading…
Reference in new issue