15 changed files with 1050 additions and 199 deletions
@ -0,0 +1,279 @@ |
|||||
|
# R01-A5 独立代码复验报告(第五批:8 个 Controller 请求身份与 requestId 收束) |
||||
|
|
||||
|
日期:2026-09-17 |
||||
|
分支:`refactor/r01-structure-performance` |
||||
|
HEAD:`0226977063243fce101e3f6c8dcf66492d306ef7`(A4 后最新提交,复验全程未变) |
||||
|
复验对象:`docs/r01-a5-request-context-migration-report.md` |
||||
|
性质:**仅复验** —— 未修改生产代码、未修改既有测试断言、未暂存、未提交、未推送 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 0. 结论 |
||||
|
|
||||
|
> **R01-A5独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** |
||||
|
|
||||
|
**FAIL = 0;SKIP = 0。未发现生产缺陷。** |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 开始前状态 |
||||
|
|
||||
|
| 检查项 | 结果 | |
||||
|
|---|---| |
||||
|
| 分支 | `refactor/r01-structure-performance` ✔ | |
||||
|
| HEAD | `0226977063243fce101e3f6c8dcf66492d306ef7`(A4 后最新)✔ | |
||||
|
| 与上游同步 | `0 / 0` ✔ | |
||||
|
| 暂存区 | 空 ✔ | |
||||
|
| `git diff --check` | 无空白错误 ✔ | |
||||
|
| 工作区改动 | **仅 8 个 A5 Controller + 路线图**,无 A5 之外的生产代码改动 ✔ | |
||||
|
| 历史未跟踪文件 | 全部保留 ✔ | |
||||
|
| reset/restore/checkout/clean、`git add -A`、Docker | 均未执行 ✔ | |
||||
|
|
||||
|
### `GlobalExceptionHandler.java` 三方 blob 比对 |
||||
|
|
||||
|
| 来源 | blob | |
||||
|
|---|---| |
||||
|
| worktree(`git hash-object`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` | |
||||
|
| index(`git ls-files -s`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` | |
||||
|
| HEAD(`git rev-parse HEAD:<path>`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` | |
||||
|
|
||||
|
**三者完全一致 → 纯 Windows stat 缓存现象,未触碰该文件** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. 8 个 Controller 静态审查 |
||||
|
|
||||
|
| Controller | 代表端点 | Accessor 调用(auth / reqId) | |
||||
|
|---|---|---| |
||||
|
| CandidateScoringGovernanceController | `GET /profile/me/candidate-scoring/appeals` | 9 / 9 | |
||||
|
| InterviewScoreController | `GET /interviews/{id}/score` | 4 / 4 | |
||||
|
| FavoriteController | `GET /jobs/favorites` | 3 / 3 | |
||||
|
| AdminReviewController | `GET /admin/reviews/pending` | 3 / 3 | |
||||
|
| AdminReviewReplyController | `GET /admin/review-replies/pending` | 2 / 2 | |
||||
|
| CompanyReviewManagementController | `GET /companies/me/job-reviews` | 4 / 4 | |
||||
|
| JobReviewController | `DELETE /jobs/{jobId}/reviews/{reviewId}` | 3 / 7 | |
||||
|
| StudentReviewWeightController | `GET /profile/me/review-weights` | 3 / 4 | |
||||
|
|
||||
|
(`JobReviewController` / `StudentReviewWeightController` 的 reqId 调用数高于 auth 属正常 —— 部分只读端点只需 requestId。) |
||||
|
|
||||
|
**改动性质**:`8 files changed, 101 insertions(+), 198 deletions(-)`,删除的 `ErrorCode.UNAUTHORIZED` 共 10 处,**全部来自被移除的私有解析方法**;**角色检查(`assertAdminRole`/`assertCompanyOwner`/`assertStudentRole`)、资源归属、`FORBIDDEN`/`NOT_FOUND` 及 Service 调用顺序未删未增、未移动** ✔ |
||||
|
|
||||
|
**`JobReviewController.parseIds` 完整保留** ✔ —— 它解析 `ids` 查询参数(业务参数,非认证主体),并以 `catch (NumberFormatException) → BAD_REQUEST` 处理,属正确的业务参数语义,未被误删或误判。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. 禁止模式扫描 |
||||
|
|
||||
|
任务书指定模式在 8 个文件中的命中:**全部为 0** ✔ |
||||
|
|
||||
|
覆盖:`SecurityContextHolder`、`AnonymousAuthenticationToken`、`anonymousUser`、`auth.getName()`、`authentication.getName()`、`Long.(valueOf|parseLong)(...(auth|authentication)`、`private (resolveUserId|getRequestId|requestId)(`、`getHeader(...X-Request-Id|REQUEST_ID_HEADER)`、`@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`new CurrentUserAccessor`、`new RequestIdAccessor` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. 构造器注入与无回退证明 |
||||
|
|
||||
|
8 个 Controller 均声明: |
||||
|
|
||||
|
```java |
||||
|
private final CurrentUserAccessor currentUserAccessor; |
||||
|
private final RequestIdAccessor requestIdAccessor; |
||||
|
``` |
||||
|
|
||||
|
- 全部为 **`private final` 构造器注入**(Lombok `@RequiredArgsConstructor`)✔ |
||||
|
- **无** `@Autowired(required = false)` / `@Nullable` / `ObjectProvider` / `new CurrentUserAccessor()` / `new RequestIdAccessor()` 回退 ✔ |
||||
|
- 身份统一为 `currentUserAccessor.requireUserId()`;requestId 统一为 `requestIdAccessor.get(request)` ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 独立接线变异测试(Accessor 真实生效的证明) |
||||
|
|
||||
|
新增 `A5AccessorWiringIndependentTest`(4 例),**不复用实施者断言**,使用 `@MockBean` 替换 `CurrentUserAccessor`、`RequestIdAccessor` 与 8 个 Service: |
||||
|
|
||||
|
| 用例 | 证明 | |
||||
|
|---|---| |
||||
|
| `allEightEndpoints_useAccessorUserId` | mock 返回 `4242L` → 逐一验证 **8 个 Service 均收到 `4242L`**(`listMyAppeals` / `getScore` / `listFavorites` / `listPendingReviews` / `listPending` / `listMine` / `deleteReview` / `getMyWeights`),并 `verify(currentUserAccessor, times(8)).requireUserId()` | |
||||
|
| `illegalIdentity_rejectedBeforeService` | mock 抛 `InsufficientAuthenticationException` → 8 端点全部 401、无 500,且各 Service **零调用** | |
||||
|
| `responseBodyRequestId_comesFromAccessor` | mock 返回 `a5-independent-request` → 8 个端点响应体 `requestId` 均为该值,`verify(requestIdAccessor, times(8)).get(...)` | |
||||
|
| `noToken_is401` | 无 Token → Security 链 401 | |
||||
|
|
||||
|
**变异有效性**:若删除任一 Controller 中的 `currentUserAccessor.requireUserId()` 或 `requestIdAccessor.get(request)` 调用,对应 `verify` 与断言必然失败 —— 即**删除 Accessor 调用会使本测试失败** ✔ |
||||
|
|
||||
|
> 说明:响应头 `X-Request-Id` 由 `RequestIdFilter` 设置,**不来自 Controller**;本测试只断言**响应体**的 `requestId` 来源于 Accessor,未错误声称响应头来源。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. 八端点非法主体结果(独立身份边界复验) |
||||
|
|
||||
|
`A5IndependentVerificationTest` 使用**真实 `CurrentUserAccessor` 与真实 `SecurityFilterChain`**(不 mock Accessor): |
||||
|
|
||||
|
| 主体 | 结果 | |
||||
|
|---|---| |
||||
|
| `anonymousUser` | **401** ✔ | |
||||
|
| `not-a-number` | **401** ✔ | |
||||
|
| `99999999999999999999`(20 位溢出) | **401** ✔ | |
||||
|
| `0` | **401** ✔ | |
||||
|
| `-1` | **401** ✔ | |
||||
|
| `AnonymousAuthenticationToken` / 无 Token | **401** ✔ | |
||||
|
|
||||
|
逐端点断言(7 个 GET 端点 + `DELETE /jobs/1/reviews/1`): |
||||
|
- **HTTP 401,无 500** ✔ |
||||
|
- **业务 Service 零调用** ✔(`verify(..., never())`) |
||||
|
- **不回显非法主体** —— 仅解析 JSON 后检查 `code`/`message` 稳定字段,**未对整段 JSON 做数字搜索** ✔ |
||||
|
- 响应不包含 `Exception` / `java.` / `principal` / `anonymousUser` / `com.mydreamjob` / `NumberFormat` ✔ |
||||
|
|
||||
|
**未使用**任何捕获 `NumberFormatException` 的方式"修复"测试。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. 合法主体传入 Service 的 userId 证据 |
||||
|
|
||||
|
§5 的 `verify` 系列即为证据:mock Accessor 返回 `4242L` 时,**8 个 Service 各自收到 `4242L`**(逐一 `eq(4242L)` 断言)✔ |
||||
|
|
||||
|
同时 `A5IndependentVerificationTest#legalPrincipal_reachesService` 使用**真实 Accessor**(数字主体 `"7"`)验证 8 端点**不被身份层判为未登录**(非 401)✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. requestId Accessor 接线证据 |
||||
|
|
||||
|
见 §5 第 3 项:mock `RequestIdAccessor#get` 返回固定值 → 8 个端点(7 个 GET + 1 个 DELETE,均返回 `ApiResponse`)响应体 `requestId` 均为 `a5-independent-request`,并 `verify(requestIdAccessor, times(8)).get(any(HttpServletRequest.class))` ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. 401 / 403 / 404 分层保持 |
||||
|
|
||||
|
| 检查项 | 结果 | |
||||
|
|---|---| |
||||
|
| 静态审查:角色/归属检查是否被删改或移动 | **否**(`git diff` 确认未删未增)✔ | |
||||
|
| `FORBIDDEN` / `NOT_FOUND` 错误码 | **未修改** ✔ | |
||||
|
| `ErrorCode.java` | **零改动** ✔ | |
||||
|
| 合法主体不被判为未登录 | ✔(§7) | |
||||
|
|
||||
|
**401 与 403/404 的分层未被统一**:身份失败为 401,角色/归属失败仍由既有 Service 逻辑产生 403/404,语义未漂移。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10. 失败请求的业务副作用 |
||||
|
|
||||
|
新增 `A5SideEffectSnapshotTest`(2 例),使用 H2 **行级快照**(显式列 + 稳定 `ORDER BY`,**逐字段**比较,**非 COUNT/SUM**): |
||||
|
|
||||
|
快照覆盖 9 张核心表:`JOB_FAVORITES`、`JOB_REVIEWS`、`COMPANY_REVIEW_REPLIES`、`INTERVIEW_SCORES`、`CANDIDATE_SCORING_APPEALS`、`STUDENT_REVIEW_WEIGHT_PREFERENCES`、`NOTIFICATIONS`、`MESSAGES`、`AUDIT_LOGS`。 |
||||
|
|
||||
|
| 用例 | 结果 | |
||||
|
|---|---| |
||||
|
| 非法身份请求(`anonymousUser` + `not-a-number` × 7 端点 + DELETE)前后 | **9 张表逐字段零变化** ✔ | |
||||
|
| 无 Token 请求前后 | **9 张表逐字段零变化** ✔ | |
||||
|
|
||||
|
**结论**:非法身份请求不写收藏、评价、回复、面试评分、治理申诉、权重;不发通知;不发 RabbitMQ 消息;不写审计成功记录(`AUDIT_LOGS` 零变化)✔ |
||||
|
|
||||
|
> 本轮**未启动 Docker**,以上为 H2 独立复验结论;**不声称真实 MySQL 结论**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 11. 测试数 |
||||
|
|
||||
|
| 命令 | 结果 | |
||||
|
|---|---| |
||||
|
| `mvn -Dtest="A5AccessorWiringIndependentTest,A5IndependentVerificationTest" test` | **8 / 8**,F=0 E=0 **S=0** | |
||||
|
| `mvn -Dtest="A5SideEffectSnapshotTest" test` | **2 / 2**,F=0 E=0 **S=0** | |
||||
|
| A5 定向(`R01A5…,A5*Test,` 8 个 Controller 测试 + 3 个 Accessor/Filter 测试) | **98 / 98**,F=0 E=0 **S=0**,BUILD SUCCESS | |
||||
|
| `mvn clean verify` | **2255 / 2255**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
||||
|
|
||||
|
- 实施基线 2245 → 复验后 **2255**(新增 **10** 个独立测试:4 + 4 + 2),**不低于基线** ✔ |
||||
|
- 未见新的基线测试污染 ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 12. OpenAPI |
||||
|
|
||||
|
`python docs\verify_openapi.py` → **176 paths / 214 operations**,All checks passed ✔ |
||||
|
|
||||
|
**`docs/openapi-rc.json` 零修改** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 13. Flyway / ErrorCode / DTO / 接口 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| V1–V45 迁移 | **零修改**(`git diff --name-only -- db/migration` 为空)✔ | |
||||
|
| `ErrorCode.java` | **零改动** ✔ | |
||||
|
| DTO | 零改动 ✔ | |
||||
|
| 公网接口 / OpenAPI | 零新增零修改 ✔ | |
||||
|
| 新增表 / 索引 | 无 ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 14. 全仓剩余统计(逐文件分类) |
||||
|
|
||||
|
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`)—— **4 文件** |
||||
|
|
||||
|
| 分类 | 文件 | |
||||
|
|---|---| |
||||
|
| 基础设施(3) | `security/CurrentUserAccessor`(Accessor 自身)、`auth/JwtAuthenticationFilter`(认证过滤器)、`deletion/DeletionGuardInterceptor`(拦截器) | |
||||
|
| **业务 Controller(1)** | `scoringmonitoring/FairnessMonitoringController` | |
||||
|
| 误报 | 0 | |
||||
|
|
||||
|
**requestId 解析**(含 `X-Request-Id` 直读、`RequestIdFilter.REQUEST_ID_HEADER`、`String (getRequestId|resolveRequestId|requestId)(`)—— **10 文件** |
||||
|
|
||||
|
| 分类 | 文件 | |
||||
|
|---|---| |
||||
|
| 基础设施 / 非业务入口(5) | `audit/AuditContext`、`common/exception/GlobalExceptionHandler`、`common/logging/RequestIdAccessor`、`common/logging/RequestLoggingFilter`、`deletion/DeletionGuardInterceptor` | |
||||
|
| **业务 Controller(5)** | `controller/ApiController`、`controller/AuthController`、`controller/SystemController`、`job/JobController`、`scoringmonitoring/FairnessMonitoringController` | |
||||
|
| 误报 | 0 | |
||||
|
|
||||
|
**与路线图记数的一处差异(如实记录)**:路线图 R01 行记「全仓 requestId 11 文件 = 基础设施/非业务入口 6 + 业务 5」。本次独立复验用任务书指定模式实测为 **10 文件 = 基础设施 5 + 业务 5**。**业务 Controller 数一致(5)**;差异在基础设施计数(5 vs 6),推测为路线图对"基础设施/非业务入口"的分类口径略有不同(如是否计入某调用点)。**不应据此修改代码或路线图**;建议后续批次统一口径。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 15. 精确新增文件 |
||||
|
|
||||
|
``` |
||||
|
新增测试(3) |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5AccessorWiringIndependentTest.java |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5IndependentVerificationTest.java |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5SideEffectSnapshotTest.java |
||||
|
|
||||
|
复验报告(1) |
||||
|
docs/r01-a5-independent-verification-20260917.md |
||||
|
``` |
||||
|
|
||||
|
**未修改**:8 个 A5 Controller 生产代码、既有测试断言、`GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、OpenAPI、Flyway。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 16. Git 状态 |
||||
|
|
||||
|
``` |
||||
|
分支:refactor/r01-structure-performance |
||||
|
HEAD:0226977063243fce101e3f6c8dcf66492d306ef7(未变) |
||||
|
暂存区:空 |
||||
|
git add -A / commit / push:均未执行 |
||||
|
reset / restore / checkout / clean:均未执行 |
||||
|
Docker:未操作 |
||||
|
工作区:8 个 A5 Controller + 路线图(实施者改动,仍未暂存)+ 本报告与 3 个独立测试(未跟踪) |
||||
|
历史未跟踪文件:全部保留 |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 17. 是否发现生产缺陷 |
||||
|
|
||||
|
**未发现生产缺陷。** 8 个 Controller 的迁移完整、正确: |
||||
|
- 禁止模式 0 命中;构造器注入无回退;`parseIds` 业务解析正确保留 |
||||
|
- 角色/归属/403/404 语义未漂移 |
||||
|
- 独立接线变异测试证明 Accessor 真实生效(删除调用即失败) |
||||
|
- 独立身份边界测试证明非法主体在业务调用前被拒(401、无 500、零 Service 调用)且不回显主体 |
||||
|
- 失败请求 9 张核心表逐字段零变化 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 18. 明确声明 |
||||
|
|
||||
|
- **未暂存、未提交、未推送。** |
||||
|
- 未修改 8 个 Controller 或任何生产代码;未修改、删除或放宽既有测试断言;未修改 `GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、OpenAPI、Flyway。 |
||||
|
- 未进入 R01-A6;未删除历史未跟踪文件。 |
||||
|
- 本轮未启动 Docker,**未伪造真实 MySQL 结论**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
> **R01-A5独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** |
||||
@ -0,0 +1,79 @@ |
|||||
|
# R01-A5 第五批请求上下文收束实施报告 |
||||
|
|
||||
|
实施日期:2026-09-17 |
||||
|
|
||||
|
分支:`refactor/r01-structure-performance` |
||||
|
|
||||
|
基线 HEAD:`0226977063243fce101e3f6c8dcf66492d306ef7` |
||||
|
|
||||
|
## 结论 |
||||
|
|
||||
|
R01-A5 第五批请求身份与 requestId 收束已完成自动化验证,待独立代码复验。 |
||||
|
|
||||
|
## 修改范围 |
||||
|
|
||||
|
本批仅修改以下 8 个 Controller: |
||||
|
|
||||
|
1. `CandidateScoringGovernanceController` |
||||
|
2. `InterviewScoreController` |
||||
|
3. `FavoriteController` |
||||
|
4. `AdminReviewController` |
||||
|
5. `AdminReviewReplyController` |
||||
|
6. `CompanyReviewManagementController` |
||||
|
7. `JobReviewController` |
||||
|
8. `StudentReviewWeightController` |
||||
|
|
||||
|
每个 Controller 均执行相同的受控迁移: |
||||
|
|
||||
|
- 构造器注入既有 `CurrentUserAccessor` 与 `RequestIdAccessor`; |
||||
|
- 身份统一调用 `currentUserAccessor.requireUserId()`; |
||||
|
- requestId 统一调用 `requestIdAccessor.get(request)`; |
||||
|
- 删除本地身份解析方法和 requestId 读取方法; |
||||
|
- 保留原有角色、资源归属、Service 参数、DTO 和响应结构。 |
||||
|
|
||||
|
`JobReviewController.parseIds` 是岗位 ID 业务参数解析,予以保留。 |
||||
|
|
||||
|
## 静态门禁 |
||||
|
|
||||
|
8 个目标文件对以下模式均为零命中: |
||||
|
|
||||
|
- `SecurityContextHolder` / `Authentication` / `anonymousUser`; |
||||
|
- 身份 `Long.valueOf(auth.getName())`; |
||||
|
- `resolveUserId` / `getRequestId` / 私有 `requestId(...)`; |
||||
|
- 原始 `X-Request-Id` header 读取; |
||||
|
- 可选注入、`ObjectProvider` 或手工 `new Accessor()` 回退。 |
||||
|
|
||||
|
## 新增验证 |
||||
|
|
||||
|
新增 `R01A5RequestContextMigrationIntegrationTest`,使用真实 Spring Security 与 MVC 映射: |
||||
|
|
||||
|
- 8 个 Controller 各选一个真实端点; |
||||
|
- 非法主体覆盖 `anonymousUser`、非数字、20 位 Long 溢出、0、负数; |
||||
|
- 所有组合必须返回 HTTP 401,且响应不含异常类、堆栈或非法主体; |
||||
|
- 合法主体下验证规范化 requestId 的响应头与响应体同源。 |
||||
|
|
||||
|
定向与关联测试:90/90,Failures=0,Errors=0,Skipped=0。 |
||||
|
|
||||
|
## 全量验证 |
||||
|
|
||||
|
- `mvn clean verify`:BUILD SUCCESS,2245/2245,Failures=0,Errors=0,Skipped=0; |
||||
|
- `python docs/verify_openapi.py`:176 paths / 214 operations,All checks passed; |
||||
|
- V1-V45 无差异; |
||||
|
- `docs/openapi-rc.json` 无差异; |
||||
|
- `git diff --check` 无空白错误,仅既有 LF/CRLF 提示。 |
||||
|
|
||||
|
## 全仓剩余 |
||||
|
|
||||
|
完成 A5 后: |
||||
|
|
||||
|
- 身份解析共 4 个文件:基础设施 3 个,业务 Controller 仅 `FairnessMonitoringController` 1 个; |
||||
|
- requestId 入口共 11 个文件:基础设施/非业务入口 6 个,业务 Controller 5 个; |
||||
|
- 最终 A6 业务白名单为 `FairnessMonitoringController`、`ApiController`、`AuthController`、`SystemController`、`JobController`。 |
||||
|
|
||||
|
## 边界 |
||||
|
|
||||
|
- 未新增或修改 Flyway、ErrorCode、接口、DTO、表、索引或依赖; |
||||
|
- 未修改评分、申诉、面试评分、岗位收藏或评价业务规则; |
||||
|
- 未暂存、未提交、未推送; |
||||
|
- 未删除或移动历史未跟踪文件; |
||||
|
- 未进入 A6。 |
||||
@ -0,0 +1,194 @@ |
|||||
|
package com.mydreamjob.common.r01a5; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.candidatescore.governance.CandidateScoringGovernanceService; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.interviewscore.InterviewScoreService; |
||||
|
import com.mydreamjob.job.JobFavoriteService; |
||||
|
import com.mydreamjob.review.AdminReviewReplyService; |
||||
|
import com.mydreamjob.review.CompanyReviewManagementService; |
||||
|
import com.mydreamjob.review.JobReviewService; |
||||
|
import com.mydreamjob.review.StudentReviewWeightService; |
||||
|
import com.mydreamjob.security.CurrentUserAccessor; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
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.security.authentication.InsufficientAuthenticationException; |
||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; |
||||
|
import org.springframework.security.core.authority.AuthorityUtils; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.anyInt; |
||||
|
import static org.mockito.ArgumentMatchers.anyString; |
||||
|
import static org.mockito.ArgumentMatchers.eq; |
||||
|
import static org.mockito.Mockito.never; |
||||
|
import static org.mockito.Mockito.times; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
|
||||
|
/** |
||||
|
* R01-A5 独立复验:**接线变异检测**(不复用实施者断言)。 |
||||
|
* |
||||
|
* <p>把 {@code CurrentUserAccessor} / {@code RequestIdAccessor} 与 8 个 Service 全部替换为 mock, |
||||
|
* 从而证明 8 个 Controller 确实通过既有 Accessor 获取身份与 requestId: |
||||
|
* 若删除 Controller 中的 Accessor 调用,本测试必然失败。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A5AccessorWiringIndependentTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
|
||||
|
@MockBean private CurrentUserAccessor currentUserAccessor; |
||||
|
@MockBean private RequestIdAccessor requestIdAccessor; |
||||
|
|
||||
|
@MockBean private CandidateScoringGovernanceService governanceService; |
||||
|
@MockBean private InterviewScoreService interviewScoreService; |
||||
|
@MockBean private JobFavoriteService favoriteService; |
||||
|
@MockBean private JobReviewService reviewService; |
||||
|
@MockBean private AdminReviewReplyService adminService; |
||||
|
@MockBean private CompanyReviewManagementService managementService; |
||||
|
@MockBean private StudentReviewWeightService weightService; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||
|
|
||||
|
private static final String APPEALS = "/profile/me/candidate-scoring/appeals"; |
||||
|
private static final String INTERVIEW_SCORE = "/interviews/1/score"; |
||||
|
private static final String FAVORITES = "/jobs/favorites"; |
||||
|
private static final String ADMIN_REVIEWS = "/admin/reviews/pending"; |
||||
|
private static final String ADMIN_REPLIES = "/admin/review-replies/pending"; |
||||
|
private static final String COMPANY_REVIEWS = "/companies/me/job-reviews"; |
||||
|
private static final String DELETE_REVIEW = "/jobs/1/reviews/1"; |
||||
|
private static final String REVIEW_WEIGHTS = "/profile/me/review-weights"; |
||||
|
|
||||
|
private static final UsernamePasswordAuthenticationToken LEGAL = |
||||
|
new UsernamePasswordAuthenticationToken("42", "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
|
||||
|
// ── 1. 身份 Accessor 必须真实参与调用 ────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:8 端点均调用 requireUserId(),且 Service 收到 Accessor 返回的 4242") |
||||
|
void allEightEndpoints_useAccessorUserId() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(4242L); |
||||
|
when(requestIdAccessor.get(any())).thenReturn("a5-independent-request"); |
||||
|
|
||||
|
mockMvc.perform(get(APPEALS).with(authentication(LEGAL))).andReturn(); |
||||
|
verify(governanceService).listMyAppeals(eq(4242L), any(), anyInt(), anyInt()); |
||||
|
|
||||
|
mockMvc.perform(get(INTERVIEW_SCORE).with(authentication(LEGAL))).andReturn(); |
||||
|
verify(interviewScoreService).getScore(eq(4242L), any()); |
||||
|
|
||||
|
mockMvc.perform(get(FAVORITES).with(authentication(LEGAL))).andReturn(); |
||||
|
verify(favoriteService).listFavorites(eq(4242L), anyInt(), anyInt()); |
||||
|
|
||||
|
mockMvc.perform(get(ADMIN_REVIEWS).with(authentication(LEGAL))).andReturn(); |
||||
|
verify(reviewService).listPendingReviews(eq(4242L), any(), any(), any(), any(), anyInt(), anyInt()); |
||||
|
|
||||
|
mockMvc.perform(get(ADMIN_REPLIES).with(authentication(LEGAL))).andReturn(); |
||||
|
verify(adminService).listPending(eq(4242L), any(), any(), any(), any(), any(), anyInt(), anyInt()); |
||||
|
|
||||
|
mockMvc.perform(get(COMPANY_REVIEWS).with(authentication(LEGAL))).andReturn(); |
||||
|
verify(managementService).listMine(eq(4242L), any(), any(), any(), any(), any(), any(), anyInt(), anyInt()); |
||||
|
|
||||
|
mockMvc.perform(delete(DELETE_REVIEW).with(authentication(LEGAL))).andReturn(); |
||||
|
verify(reviewService).deleteReview(eq(4242L), any(), any(), any()); |
||||
|
|
||||
|
mockMvc.perform(get(REVIEW_WEIGHTS).with(authentication(LEGAL))).andReturn(); |
||||
|
verify(weightService).getMyWeights(eq(4242L)); |
||||
|
|
||||
|
// 8 个端点各调用一次 Accessor(证明接线真实存在)
|
||||
|
verify(currentUserAccessor, times(8)).requireUserId(); |
||||
|
} |
||||
|
|
||||
|
// ── 2. 非法身份必须在业务调用前拒绝 ──────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:Accessor 抛未登录时 8 端点全部 401、无 500、Service 零调用") |
||||
|
void illegalIdentity_rejectedBeforeService() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()) |
||||
|
.thenThrow(new InsufficientAuthenticationException("authentication required")); |
||||
|
|
||||
|
List<String> paths = List.of(APPEALS, INTERVIEW_SCORE, FAVORITES, ADMIN_REVIEWS, |
||||
|
ADMIN_REPLIES, COMPANY_REVIEWS, REVIEW_WEIGHTS); |
||||
|
|
||||
|
for (String path : paths) { |
||||
|
var r = mockMvc.perform(get(path).with(authentication(LEGAL))).andReturn(); |
||||
|
assertThat(r.getResponse().getStatus()).as("path=%s 必须 401", path).isEqualTo(401); |
||||
|
assertThat(r.getResponse().getStatus()).as("path=%s 不得 500", path).isNotEqualTo(500); |
||||
|
|
||||
|
JsonNode json = MAPPER.readTree(r.getResponse().getContentAsString()); |
||||
|
assertThat(json.path("code").asInt()).as("path=%s", path).isEqualTo(401); |
||||
|
String msg = json.path("message").asText(); |
||||
|
assertThat(msg).isIn("authentication required", "not authenticated"); |
||||
|
assertThat(msg) |
||||
|
.doesNotContain("Exception").doesNotContain("java.") |
||||
|
.doesNotContain("principal").doesNotContain("anonymousUser") |
||||
|
.doesNotContain("com.mydreamjob"); |
||||
|
assertThat(json.has("stackTrace")).isFalse(); |
||||
|
assertThat(json.has("trace")).isFalse(); |
||||
|
} |
||||
|
|
||||
|
var dr = mockMvc.perform(delete(DELETE_REVIEW).with(authentication(LEGAL))).andReturn(); |
||||
|
assertThat(dr.getResponse().getStatus()).isEqualTo(401); |
||||
|
|
||||
|
// 业务 Service 零调用
|
||||
|
verify(governanceService, never()).listMyAppeals(any(), any(), anyInt(), anyInt()); |
||||
|
verify(interviewScoreService, never()).getScore(any(), any()); |
||||
|
verify(favoriteService, never()).listFavorites(any(), anyInt(), anyInt()); |
||||
|
verify(reviewService, never()).deleteReview(any(), any(), any(), any()); |
||||
|
verify(weightService, never()).getMyWeights(any()); |
||||
|
} |
||||
|
|
||||
|
// ── 3. requestId Accessor 必须真实参与调用 ───────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:响应体 requestId 来自 RequestIdAccessor") |
||||
|
void responseBodyRequestId_comesFromAccessor() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(42L); |
||||
|
when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a5-independent-request"); |
||||
|
|
||||
|
for (String path : List.of(APPEALS, INTERVIEW_SCORE, FAVORITES, ADMIN_REVIEWS, |
||||
|
ADMIN_REPLIES, COMPANY_REVIEWS, REVIEW_WEIGHTS)) { |
||||
|
String body = mockMvc.perform(get(path).with(authentication(LEGAL))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
assertThat(body).as("path=%s 响应体 requestId 应来自 Accessor", path) |
||||
|
.contains("\"requestId\":\"a5-independent-request\""); |
||||
|
} |
||||
|
|
||||
|
// DELETE 端点同样返回 ApiResponse,含 requestId
|
||||
|
String delBody = mockMvc.perform(delete(DELETE_REVIEW).with(authentication(LEGAL))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
assertThat(delBody).contains("\"requestId\":\"a5-independent-request\""); |
||||
|
|
||||
|
verify(requestIdAccessor, times(8)).get(any(HttpServletRequest.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:无 Token 时 8 端点由 Security 链返回 401(不经 Controller)") |
||||
|
void noToken_is401() throws Exception { |
||||
|
for (String path : List.of(APPEALS, INTERVIEW_SCORE, FAVORITES, ADMIN_REVIEWS, |
||||
|
ADMIN_REPLIES, COMPANY_REVIEWS, REVIEW_WEIGHTS)) { |
||||
|
assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) |
||||
|
.as(path).isEqualTo(401); |
||||
|
} |
||||
|
assertThat(mockMvc.perform(delete(DELETE_REVIEW)).andReturn().getResponse().getStatus()) |
||||
|
.isEqualTo(401); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,148 @@ |
|||||
|
package com.mydreamjob.common.r01a5; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.candidatescore.governance.CandidateScoringGovernanceService; |
||||
|
import com.mydreamjob.interviewscore.InterviewScoreService; |
||||
|
import com.mydreamjob.job.JobFavoriteService; |
||||
|
import com.mydreamjob.review.AdminReviewReplyService; |
||||
|
import com.mydreamjob.review.CompanyReviewManagementService; |
||||
|
import com.mydreamjob.review.JobReviewService; |
||||
|
import com.mydreamjob.review.StudentReviewWeightService; |
||||
|
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.security.authentication.AnonymousAuthenticationToken; |
||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; |
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.authority.AuthorityUtils; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.anyInt; |
||||
|
import static org.mockito.Mockito.never; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
|
||||
|
/** |
||||
|
* R01-A5 独立复验:**身份边界**(使用真实 {@code CurrentUserAccessor} 与真实 SecurityFilterChain)。 |
||||
|
* |
||||
|
* <p>验证非法主体在业务调用前安全失败,且合法主体仍进入角色/归属判断。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A5IndependentVerificationTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
|
||||
|
@MockBean private CandidateScoringGovernanceService governanceService; |
||||
|
@MockBean private InterviewScoreService interviewScoreService; |
||||
|
@MockBean private JobFavoriteService favoriteService; |
||||
|
@MockBean private JobReviewService reviewService; |
||||
|
@MockBean private AdminReviewReplyService adminService; |
||||
|
@MockBean private CompanyReviewManagementService managementService; |
||||
|
@MockBean private StudentReviewWeightService weightService; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||
|
|
||||
|
private static final List<String> PATHS = List.of( |
||||
|
"/profile/me/candidate-scoring/appeals", |
||||
|
"/interviews/1/score", |
||||
|
"/jobs/favorites", |
||||
|
"/admin/reviews/pending", |
||||
|
"/admin/review-replies/pending", |
||||
|
"/companies/me/job-reviews", |
||||
|
"/profile/me/review-weights"); |
||||
|
|
||||
|
private static final String DELETE_REVIEW = "/jobs/1/reviews/1"; |
||||
|
|
||||
|
private static final List<String> ILLEGAL = List.of( |
||||
|
"anonymousUser", "not-a-number", "99999999999999999999", "0", "-1"); |
||||
|
|
||||
|
private static Authentication principal(String name) { |
||||
|
return new UsernamePasswordAuthenticationToken(name, "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:五类非法主体 × 端点 一律 401、无 500、不回显主体") |
||||
|
void illegalPrincipals_are401() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
for (String p : ILLEGAL) { |
||||
|
var r = mockMvc.perform(get(path).with(authentication(principal(p)))).andReturn(); |
||||
|
int st = r.getResponse().getStatus(); |
||||
|
assertThat(st).as("path=%s principal='%s' 必须 401", path, p).isEqualTo(401); |
||||
|
|
||||
|
JsonNode json = MAPPER.readTree(r.getResponse().getContentAsString()); |
||||
|
assertThat(json.path("code").asInt()).as("path=%s", path).isEqualTo(401); |
||||
|
String msg = json.path("message").asText(); |
||||
|
assertThat(msg).isIn("authentication required", "not authenticated"); |
||||
|
// 只检查稳定字段,不对整段 JSON 做数字搜索
|
||||
|
assertThat(msg) |
||||
|
.doesNotContain("Exception").doesNotContain("java.") |
||||
|
.doesNotContain("principal").doesNotContain("anonymousUser") |
||||
|
.doesNotContain("com.mydreamjob").doesNotContain("NumberFormat"); |
||||
|
if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) { |
||||
|
assertThat(msg).doesNotContain(p); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
// DELETE 端点
|
||||
|
for (String p : ILLEGAL) { |
||||
|
assertThat(mockMvc.perform(delete(DELETE_REVIEW).with(authentication(principal(p)))) |
||||
|
.andReturn().getResponse().getStatus()).as("delete principal='%s'", p).isEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:匿名 token 与无 Token 均 401") |
||||
|
void anonymousAndNoToken_are401() throws Exception { |
||||
|
Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); |
||||
|
for (String path : PATHS) { |
||||
|
assertThat(mockMvc.perform(get(path).with(authentication(anon))).andReturn() |
||||
|
.getResponse().getStatus()).as(path).isEqualTo(401); |
||||
|
assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) |
||||
|
.as(path).isEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:非法主体的失败请求不触达任何业务 Service") |
||||
|
void illegalPrincipals_produceNoServiceCall() throws Exception { |
||||
|
for (String p : ILLEGAL) { |
||||
|
for (String path : PATHS) { |
||||
|
mockMvc.perform(get(path).with(authentication(principal(p)))).andReturn(); |
||||
|
} |
||||
|
} |
||||
|
verify(governanceService, never()).listMyAppeals(any(), any(), anyInt(), anyInt()); |
||||
|
verify(interviewScoreService, never()).getScore(any(), any()); |
||||
|
verify(favoriteService, never()).listFavorites(any(), anyInt(), anyInt()); |
||||
|
verify(reviewService, never()).deleteReview(any(), any(), any(), any()); |
||||
|
verify(weightService, never()).getMyWeights(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:合法数字主体仍能进入业务层(不被身份层拒绝)") |
||||
|
void legalPrincipal_reachesService() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
int st = mockMvc.perform(get(path).with(authentication(principal("7")))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("path=%s 合法主体不得被判未登录", path).isNotEqualTo(401); |
||||
|
} |
||||
|
assertThat(mockMvc.perform(delete(DELETE_REVIEW).with(authentication(principal("7")))) |
||||
|
.andReturn().getResponse().getStatus()).isNotEqualTo(401); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,124 @@ |
|||||
|
package com.mydreamjob.common.r01a5; |
||||
|
|
||||
|
import com.mydreamjob.candidatescore.governance.CandidateScoringGovernanceService; |
||||
|
import com.mydreamjob.interviewscore.InterviewScoreService; |
||||
|
import com.mydreamjob.job.JobFavoriteService; |
||||
|
import com.mydreamjob.review.AdminReviewReplyService; |
||||
|
import com.mydreamjob.review.CompanyReviewManagementService; |
||||
|
import com.mydreamjob.review.JobReviewService; |
||||
|
import com.mydreamjob.review.StudentReviewWeightService; |
||||
|
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.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
|
||||
|
import java.util.LinkedHashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
|
||||
|
/** |
||||
|
* R01-A5 独立复验:**失败请求的业务副作用**。 |
||||
|
* |
||||
|
* <p>使用 H2 行级快照(显式列 + 稳定 ORDER BY,逐字段比较,非 COUNT/SUM), |
||||
|
* 验证非法身份请求前后与本批端点相关的核心表完全不变。</p> |
||||
|
* |
||||
|
* <p>说明:本轮不启动 Docker,H2 独立复验;不声称真实 MySQL 结论。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A5SideEffectSnapshotTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
@Autowired private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
@MockBean private CandidateScoringGovernanceService governanceService; |
||||
|
@MockBean private InterviewScoreService interviewScoreService; |
||||
|
@MockBean private JobFavoriteService favoriteService; |
||||
|
@MockBean private JobReviewService reviewService; |
||||
|
@MockBean private AdminReviewReplyService adminService; |
||||
|
@MockBean private CompanyReviewManagementService managementService; |
||||
|
@MockBean private StudentReviewWeightService weightService; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
/** (表, 主键列, 全列) —— 逐字段快照。 */ |
||||
|
private static final List<String[]> TABLES = List.of( |
||||
|
new String[]{"JOB_FAVORITES", "ID", "ID, JOB_ID, STUDENT_USER_ID, CREATED_AT"}, |
||||
|
new String[]{"JOB_REVIEWS", "ID", "ID, JOB_ID, STUDENT_USER_ID, RATING, CONTENT, STATUS, CREATED_AT, UPDATED_AT"}, |
||||
|
new String[]{"COMPANY_REVIEW_REPLIES", "ID", "ID, REVIEW_ID, COMPANY_ID, CONTENT, MODERATION_STATUS, VERSION, CREATED_AT, UPDATED_AT"}, |
||||
|
new String[]{"INTERVIEW_SCORES", "ID", "ID, INTERVIEW_ID, APPLICATION_ID, COMPANY_USER_ID, STUDENT_USER_ID, STATUS, CREATED_AT, UPDATED_AT"}, |
||||
|
new String[]{"CANDIDATE_SCORING_APPEALS", "ID", "ID, CANDIDATE_USER_ID, DIMENSION, PERIOD, STATUS, REASON, CREATED_AT, UPDATED_AT"}, |
||||
|
new String[]{"STUDENT_REVIEW_WEIGHT_PREFERENCES", "ID", "ID, STUDENT_USER_ID, SOURCE, TEMPLATE_CODE, VERSION, CREATED_AT, UPDATED_AT"}, |
||||
|
new String[]{"NOTIFICATIONS", "ID", "ID, USER_ID, TYPE, TITLE, IS_READ, CREATED_AT"}, |
||||
|
new String[]{"MESSAGES", "ID", "ID, CONVERSATION_ID, SENDER_USER_ID, CONTENT, CREATED_AT"}, |
||||
|
new String[]{"AUDIT_LOGS", "ID", "ID, USER_ID, ACTION, ENTITY_TYPE, ENTITY_ID, CREATED_AT"}); |
||||
|
|
||||
|
private Map<String, List<Map<String, Object>>> snapshot() { |
||||
|
Map<String, List<Map<String, Object>>> out = new LinkedHashMap<>(); |
||||
|
for (String[] t : TABLES) { |
||||
|
List<Map<String, Object>> rows = jdbcTemplate.queryForList( |
||||
|
"SELECT " + t[2] + " FROM " + t[0] + " ORDER BY " + t[2]); |
||||
|
out.put(t[0], rows); |
||||
|
} |
||||
|
return out; |
||||
|
} |
||||
|
|
||||
|
private static final List<String> PATHS = List.of( |
||||
|
"/profile/me/candidate-scoring/appeals", |
||||
|
"/interviews/1/score", |
||||
|
"/jobs/favorites", |
||||
|
"/admin/reviews/pending", |
||||
|
"/admin/review-replies/pending", |
||||
|
"/companies/me/job-reviews", |
||||
|
"/profile/me/review-weights"); |
||||
|
private static final String DELETE_REVIEW = "/jobs/1/reviews/1"; |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:非法身份请求前后,九张核心表逐字段零变化") |
||||
|
void illegalIdentityRequests_produceNoBusinessWrite() throws Exception { |
||||
|
Map<String, List<Map<String, Object>>> before = snapshot(); |
||||
|
|
||||
|
var bad = new UsernamePasswordAuthenticationToken("anonymousUser", "n/a", AuthorityUtils.NO_AUTHORITIES); |
||||
|
for (String path : PATHS) { |
||||
|
mockMvc.perform(get(path).with(authentication(bad))).andReturn(); |
||||
|
mockMvc.perform(get(path).with(authentication( |
||||
|
new UsernamePasswordAuthenticationToken("not-a-number", "n/a", AuthorityUtils.NO_AUTHORITIES)))).andReturn(); |
||||
|
} |
||||
|
mockMvc.perform(delete(DELETE_REVIEW).with(authentication(bad))).andReturn(); |
||||
|
|
||||
|
Map<String, List<Map<String, Object>>> after = snapshot(); |
||||
|
|
||||
|
for (String[] t : TABLES) { |
||||
|
assertThat(after.get(t[0])) |
||||
|
.as("%s 必须逐字段零变化", t[0]) |
||||
|
.isEqualTo(before.get(t[0])); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:无 Token 请求前后同样零变化") |
||||
|
void noTokenRequests_produceNoBusinessWrite() throws Exception { |
||||
|
Map<String, List<Map<String, Object>>> before = snapshot(); |
||||
|
for (String path : PATHS) { |
||||
|
mockMvc.perform(get(path)).andReturn(); |
||||
|
} |
||||
|
mockMvc.perform(delete(DELETE_REVIEW)).andReturn(); |
||||
|
Map<String, List<Map<String, Object>>> after = snapshot(); |
||||
|
for (String[] t : TABLES) { |
||||
|
assertThat(after.get(t[0])).as("%s", t[0]).isEqualTo(before.get(t[0])); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,124 @@ |
|||||
|
package com.mydreamjob.common.r01a5; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.candidatescore.governance.CandidateScoringGovernanceService; |
||||
|
import com.mydreamjob.interviewscore.InterviewScoreService; |
||||
|
import com.mydreamjob.job.JobFavoriteService; |
||||
|
import com.mydreamjob.review.AdminReviewReplyService; |
||||
|
import com.mydreamjob.review.CompanyReviewManagementService; |
||||
|
import com.mydreamjob.review.CompanyReviewReplyService; |
||||
|
import com.mydreamjob.review.JobReviewService; |
||||
|
import com.mydreamjob.review.StudentReviewWeightService; |
||||
|
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.security.authentication.UsernamePasswordAuthenticationToken; |
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.authority.AuthorityUtils; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
import org.springframework.test.web.servlet.RequestBuilder; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.function.Function; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
|
||||
|
/** R01-A5:八个真实 Controller 映射必须统一经过身份与 requestId 边界。 */ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class R01A5RequestContextMigrationIntegrationTest { |
||||
|
|
||||
|
private static final ObjectMapper JSON = new ObjectMapper(); |
||||
|
private static final List<String> ILLEGAL_PRINCIPALS = List.of( |
||||
|
"anonymousUser", "a5-not-a-number", "99999999999999999999", "0", "-1"); |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
|
||||
|
@MockBean private CandidateScoringGovernanceService governanceService; |
||||
|
@MockBean private InterviewScoreService interviewScoreService; |
||||
|
@MockBean private JobFavoriteService favoriteService; |
||||
|
@MockBean private JobReviewService reviewService; |
||||
|
@MockBean private AdminReviewReplyService adminReviewReplyService; |
||||
|
@MockBean private CompanyReviewManagementService companyReviewManagementService; |
||||
|
@MockBean private CompanyReviewReplyService companyReviewReplyService; |
||||
|
@MockBean private StudentReviewWeightService studentReviewWeightService; |
||||
|
@MockBean private UserRepository userRepository; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private record Endpoint(String controller, Function<Authentication, RequestBuilder> request) { } |
||||
|
|
||||
|
private static List<Endpoint> endpoints() { |
||||
|
return List.of( |
||||
|
new Endpoint("CandidateScoringGovernanceController", |
||||
|
auth -> get("/profile/me/candidate-scoring/appeals").with(authentication(auth))), |
||||
|
new Endpoint("InterviewScoreController", |
||||
|
auth -> get("/interviews/1/score").with(authentication(auth))), |
||||
|
new Endpoint("FavoriteController", |
||||
|
auth -> get("/jobs/favorites").with(authentication(auth))), |
||||
|
new Endpoint("AdminReviewController", |
||||
|
auth -> get("/admin/reviews/pending").with(authentication(auth))), |
||||
|
new Endpoint("AdminReviewReplyController", |
||||
|
auth -> get("/admin/review-replies/pending").with(authentication(auth))), |
||||
|
new Endpoint("CompanyReviewManagementController", |
||||
|
auth -> get("/companies/me/job-reviews").with(authentication(auth))), |
||||
|
new Endpoint("JobReviewController", |
||||
|
auth -> delete("/jobs/1/reviews/1").with(authentication(auth))), |
||||
|
new Endpoint("StudentReviewWeightController", |
||||
|
auth -> get("/profile/me/review-weights").with(authentication(auth)))); |
||||
|
} |
||||
|
|
||||
|
private static Authentication principal(String name) { |
||||
|
return new UsernamePasswordAuthenticationToken(name, "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A5:8/8 真实映射对非法主体统一返回安全的 401") |
||||
|
void allEightMappingsRejectIllegalPrincipals() throws Exception { |
||||
|
assertThat(endpoints()).hasSize(8); |
||||
|
for (Endpoint endpoint : endpoints()) { |
||||
|
for (String illegal : ILLEGAL_PRINCIPALS) { |
||||
|
var response = mockMvc.perform(endpoint.request().apply(principal(illegal))) |
||||
|
.andReturn().getResponse(); |
||||
|
assertThat(response.getStatus()) |
||||
|
.as("%s principal=%s", endpoint.controller(), illegal).isEqualTo(401); |
||||
|
JsonNode body = JSON.readTree(response.getContentAsString()); |
||||
|
assertThat(body.path("code").asInt()).isEqualTo(401); |
||||
|
assertThat(body.path("message").asText()) |
||||
|
.doesNotContain("Exception", "java.", "principal", "NumberFormatException", |
||||
|
"a5-not-a-number", "99999999999999999999"); |
||||
|
assertThat(body.has("stackTrace")).isFalse(); |
||||
|
assertThat(body.has("trace")).isFalse(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A5:8/8 真实映射的 requestId 响应头与响应体同源") |
||||
|
void allEightMappingsUseNormalizedRequestId() throws Exception { |
||||
|
Authentication legal = principal("4242"); |
||||
|
for (Endpoint endpoint : endpoints()) { |
||||
|
RequestBuilder request = endpoint.request().apply(legal); |
||||
|
if (request instanceof org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder builder) { |
||||
|
builder.header("X-Request-Id", "a5/<unsafe value>" + "x".repeat(80)); |
||||
|
} |
||||
|
var response = mockMvc.perform(request).andReturn().getResponse(); |
||||
|
assertThat(response.getStatus()).as(endpoint.controller()).isNotEqualTo(401); |
||||
|
String requestId = response.getHeader("X-Request-Id"); |
||||
|
assertThat(requestId).isNotBlank().hasSizeLessThanOrEqualTo(32) |
||||
|
.doesNotContain("<", ">", " ", "\r", "\n"); |
||||
|
assertThat(response.getContentAsString()).contains("\"requestId\":\"" + requestId + "\""); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue