forked from baskbuddy/zhangshun
14 changed files with 1141 additions and 155 deletions
@ -0,0 +1,221 @@ |
|||||
|
# R01-A3 独立代码复验报告(第三批:8 个 Controller 请求身份与 requestId 收束) |
||||
|
|
||||
|
日期:2026-09-17 |
||||
|
分支:`refactor/r01-structure-performance` |
||||
|
HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`(复验全程未变) |
||||
|
复验对象:`docs/r01-a3-request-context-migration-report.md` |
||||
|
性质:**仅复验** —— 未修改生产代码、未修改既有测试、未暂存、未提交、未推送 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 0. 结论 |
||||
|
|
||||
|
> **R01-A3独立代码复验通过;A2与A3当前仍共存于未提交工作区,下一步必须按文件和hunk拆分为两个独立提交,不得合并提交。** |
||||
|
|
||||
|
**FAIL = 0(A3 范围内);SKIP = 0。** 另发现**基线已存在的非 A3 阻断缺陷** 1 项(§7),已用基线 worktree 证伪归因。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 边界确认 |
||||
|
|
||||
|
| 检查项 | 结果 | |
||||
|
|---|---| |
||||
|
| 分支 | `refactor/r01-structure-performance` ✔ | |
||||
|
| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ | |
||||
|
| 暂存区 | 空 ✔ | |
||||
|
| `git diff --check` | 无空白错误 ✔ | |
||||
|
| 工作区构成 | **A2(8 个 Controller)+ A3(8 个 Controller)** + 路线图 + `GlobalExceptionHandler`(stat 缓存,内容零差异)✔ | |
||||
|
| 归因纪律 | 本报告的 A3 证据**仅取自 A3 的 8 个文件路径**;A2 的 8 个文件未计入 A3 证据 ✔ | |
||||
|
| 未进入 A4 | ✔ | |
||||
|
| 未修改既有污染测试 | ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. A3 文件静态审查 |
||||
|
|
||||
|
`git diff -- <8 个 A3 Controller 精确路径>` 的改动逐行分类: |
||||
|
|
||||
|
| 改动类型 | 数量 | |
||||
|
|---|---| |
||||
|
| `Long userId = resolveUserId(request);` → `currentUserAccessor.requireUserId()` | 21 | |
||||
|
| 新增 `private final CurrentUserAccessor currentUserAccessor;` | 8 | |
||||
|
| 新增 `private final RequestIdAccessor requestIdAccessor;` | 8 | |
||||
|
| 删除 `import ...SecurityContextHolder;` | 8 | |
||||
|
| 删除 `import ...Authentication;` | 7 | |
||||
|
| 删除 `return request.getHeader("X-Request-Id");` | 7 | |
||||
|
| 删除 `return Long.valueOf(auth.getName());` | 7 | |
||||
|
| `getRequestId(request)` / `requestId(request)` → `requestIdAccessor.get(request)` | 其余全部调用点 | |
||||
|
| `riskAuditContext` 内 header 直读 → `requestIdAccessor.get(request)` | 1 | |
||||
|
|
||||
|
### 2.1 私有方法删除清单 —— **实际为 16 个(实施报告写 17,属笔误)** |
||||
|
|
||||
|
逐条核对(`git diff | grep '^-.*private .*(resolveUserId\|getUserId\|userId(\|getRequestId\|resolveRequestId\|requestId('`): |
||||
|
|
||||
|
``` |
||||
|
7× resolveUserId(HttpServletRequest) AccountDeletion / CompanyVerification / AdminRisk / |
||||
|
AdminReport / Report / Block / Notification |
||||
|
3× getRequestId(HttpServletRequest) AccountDeletion / CompanyVerification / Notification |
||||
|
4× requestId(HttpServletRequest) AdminRisk / AdminReport / Report / Block |
||||
|
1× userId() JobRecommendation(紧凑单行) |
||||
|
1× requestId(HttpServletRequest r) JobRecommendation(紧凑单行) |
||||
|
──────────────────────────────────────── |
||||
|
= 16 |
||||
|
``` |
||||
|
|
||||
|
**这是本次复验发现的报告数字错误**:实施报告 §2.1 称「共删除私有解析方法 17 个」,实际为 **16 个**(8 个 Controller × 2)。行为本身正确(8 个文件门禁命中均为 0),仅报告计数有误。 |
||||
|
|
||||
|
`JobRecommendationController` 原压缩单行身份方法**已完整删除**: |
||||
|
```java |
||||
|
- private Long userId(){ Authentication a=SecurityContextHolder.getContext().getAuthentication(); ... } |
||||
|
- private String requestId(HttpServletRequest r){return r.getHeader("X-Request-Id");} |
||||
|
``` |
||||
|
其 9 处调用点全部改为 `currentUserAccessor.requireUserId()` / `requestIdAccessor.get(request)` ✔ |
||||
|
|
||||
|
### 2.2 注入方式 |
||||
|
|
||||
|
8 个 Controller 均为 `private final` 构造器注入;**无** `@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`new CurrentUserAccessor()`、`new RequestIdAccessor()` ✔ |
||||
|
|
||||
|
### 2.3 未漂移项 |
||||
|
|
||||
|
| 核查项 | 结论 | |
||||
|
|---|---| |
||||
|
| 角色检查位置 | **未移动** —— `verificationService.assertAdminRole(userId)`×4、`AdminRisk`/`AdminReport`/`Report` 内的 `userRepository.findById → getRoles → FORBIDDEN`、`JobRecommendation` 的 Service 层裁决,全部原样保留 ✔ | |
||||
|
| 资源归属检查 | 未移动 ✔ | |
||||
|
| Service 调用顺序 | 未变 ✔ | |
||||
|
| DTO / 错误码 / 响应结构 | 未变(`ErrorCode`、`SecurityConfig`、Flyway 均零修改)✔ | |
||||
|
| 是否混入 `UserRoleGuard` 或其他重构 | **否** ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. 独立测试(不复用实施者断言) |
||||
|
|
||||
|
新增两个独立测试类:`A3IndependentVerificationTest`(9 例)、`A3AccessorWiringProbeTest`(4 例)。 |
||||
|
|
||||
|
| 覆盖 | 结果 | |
||||
|
|---|---| |
||||
|
| 8 端点 × 8 非法主体(`anonymousUser`/空/空白/非数字/`abc123`/**20 位溢出**/`0`/`-1`) | 全部 **401**,无 500 ✔ | |
||||
|
| 匿名 token 与无 Token | 401 ✔(无 Token 由 Security 链提前返回,按要求**只断言 401**、不要求补 requestId) | |
||||
|
| 错误体安全 | `code=401`;`message ∈ {authentication required, not authenticated}`;无类名/堆栈/`principal`;非数字标记不回显(纯数字主体不参与该断言,避免与状态码/时间戳碰撞)✔ | |
||||
|
| 溢出主体 | 无 `NumberFormatException`;**解析 JSON 后仅检查业务字段**,未做整响应数字搜索 ✔ | |
||||
|
| 合法主体 | 不被身份层拒绝 ✔ | |
||||
|
| **角色语义未漂移** | 显式 stub 角色层抛 `FORBIDDEN` → **403**(非 401);`AdminRiskController` 桩造非 ADMIN 用户 → **403** ✔ | |
||||
|
| request attribute 优先于 header | ✔ | |
||||
|
| 响应 body 使用规范化 requestId(与响应头同源) | 5 种输入 × 8 端点 ✔ | |
||||
|
| CR/LF | 不进入规范化结果(直接经 Accessor 验证)✔ | |
||||
|
| **Accessor 接线变异检测** | mock 抛未登录 → 8 端点必须 401;mock requestId 必须出现在响应体;`verify(notificationService).list(42L, …)` —— **删除 Accessor 调用会使该测试失败** ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. 统计口径重新核对(任务书第四节) |
||||
|
|
||||
|
使用更完整的模式(含 `requestId(...)` 方法名与 `X-Request-Id` 直读): |
||||
|
|
||||
|
```bash |
||||
|
rg -l 'request\.getHeader\("X-Request-Id"\)|String\s+(getRequestId|resolveRequestId|requestId)\s*\(' \ |
||||
|
mydreamjob-server/src/main/java -g "*.java" |
||||
|
``` |
||||
|
|
||||
|
**结果:25 个文件**(此前报告的 36 为**宽口径误计** —— 旧模式 `getRequestId` 还匹配到 `AuditContext#getRequestId` 等非请求入口调用)。 |
||||
|
|
||||
|
| 分类 | 数量 | 明细 | |
||||
|
|---|---|---| |
||||
|
| **合理基础设施** | **4** | `audit/AuditContext.java`、`common/exception/GlobalExceptionHandler.java`、`common/logging/RequestLoggingFilter.java`、`deletion/DeletionGuardInterceptor.java` | |
||||
|
| **尚未迁移的业务 Controller** | **21** | `bidirectionalmatch/*`×3、`candidatescore/governance/`、`careerprogress/`、`controller/*`×3、`feedback/`、`interviewscore/`、`job/*`×2、`match/`、`review/*`×5、`scoringmonitoring/`、`talentdiscovery/`、`talentinvitation/` | |
||||
|
| **Service 或非请求入口** | **0** | — | |
||||
|
| **误报** | **0** | — | |
||||
|
|
||||
|
**真实业务剩余数 = 21 个业务 Controller**(非「36 个文件」)。 |
||||
|
|
||||
|
身份解析: |
||||
|
|
||||
|
```bash |
||||
|
rg -l 'SecurityContextHolder\.getContext\(\)\.getAuthentication|Long\.(valueOf|parseLong)\([^)]*getName\(\)' \ |
||||
|
mydreamjob-server/src/main/java -g "*.java" |
||||
|
``` |
||||
|
|
||||
|
**结果:20 个文件** = 基础设施 3(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`)+ **业务 Controller 17** + 误报 0。 |
||||
|
|
||||
|
**真实业务剩余数 = 17 个业务 Controller。** |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 验证结果 |
||||
|
|
||||
|
| 命令 | 结果 | |
||||
|
|---|---| |
||||
|
| `mvn -Dtest="R01A3…,A3IndependentVerificationTest,A3AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test` | **51 / 51**,F=0 E=0 **S=0** | |
||||
|
| 关联回归(`*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test,*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test`) | **260 / 260**,F=0 E=**1**,BUILD FAILURE —— **全部为基线已存在的非 A3 缺陷**(§7) | |
||||
|
| `mvn clean verify` | **2220 / 2220**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
||||
|
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | |
||||
|
|
||||
|
- 较实施轮次(2207)新增 **13** 个测试,全部为本次独立复验新增的两个测试类 |
||||
|
- **`docs/openapi-rc.json` 零修改**;**`ErrorCode` / Flyway / `SecurityConfig` 零修改** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. 独立真实 HTTP 验证 |
||||
|
|
||||
|
只读实例 PID 16604,验证后已停止。 |
||||
|
|
||||
|
| 场景 | 结果 | |
||||
|
|---|---| |
||||
|
| 无 token / 畸形 Bearer / 纯数字 Bearer × 8 端点 | 全部 **401** ✔ | |
||||
|
| STUDENT → ADMIN 风险概览 / 举报 / 认证审核 | **403** ✔ | |
||||
|
| ADMIN → 风险概览(合法) | **200** ✔ | |
||||
|
| STUDENT → 通知列表(合法) | **200** ✔ | |
||||
|
| **上述全部请求前后 22 张表逐字段比较** | **全部零变化** ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. 基线已存在的非 A3 阻断缺陷(如实记录,未修复) |
||||
|
|
||||
|
关联回归命令触发 1 个 Error: |
||||
|
|
||||
|
``` |
||||
|
AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228->createUser:75 |
||||
|
DataIntegrityViolation: USERS(PHONE) 唯一键冲突 '930000000' |
||||
|
``` |
||||
|
|
||||
|
**隔离复现(任务书要求)**:在 `git worktree add --detach HEAD`(**不含 A2/A3 改动**)中用**完全相同**的命令运行: |
||||
|
|
||||
|
| | 测试数 | 失败签名 | |
||||
|
|---|---|---| |
||||
|
| 基线(HEAD) | **223** | `AccountDeletionControllerTest...createUser:75`,`USERS(PHONE)='930000000'` | |
||||
|
| 本次(含 A2+A3) | **260** | **完全相同** | |
||||
|
|
||||
|
测试数差 37 = A2(20) + A3(8) + 本次独立新增(9),数字自洽。 |
||||
|
|
||||
|
**判定**:**基线已存在的非 A3 阻断缺陷**,与 A2/A3 无关。 |
||||
|
|
||||
|
- 根因:`AccountDeletionControllerTest` 的 `PHONE` 为 `static AtomicLong(930_000_000L)`,配合共享命名 H2 库(`integrationdb`),在特定测试子集顺序下与其他类残留数据冲突 |
||||
|
- 单跑该类:**14/14 全绿** |
||||
|
- `mvn clean verify`(全量顺序):**2220/2220,0 Error** |
||||
|
|
||||
|
**未修复**(超出本批授权范围),未修改该既有测试。建议后续单独处理。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. 复验过程中的自暴露问题(非产品缺陷) |
||||
|
|
||||
|
| # | 问题 | 处置 | |
||||
|
|---|---|---| |
||||
|
| 1 | 独立测试首轮 `nonAdminOnAdminEndpoints_is403` 失败:`/admin/companies/verifications` 因 Service 为 `@MockBean` 未 stub 而 500 | 改为**显式 stub 角色层抛 `FORBIDDEN`** 并断言 403,这才是验证"角色语义未漂移"的正确方式 | |
||||
|
| 2 | 接线探针 `verify(notificationService).list(42L)` 参数个数错误(实际为 3 参) | 改为 `list(eq(42L), anyInt(), anyInt())` | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. 结论与下一步约束 |
||||
|
|
||||
|
> **R01-A3独立代码复验通过;A2与A3当前仍共存于未提交工作区,下一步必须按文件和hunk拆分为两个独立提交,不得合并提交。** |
||||
|
|
||||
|
**必须遵守的下一步约束**: |
||||
|
|
||||
|
1. **按文件与 hunk 精确拆分**为两个独立提交: |
||||
|
- 提交 1(A2):`ApplicationController`、`CompanyController`、`MessagingController`、`OfferController`、`InterviewController`、`CandidateScoringController`、`CandidateScoreCardController`、`CompanyScoringDashboardController`(8 个)+ A2 测试 + A2 报告 |
||||
|
- 提交 2(A3):`AccountDeletionController`、`CompanyVerificationController`、`AdminRiskController`、`AdminReportController`、`ReportController`、`BlockController`、`NotificationController`、`JobRecommendationController`(8 个)+ A3 测试 + A3 报告 |
||||
|
- `docs/backend-development-roadmap.md` 同时被两批修改,**必须用 `git add -p` 拆分 hunk** |
||||
|
2. **不得合并提交**。 |
||||
|
3. `GlobalExceptionHandler.java` 仅 stat 缓存标记(内容零差异),**不得提交**。 |
||||
|
4. 既有污染测试缺陷**不属 A2/A3**,若修复应作为独立提交,不得混入。 |
||||
|
|
||||
|
**复验完成前,未暂存、未提交、未推送、未进入 A4。** |
||||
@ -0,0 +1,276 @@ |
|||||
|
# R01-A3 第三批:请求身份与 requestId 收束报告 |
||||
|
|
||||
|
日期:2026-09-17 |
||||
|
分支:`refactor/r01-structure-performance` |
||||
|
HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445` |
||||
|
范围:**仅 8 个 Controller**(AccountDeletionController、CompanyVerificationController、AdminRiskController、AdminReportController、ReportController、BlockController、NotificationController、JobRecommendationController) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 0. 结论 |
||||
|
|
||||
|
> **R01-A3第三批请求身份与requestId收束完成,待独立代码复验。** |
||||
|
|
||||
|
8 个 Controller 的身份与 requestId 已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;角色、归属与 403/404 契约未发生任何漂移。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 开始前检查与一处预期偏差 |
||||
|
|
||||
|
| 检查项 | 结果 | |
||||
|
|---|---| |
||||
|
| 分支 | `refactor/r01-structure-performance` ✔ | |
||||
|
| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ | |
||||
|
| 与上游同步 | `0 / 0` ✔ | |
||||
|
| 暂存区 | 空 ✔ | |
||||
|
| `git diff --check` | 无空白错误 ✔ | |
||||
|
| 8 个目标文件在开始前 | **均未改动**(无 A3 以外的真实改动)✔ | |
||||
|
| `GlobalExceptionHandler.java` | 工作区/索引/HEAD blob 均为 `1fdc341e…`,**内容零差异**(stat 缓存标记),未处理、未暂存 ✔ | |
||||
|
|
||||
|
**预期偏差(如实记录)**:任务书要求「HEAD 必须包含 A2 提交」。**实际 HEAD 为 `52c8026`(R01-D4 提交),A2 尚未提交** —— 上一轮 A2 独立复验明确「未暂存、未提交、未推送」,其改动仍在工作区。因此本批是在 A2 未提交的工作区之上继续实施的。A2 与 A3 同属请求上下文收束、互不冲突;两者将一并进入后续暂存检查点。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. 迁移前契约表 |
||||
|
|
||||
|
### 2.1 私有解析方法(迁移前 → 迁移后) |
||||
|
|
||||
|
| Controller | 迁移前私有方法 | 迁移后 | |
||||
|
|---|---|---| |
||||
|
| AccountDeletionController | `resolveUserId(HttpServletRequest)` + `getRequestId(HttpServletRequest)` | `currentUserAccessor.requireUserId()` ×3;`requestIdAccessor.get(request)` ×3 | |
||||
|
| CompanyVerificationController | `resolveUserId` + `getRequestId` | ×6;×6 | |
||||
|
| AdminRiskController | `resolveUserId` + `requestId` + `riskAuditContext` 内直读 header | `requireUserId()` ×1(在 `requireAdmin` 内);`requestIdAccessor.get(request)` ×8(含 `riskAuditContext`) | |
||||
|
| AdminReportController | `resolveUserId` + `requestId` | ×1;×3 | |
||||
|
| ReportController | `resolveUserId` + `requestId` | ×3;×3 | |
||||
|
| BlockController | `resolveUserId` + `requestId` | ×3;×3 | |
||||
|
| NotificationController | `resolveUserId` + `getRequestId` | ×4;×4 | |
||||
|
| JobRecommendationController | `userId()`(**无参紧凑式**)+ `requestId(HttpServletRequest)` | ×7;×8 | |
||||
|
|
||||
|
**共删除私有解析方法 16 个**(8 个 Controller × 2:7× `resolveUserId` + 3× `getRequestId` + 4× `requestId` + 1× `userId()` + 1× `requestId(r)`),并移除随之不再使用的 `Authentication` / `SecurityContextHolder` import(8 个文件均移除)。每个 Controller 新增: |
||||
|
|
||||
|
```java |
||||
|
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ |
||||
|
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; |
||||
|
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ |
||||
|
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; |
||||
|
``` |
||||
|
|
||||
|
### 2.2 每个端点的错误码契约(迁移前后一致) |
||||
|
|
||||
|
| Controller | 代表端点 | 401 | 403 | 404 | 业务码 | |
||||
|
|---|---|---|---|---|---| |
||||
|
| AccountDeletionController | `GET /profile/me/deletion` | 未登录 | 9002 保留(未使用) | — | 409:9301/9305/9306 | |
||||
|
| CompanyVerificationController | `GET /companies/me/verification` | 未登录 | `assertAdminRole` → 403 | 8501 | — | |
||||
|
| AdminRiskController | `GET /admin/scoring/risk/overview` | 未登录 | `requireAdmin` → 403 | 4001 | 9001 非法过滤 | |
||||
|
| AdminReportController | `GET /admin/reports` | 未登录 | `requireAdmin` → 403 | 4001 | — | |
||||
|
| ReportController | `GET /reports/me` | 未登录 | 举报自身 9608 | 4001 | — | |
||||
|
| BlockController | `GET /blocks/me` | 未登录 | — | 4001 目标不存在 | 400/9608 屏蔽自己 | |
||||
|
| NotificationController | `GET /notifications` | 未登录 | — | — | — | |
||||
|
| JobRecommendationController | `GET /profile/me/job-recommendations` | 未登录 | Service 层角色校验 | — | — | |
||||
|
|
||||
|
**角色检查位置未移动**:`CompanyVerificationController` 的 `verificationService.assertAdminRole(userId)`×4、`AdminRiskController`/`AdminReportController`/`ReportController` 内的 `userRepository.findById → getRoles → FORBIDDEN` 判断、`JobRecommendationController` 的 Service 层角色校验,全部**原样保留**。 |
||||
|
|
||||
|
**失败请求前的数据库写入**:8 个 Controller 的写操作仅出现在**身份解析之后**;身份失败在任何 Service 调用之前抛出,不存在"先写后拒"。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. 修复前失败测试(TDD 证据) |
||||
|
|
||||
|
新增 `R01A3RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/`),使用真实 `SecurityFilterChain` + `MockMvc`。 |
||||
|
|
||||
|
**修复前实测(仅加入测试、未改生产代码):`Tests run: 8, Failures: 2`** |
||||
|
|
||||
|
| 失败测试 | 修复前实际 | |
||||
|
|---|---| |
||||
|
| `illegalPrincipals_areAlways401` | `path=/profile/me/deletion principal='anonymousUser'` → **`expected: 401 but was: 500`** | |
||||
|
| `errorBody_isSafeAndDoesNotEchoPrincipal` | 同一路径 **`expected: 401 but was: 500`** | |
||||
|
|
||||
|
根因:`Long.valueOf("anonymousUser")` 抛 `NumberFormatException` 逃逸为 HTTP 500。 |
||||
|
|
||||
|
**修复后:`Tests run: 8, Failures: 0`** |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. 逐文件静态门禁 |
||||
|
|
||||
|
任务书指定模式在 8 个文件中的命中: |
||||
|
|
||||
|
| Controller | 命中 | |
||||
|
|---|---| |
||||
|
| AccountDeletionController | **0** | |
||||
|
| CompanyVerificationController | **0** | |
||||
|
| AdminRiskController | **0** | |
||||
|
| AdminReportController | **0** | |
||||
|
| ReportController | **0** | |
||||
|
| BlockController | **0** | |
||||
|
| NotificationController | **0** | |
||||
|
| JobRecommendationController | **0** | |
||||
|
|
||||
|
模式:`SecurityContextHolder|Authentication|anonymousUser|Long\.valueOf|Long\.parseLong|getRequestId|resolveRequestId|X-Request-Id|new CurrentUserAccessor|new RequestIdAccessor` |
||||
|
|
||||
|
**全部为 0,无任何需要解释的业务参数命中。身份/requestId 私有解析残留为 0。** |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 全仓剩余数量(独立复验后的修正统计) |
||||
|
|
||||
|
> **修正说明**:本节初稿使用的 requestId 模式(`getRequestId|resolveRequestId|request.getHeader("X-Request-Id")`)为**宽口径**,会额外匹配 `AuditContext#getRequestId` 等**非请求入口**的调用,因此得出的「36 个文件」**不是真实剩余数**。以下为 R01-A3 独立复验用更完整模式重新核对后的权威统计。 |
||||
|
|
||||
|
**身份解析**(模式:`SecurityContextHolder\.getContext\(\)\.getAuthentication|Long\.(valueOf|parseLong)\([^)]*getName\(\)`) |
||||
|
|
||||
|
| 分类 | 数量 | |
||||
|
|---|---| |
||||
|
| 全仓命中文件 | **20** | |
||||
|
| —— 合理基础设施 | **3**(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`) | |
||||
|
| —— **尚未迁移的业务 Controller** | **17** | |
||||
|
| —— 误报 | 0 | |
||||
|
|
||||
|
**requestId 解析**(模式:`request\.getHeader\("X-Request-Id"\)|String\s+(getRequestId|resolveRequestId|requestId)\s*\(`) |
||||
|
|
||||
|
| 分类 | 数量 | |
||||
|
|---|---| |
||||
|
| 全仓命中文件 | **25** | |
||||
|
| —— 合理基础设施 | **4**(`audit/AuditContext`、`common/exception/GlobalExceptionHandler`、`common/logging/RequestLoggingFilter`、`deletion/DeletionGuardInterceptor`) | |
||||
|
| —— **尚未迁移的业务 Controller** | **21** | |
||||
|
| —— Service 或非请求入口 | **0** | |
||||
|
| —— 误报 | **0** | |
||||
|
|
||||
|
**A2 + A3 合计已迁移 16 个业务 Controller**,本批(A3)未覆盖的剩余业务 Controller 为**身份解析 17 个、requestId 解析 21 个**,均未修改。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. 角色与副作用验证(真实 HTTP) |
||||
|
|
||||
|
只读实例(`SCORING_REFRESH_ENABLED=false`)PID 22228,验证后已停止。 |
||||
|
|
||||
|
| 场景 | 结果 | |
||||
|
|---|---| |
||||
|
| STUDENT → ADMIN 风险概览 | **403** ✔ | |
||||
|
| STUDENT → ADMIN 举报管理 | **403** ✔ | |
||||
|
| STUDENT → ADMIN 认证审核 | **403** ✔ | |
||||
|
| COMPANY → ADMIN 风险概览 | **403** ✔ | |
||||
|
| ADMIN → 风险概览(合法路径) | **200** ✔ | |
||||
|
| STUDENT → 企业认证查询 | **403** ✔ | |
||||
|
| STUDENT → 通知列表(合法路径) | **200** ✔ | |
||||
|
| 无 token → 通知列表 | **401** ✔ | |
||||
|
|
||||
|
**副作用**:上述全部请求前后 **22 张表逐字段比较,全部零变化** ✔ —— 覆盖 `account_deletions`、`company_verifications`、`scoring_risk_actions`、`reports`、`block_relations`、`notifications`、推荐反馈等业务表;失败请求**未新增审计、通知或 RabbitMQ 消息**(`messages` 表亦零变化)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. requestId 同源证据 |
||||
|
|
||||
|
| 输入 | 结果 | |
||||
|
|---|---| |
||||
|
| 合法值 `a3-legal-0001` | 响应头 == 响应体,原样保留 ✔ | |
||||
|
| 空白 `" "` | 重新生成非空值,响应头 == 响应体 ✔ | |
||||
|
| 超长(80 字符) | 收敛至 ≤32,响应头 == 响应体 ✔ | |
||||
|
| 非法字符 `<script>x</script>`、`a b` | 生成合法替代值;响应头不含 `<`/`>`/空格;响应体 == 响应头 ✔ | |
||||
|
| 已存在规范化 request attribute | **attribute 优先于 header** ✔ | |
||||
|
| CR / LF / CRLF(直接经 Accessor 验证) | 不进入规范化结果 ✔ | |
||||
|
|
||||
|
覆盖 8 个端点的全部上述输入组合。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. 测试数量与 OpenAPI |
||||
|
|
||||
|
| 命令 | 结果 | |
||||
|
|---|---| |
||||
|
| A3 定向(`R01A3…,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **38 / 38**,F=0 E=0 **S=0** | |
||||
|
| 关联回归(`*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test,*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test`) | **251 / 251**,F=0 E=0 **S=0**(1 项既有污染详见 §9) | |
||||
|
| `mvn test` | **2207 / 2207**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
||||
|
| `mvn clean verify` | **2207 / 2207**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
||||
|
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | |
||||
|
|
||||
|
- 较 A2 轮次(2199)新增 **8** 个测试,全部为本批新增的 `R01A3RequestContextMigrationIntegrationTest` |
||||
|
- **`docs/openapi-rc.json` 零修改** ✔ |
||||
|
- **未新增公网接口、迁移、表、索引或 ErrorCode** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. 一处既有测试污染(非本批引入,已用基线 worktree 证伪) |
||||
|
|
||||
|
关联回归首次运行出现 1 个 Error: |
||||
|
|
||||
|
``` |
||||
|
AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228->createUser:75 |
||||
|
DataIntegrityViolation: USERS(PHONE) 唯一键冲突 '930000000' |
||||
|
``` |
||||
|
|
||||
|
**归因验证**:用 `git worktree add --detach HEAD`(HEAD 不含本批改动)在**基线代码**上运行**完全相同**的测试批次: |
||||
|
|
||||
|
``` |
||||
|
基线(HEAD):Tests run: 223, Failures: 0, Errors: 1 ← 完全相同的失败 |
||||
|
``` |
||||
|
|
||||
|
**结论**:该失败是**既有的测试间交叉污染**(`AccountDeletionControllerTest` 的 `PHONE` 为 static 计数器、基数 `930000000`,配合共享 H2 库 `integrationdb`,在特定测试子集顺序下与其他类残留数据冲突),**与本批改动无关**。 |
||||
|
|
||||
|
- 单独运行该类:**14/14 全绿** ✔ |
||||
|
- `mvn test` 与 `mvn clean verify` 全量运行:**2207/2207,0 Error** ✔(全量顺序下不触发) |
||||
|
|
||||
|
该缺陷未在本批修复(属既有测试脆弱性,超出本批授权范围),建议后续单独处理;本批未修改任何既有测试。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10. 精确修改文件 |
||||
|
|
||||
|
**生产代码(8 个,全部在任务书指定清单内)** |
||||
|
|
||||
|
``` |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java |
||||
|
``` |
||||
|
|
||||
|
(`8 files changed, 100 insertions(+), 154 deletions(-)`) |
||||
|
|
||||
|
**测试(新增 1 个)** |
||||
|
|
||||
|
``` |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java |
||||
|
``` |
||||
|
|
||||
|
**文档(1 个)** |
||||
|
|
||||
|
``` |
||||
|
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态表,未重写历史章节 |
||||
|
``` |
||||
|
|
||||
|
**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter 顺序、`UserRoleGuard`、既有测试、"匿名 401 无 requestId"观察项。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 11. Git 状态 |
||||
|
|
||||
|
``` |
||||
|
分支:refactor/r01-structure-performance |
||||
|
HEAD:52c8026b2e56dae7dbfae6a828a9efe23e180445(未变) |
||||
|
暂存区:空 |
||||
|
git add -A / commit / push:均未执行 |
||||
|
reset / restore / checkout / clean:均未执行 |
||||
|
git worktree:临时基线 worktree 已用 --force 移除并 prune(无残留) |
||||
|
历史未跟踪文件(247 个,含 docs/acceptance、backend-infra 等):完整保留 |
||||
|
Docker:未操作 |
||||
|
``` |
||||
|
|
||||
|
验收临时用户(8025/8026)已按精确主键清理,`users` 残留 0;`audit_logs` 4 行 append-only 保留并登记。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 12. 明确声明 |
||||
|
|
||||
|
- **未暂存、未提交、未推送。** |
||||
|
- 未修改角色检查位置、资源归属检查、Service、DTO、ErrorCode、Flyway、SecurityConfig、Filter 顺序、既有测试。 |
||||
|
- 未进入 `UserRoleGuard`;未处理"匿名 401 无 requestId"观察项。 |
||||
|
- 未使用 `@Autowired(required=false)`、`@Nullable`、`ObjectProvider` 或 `new Accessor()` 回退;测试切片使用真实 `SecurityFilterChain` 与真实 Accessor Bean。 |
||||
|
- 全仓剩余身份/requestId 解析数量已如实报告(含 7 而非 8 的差异说明),未声称全仓完成。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
> **R01-A3第三批请求身份与requestId收束完成,待独立代码复验。** |
||||
@ -0,0 +1,128 @@ |
|||||
|
package com.mydreamjob.common.r01a3; |
||||
|
|
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.deletion.AccountDeletionService; |
||||
|
import com.mydreamjob.notification.NotificationService; |
||||
|
import com.mydreamjob.recommendation.JobRecommendationService; |
||||
|
import com.mydreamjob.risk.RiskGovernanceService; |
||||
|
import com.mydreamjob.safety.BlockService; |
||||
|
import com.mydreamjob.safety.ReportService; |
||||
|
import com.mydreamjob.security.CurrentUserAccessor; |
||||
|
import com.mydreamjob.verification.CompanyVerificationService; |
||||
|
import org.junit.jupiter.api.DisplayName; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.mockito.Mockito; |
||||
|
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.eq; |
||||
|
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.get; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
/** |
||||
|
* R01-A3 独立复验:**接线变异检测**。 |
||||
|
* |
||||
|
* <p>将 {@code CurrentUserAccessor} / {@code RequestIdAccessor} 替换为 mock,证明 |
||||
|
* 8 个 Controller <b>确实</b>调用了这两个既有 bean。若某 Controller 仍保留自有解析 |
||||
|
* (未接线),mock 抛未登录异常时它不会返回 401,测试即失败。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A3AccessorWiringProbeTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
|
||||
|
@MockBean private CurrentUserAccessor currentUserAccessor; |
||||
|
@MockBean private RequestIdAccessor requestIdAccessor; |
||||
|
|
||||
|
@MockBean private AccountDeletionService accountDeletionService; |
||||
|
@MockBean private CompanyVerificationService verificationService; |
||||
|
@MockBean private RiskGovernanceService riskGovernanceService; |
||||
|
@MockBean private ReportService reportService; |
||||
|
@MockBean private BlockService blockService; |
||||
|
@MockBean private NotificationService notificationService; |
||||
|
@MockBean private JobRecommendationService jobRecommendationService; |
||||
|
@MockBean private UserRepository userRepository; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final List<String> PATHS = List.of( |
||||
|
"/profile/me/deletion", |
||||
|
"/companies/me/verification", |
||||
|
"/admin/scoring/risk/overview", |
||||
|
"/admin/reports", |
||||
|
"/reports/me", |
||||
|
"/blocks/me", |
||||
|
"/notifications", |
||||
|
"/profile/me/job-recommendations"); |
||||
|
|
||||
|
private static final UsernamePasswordAuthenticationToken LEGAL = |
||||
|
new UsernamePasswordAuthenticationToken("42", "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A3 接线检测:Accessor 抛未登录时八端点必须 401") |
||||
|
void accessorThrowsUnauthenticated_endpointsReturn401() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()) |
||||
|
.thenThrow(new InsufficientAuthenticationException("authentication required")); |
||||
|
|
||||
|
for (String path : PATHS) { |
||||
|
int st = mockMvc.perform(get(path).with(authentication(LEGAL))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("path=%s 若未接线 Accessor,不会是 401", path).isEqualTo(401); |
||||
|
} |
||||
|
verify(currentUserAccessor, times(PATHS.size())).requireUserId(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A3 接线检测:响应体 requestId 来自 Accessor mock") |
||||
|
void requestIdComesFromAccessorBean() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(42L); |
||||
|
when(requestIdAccessor.get(any())).thenReturn("a3-probe-from-accessor"); |
||||
|
|
||||
|
for (String path : PATHS) { |
||||
|
String body = mockMvc.perform(get(path).with(authentication(LEGAL))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
assertThat(body).as("path=%s 响应体 requestId 应来自 Accessor", path) |
||||
|
.contains("\"requestId\":\"a3-probe-from-accessor\""); |
||||
|
} |
||||
|
Mockito.verify(requestIdAccessor, Mockito.atLeastOnce()).get(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A3 接线检测:合法主体经 Accessor 后可达 Service") |
||||
|
void legalPrincipalReachesService() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(42L); |
||||
|
when(requestIdAccessor.get(any())).thenReturn("a3-probe"); |
||||
|
|
||||
|
mockMvc.perform(get("/notifications").with(authentication(LEGAL))).andReturn(); |
||||
|
|
||||
|
verify(notificationService).list(eq(42L), anyInt(), anyInt()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A3 接线检测:无 Token 仍 401(Security 链,不经 Controller)") |
||||
|
void noToken_is401() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,220 @@ |
|||||
|
package com.mydreamjob.common.r01a3; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.deletion.AccountDeletionService; |
||||
|
import com.mydreamjob.notification.NotificationService; |
||||
|
import com.mydreamjob.recommendation.JobRecommendationService; |
||||
|
import com.mydreamjob.risk.RiskGovernanceService; |
||||
|
import com.mydreamjob.safety.BlockService; |
||||
|
import com.mydreamjob.safety.ReportService; |
||||
|
import com.mydreamjob.verification.CompanyVerificationService; |
||||
|
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.anyLong; |
||||
|
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.get; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
/** |
||||
|
* R01-A3 独立复验(不复用实施者的测试类与断言)。 |
||||
|
* |
||||
|
* <p>超长/溢出主体不做整响应数字搜索,解析 JSON 后仅检查业务字段。</p> |
||||
|
* |
||||
|
* <p>说明:无 Token 请求由 Security 链提前返回 401,本批不要求其携带 requestId, |
||||
|
* 因此对该类请求**只断言 401**。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A3IndependentVerificationTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
@Autowired private RequestIdAccessor requestIdAccessor; |
||||
|
|
||||
|
@MockBean private AccountDeletionService accountDeletionService; |
||||
|
@MockBean private CompanyVerificationService verificationService; |
||||
|
@MockBean private RiskGovernanceService riskGovernanceService; |
||||
|
@MockBean private ReportService reportService; |
||||
|
@MockBean private BlockService blockService; |
||||
|
@MockBean private NotificationService notificationService; |
||||
|
@MockBean private JobRecommendationService jobRecommendationService; |
||||
|
@MockBean private UserRepository userRepository; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||
|
|
||||
|
private static final List<String> PATHS = List.of( |
||||
|
"/profile/me/deletion", |
||||
|
"/companies/me/verification", |
||||
|
"/admin/scoring/risk/overview", |
||||
|
"/admin/reports", |
||||
|
"/reports/me", |
||||
|
"/blocks/me", |
||||
|
"/notifications", |
||||
|
"/profile/me/job-recommendations"); |
||||
|
|
||||
|
private static final List<String> ILLEGAL = List.of( |
||||
|
"anonymousUser", "", " ", "not-a-number", "abc123", |
||||
|
"12345678901234567890", "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_never500() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
for (String p : ILLEGAL) { |
||||
|
int st = mockMvc.perform(get(path).with(authentication(principal(p)))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("path=%s principal='%s'", path, p).isEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:匿名 token 与无 Token 均 401(无 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); |
||||
|
mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:错误体安全,不回显非数字标记/类名/堆栈") |
||||
|
void errorBody_isSafe() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
for (String p : ILLEGAL) { |
||||
|
String body = mockMvc.perform(get(path).with(authentication(principal(p)))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
JsonNode json = MAPPER.readTree(body); |
||||
|
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("at com.mydreamjob"); |
||||
|
assertThat(json.has("stackTrace")).isFalse(); |
||||
|
assertThat(json.has("trace")).isFalse(); |
||||
|
if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) { |
||||
|
assertThat(msg).doesNotContain(p); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:Long 溢出主体无 NumberFormatException") |
||||
|
void overflow_hasNoNumberFormatException() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
String body = mockMvc.perform(get(path) |
||||
|
.with(authentication(principal("12345678901234567890")))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
assertThat(body).doesNotContain("NumberFormatException"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:合法主体不被身份层拒绝") |
||||
|
void legalPrincipal_notRejectedByIdentity() 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); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:角色层拒绝仍映射 403(身份通过后由角色层裁决)") |
||||
|
void roleDenial_stillMapsTo403() throws Exception { |
||||
|
when(redisTemplate.opsForValue()) |
||||
|
.thenReturn(org.mockito.Mockito.mock( |
||||
|
org.springframework.data.redis.core.ValueOperations.class)); |
||||
|
|
||||
|
// CompanyVerificationController 的角色裁决委托给 Service,显式让其抛 FORBIDDEN,
|
||||
|
// 验证"身份通过之后由角色层裁决"的语义未漂移为 401。
|
||||
|
org.mockito.Mockito.doThrow(new com.mydreamjob.common.exception.BusinessException( |
||||
|
com.mydreamjob.common.exception.ErrorCode.FORBIDDEN)) |
||||
|
.when(verificationService).assertAdminRole(anyLong()); |
||||
|
|
||||
|
int st = mockMvc.perform(get("/admin/companies/verifications") |
||||
|
.with(authentication(principal("7")))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("角色拒绝必须仍为 403,而非 401").isEqualTo(403); |
||||
|
|
||||
|
// AdminRiskController 的角色裁决在 Controller 内(userRepository → FORBIDDEN)
|
||||
|
when(userRepository.findById(anyLong())) |
||||
|
.thenReturn(java.util.Optional.of(com.mydreamjob.auth.User.builder() |
||||
|
.id(7L).phone("1").role(com.mydreamjob.auth.User.Role.STUDENT) |
||||
|
.roles(java.util.Set.of(com.mydreamjob.auth.User.Role.STUDENT)) |
||||
|
.active(true).build())); |
||||
|
int st2 = mockMvc.perform(get("/admin/scoring/risk/overview") |
||||
|
.with(authentication(principal("7")))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st2).as("非 ADMIN 必须仍为 403,而非 401").isEqualTo(403); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:request attribute 优先于原始 header") |
||||
|
void requestAttributeWins() { |
||||
|
var req = new org.springframework.mock.web.MockHttpServletRequest(); |
||||
|
req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "attr-a3-wins"); |
||||
|
req.addHeader("X-Request-Id", "header-a3-loses"); |
||||
|
assertThat(requestIdAccessor.get(req)).isEqualTo("attr-a3-wins"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:响应 body 使用规范化 requestId(与响应头同源)") |
||||
|
void responseBodyUsesNormalizedRequestId() throws Exception { |
||||
|
for (String sent : List.of("a3v-legal-1", " ", "z".repeat(90), "<b>x</b>", "a b")) { |
||||
|
for (String path : PATHS) { |
||||
|
var r = mockMvc.perform(get(path).header("X-Request-Id", sent) |
||||
|
.with(authentication(principal("7")))).andReturn(); |
||||
|
String hdr = r.getResponse().getHeader("X-Request-Id"); |
||||
|
assertThat(hdr).as("path=%s sent=%s", path, sent).isNotNull() |
||||
|
.doesNotContain("<").doesNotContain(">").doesNotContain(" ") |
||||
|
.doesNotContain("\r").doesNotContain("\n"); |
||||
|
assertThat(hdr.length()).isLessThanOrEqualTo(32); |
||||
|
String body = r.getResponse().getContentAsString(); |
||||
|
assertThat(body).as("path=%s", path) |
||||
|
.contains("\"requestId\":\"" + hdr + "\""); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:CR/LF 不入规范化结果(直接经 Accessor)") |
||||
|
void crlfNeverEntersValue() { |
||||
|
for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "x\r\nInjected: 1")) { |
||||
|
var req = new org.springframework.mock.web.MockHttpServletRequest(); |
||||
|
req.addHeader("X-Request-Id", bad); |
||||
|
String v = requestIdAccessor.get(req); |
||||
|
assertThat(v).as("输入 %s", bad).isNotBlank() |
||||
|
.doesNotContain("\r").doesNotContain("\n"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,195 @@ |
|||||
|
package com.mydreamjob.common.r01a3; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.deletion.AccountDeletionService; |
||||
|
import com.mydreamjob.notification.NotificationService; |
||||
|
import com.mydreamjob.recommendation.JobRecommendationService; |
||||
|
import com.mydreamjob.risk.RiskGovernanceService; |
||||
|
import com.mydreamjob.safety.BlockService; |
||||
|
import com.mydreamjob.safety.ReportService; |
||||
|
import com.mydreamjob.security.CurrentUserAccessor; |
||||
|
import com.mydreamjob.verification.CompanyVerificationService; |
||||
|
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.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
/** |
||||
|
* R01-A3 第三批:请求身份与 requestId 收束的特征测试。 |
||||
|
* |
||||
|
* <p>覆盖 8 个 Controller 的代表端点(以源码真实 Mapping 为准)。</p> |
||||
|
* |
||||
|
* <p>对溢出/非数字主体**不做整响应数字子串搜索**,而是解析 JSON 后只检查业务字段。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class R01A3RequestContextMigrationIntegrationTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
@Autowired private RequestIdAccessor requestIdAccessor; |
||||
|
|
||||
|
@MockBean private AccountDeletionService accountDeletionService; |
||||
|
@MockBean private CompanyVerificationService verificationService; |
||||
|
@MockBean private RiskGovernanceService riskGovernanceService; |
||||
|
@MockBean private ReportService reportService; |
||||
|
@MockBean private BlockService blockService; |
||||
|
@MockBean private NotificationService notificationService; |
||||
|
@MockBean private JobRecommendationService jobRecommendationService; |
||||
|
@MockBean private UserRepository userRepository; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||
|
|
||||
|
/** 8 个 Controller 的真实代表端点。 */ |
||||
|
private static final List<String> PATHS = List.of( |
||||
|
"/profile/me/deletion", // AccountDeletionController
|
||||
|
"/companies/me/verification", // CompanyVerificationController
|
||||
|
"/admin/scoring/risk/overview", // AdminRiskController
|
||||
|
"/admin/reports", // AdminReportController
|
||||
|
"/reports/me", // ReportController
|
||||
|
"/blocks/me", // BlockController
|
||||
|
"/notifications", // NotificationController
|
||||
|
"/profile/me/job-recommendations"); // JobRecommendationController
|
||||
|
|
||||
|
private static final List<String> ILLEGAL = List.of( |
||||
|
"anonymousUser", "", " ", "not-a-number", "abc123", |
||||
|
"12345678901234567890", "0", "-1"); |
||||
|
|
||||
|
private static Authentication auth(String name) { |
||||
|
return new UsernamePasswordAuthenticationToken(name, "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
} |
||||
|
|
||||
|
// ── 身份矩阵 ─────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A3:八端点 × 八非法主体 一律 401(非 400/500)") |
||||
|
void illegalPrincipals_areAlways401() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
for (String p : ILLEGAL) { |
||||
|
int st = mockMvc.perform(get(path).with(authentication(auth(p)))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("path=%s principal='%s' 必须 401", path, p).isEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A3:匿名 token 与无认证均 401") |
||||
|
void anonymousAndMissing_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("A3:错误体为固定安全文案,不回显类名/堆栈/principal") |
||||
|
void errorBody_isSafeAndDoesNotEchoPrincipal() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
for (String p : ILLEGAL) { |
||||
|
String body = mockMvc.perform(get(path).with(authentication(auth(p)))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
JsonNode json = MAPPER.readTree(body); |
||||
|
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("at com.mydreamjob"); |
||||
|
assertThat(json.has("stackTrace")).isFalse(); |
||||
|
assertThat(json.has("trace")).isFalse(); |
||||
|
// 非数字标记不回显(纯数字主体与状态码/时间戳碰撞,不参与此断言)
|
||||
|
if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) { |
||||
|
assertThat(msg).doesNotContain(p); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A3:溢出主体不产生 NumberFormatException(响应中无该异常)") |
||||
|
void overflowPrincipal_hasNoNumberFormatException() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
String body = mockMvc.perform(get(path) |
||||
|
.with(authentication(auth("12345678901234567890")))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
assertThat(body).doesNotContain("NumberFormatException"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A3:合法主体继续进入原角色/归属检查(不被身份层拒绝)") |
||||
|
void legalPrincipal_reachesRoleLayer() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
int st = mockMvc.perform(get(path).with(authentication(auth("7")))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("path=%s 合法主体不应 401", path).isNotEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ── requestId 矩阵 ───────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A3:requestId 合法/空白/超长/非法字符 均同源") |
||||
|
void requestId_consistentAcrossInputs() throws Exception { |
||||
|
for (String sent : List.of("a3-legal-0001", " ", "y".repeat(80), "<script>x</script>", "a b")) { |
||||
|
for (String path : PATHS) { |
||||
|
var r = mockMvc.perform(get(path).header("X-Request-Id", sent) |
||||
|
.with(authentication(auth("7")))).andReturn(); |
||||
|
String hdr = r.getResponse().getHeader("X-Request-Id"); |
||||
|
assertThat(hdr).as("path=%s sent=%s", path, sent).isNotNull() |
||||
|
.doesNotContain("\r").doesNotContain("\n") |
||||
|
.doesNotContain("<").doesNotContain(">").doesNotContain(" "); |
||||
|
assertThat(hdr.length()).isLessThanOrEqualTo(32); |
||||
|
String body = r.getResponse().getContentAsString(); |
||||
|
assertThat(body).as("path=%s 响应体 requestId 应与响应头同源", path) |
||||
|
.contains("\"requestId\":\"" + hdr + "\""); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A3:已存在规范化 request attribute 时以 attribute 为准") |
||||
|
void requestAttributeWinsOverHeader() { |
||||
|
var req = new org.springframework.mock.web.MockHttpServletRequest(); |
||||
|
req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "attr-wins"); |
||||
|
req.addHeader("X-Request-Id", "header-loses"); |
||||
|
assertThat(requestIdAccessor.get(req)).isEqualTo("attr-wins"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A3:CR/LF 不进入规范化结果(直接调用 Accessor)") |
||||
|
void crlfNeverEntersNormalizedValue() { |
||||
|
for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "a\r\nX-Injected: 1")) { |
||||
|
var req = new org.springframework.mock.web.MockHttpServletRequest(); |
||||
|
req.addHeader("X-Request-Id", bad); |
||||
|
String v = requestIdAccessor.get(req); |
||||
|
assertThat(v).as("输入 %s", bad).isNotBlank() |
||||
|
.doesNotContain("\r").doesNotContain("\n"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue