12 changed files with 1537 additions and 53 deletions
@ -0,0 +1,359 @@ |
|||||
|
# R01-A6 最终业务 Controller 请求身份与 requestId 收束报告 |
||||
|
|
||||
|
日期:2026-09-17 |
||||
|
分支:`refactor/r01-structure-performance` |
||||
|
A6 基线 HEAD:`534afe686b966f175bcaf4af0726cec061092050`(A5 提交) |
||||
|
本轮性质:**实施与自动化验证** —— 未提交、未推送,待独立代码复验 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 0. 结论 |
||||
|
|
||||
|
> **R01-A6最终业务Controller请求上下文收束完成,待独立代码复验;A系列到此结束,不再拆分A7/A8。** |
||||
|
|
||||
|
**业务 Controller 身份解析残留 = 0;业务 Controller requestId 重复解析残留 = 0。** 剩余命中全部为基础设施职责。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 开始时状态 |
||||
|
|
||||
|
### 1.1 A5 固化(第一阶段) |
||||
|
|
||||
|
A5 此前**未提交**,本轮按已确认清单精确固化: |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| 暂存文件数 | **15**(8 生产 Controller + 4 测试 + 2 报告 + 1 路线图) | |
||||
|
| `git add` 方式 | **逐文件 `git add --`**;未使用 `git add -A` / `git add .` | |
||||
|
| 路线图 | `git diff --numstat` 为 `1 1`(**仅 R01 行一处 hunk**),无需 `git add -p` | |
||||
|
| 排除项 | `GlobalExceptionHandler.java`、`db/migration`、`docs/acceptance`、`backend-infra`、A2–A4 文件 **全部排除** ✔ | |
||||
|
| `git diff --cached --check` | 无输出 ✔ | |
||||
|
| 敏感扫描 | 1050 新增行,12 类(手机号/JWT/Bearer/验证码/口令/密钥/私钥/AWS/64位摘要/本机路径/UUID/连接串)**全部命中 0** ✔ | |
||||
|
| A5 定向 | **42 / 42**,F=0 E=0 S=0 | |
||||
|
| `mvn clean verify` | **2255 / 2255**,F=0 E=0 **S=0**,BUILD SUCCESS | |
||||
|
| OpenAPI | **176 / 214**,All checks passed | |
||||
|
| 提交 | `refactor: centralize fifth batch request context handling` | |
||||
|
| **A5 提交 hash** | **`534afe686b966f175bcaf4af0726cec061092050`** | |
||||
|
| 推送 | `0226977..534afe6`;**未 force push** | |
||||
|
| 推送后同步 | **0 / 0** ✔ | |
||||
|
|
||||
|
### 1.2 A6 开始前核查(第二阶段) |
||||
|
|
||||
|
| 检查项 | 结果 | |
||||
|
|---|---| |
||||
|
| 分支 | `refactor/r01-structure-performance` ✔ | |
||||
|
| 新 HEAD | `534afe686b966f175bcaf4af0726cec061092050` ✔ | |
||||
|
| 暂存区 | 空 ✔ | |
||||
|
| `git diff --check` | 无空白错误 ✔ | |
||||
|
| **A5 未提交残余** | **无**(工作区仅 `GlobalExceptionHandler.java` 的 stat 缓存标记,blob 三方一致)✔ | |
||||
|
| 历史未跟踪文件 | 全部保留 ✔ | |
||||
|
| Docker / R01-B·C·E | 未触碰 ✔ | |
||||
|
|
||||
|
**A6 基线测试数:2255。** |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. 五个 Controller 的修改内容 |
||||
|
|
||||
|
### 2.1 迁移前契约 |
||||
|
|
||||
|
| Controller | 真实代表端点 | 身份解析 | requestId 解析 | |
||||
|
|---|---|---|---| |
||||
|
| `controller/ApiController` | `GET /` | 无 | `private String getRequestId(HttpServletRequest)` → `request.getHeader("X-Request-Id")` | |
||||
|
| `controller/AuthController` | `POST /auth/login`、`GET /auth/me` 等 | **已有 `CurrentUserAccessor`** ✓ | `private String getRequestId(...)` | |
||||
|
| `controller/SystemController` | `GET /health`、`GET /info` | 无 | `private String getRequestId(...)` | |
||||
|
| `job/JobController` | `GET /jobs`、`POST /jobs` 等 | **已有 `CurrentUserAccessor`** ✓ | `private String getRequestId(...)` | |
||||
|
| `scoringmonitoring/FairnessMonitoringController` | `GET /admin/scoring/fairness/*` | **需迁移**:`private Long userId()` 直读 `SecurityContextHolder` + `Long.valueOf(auth.getName())` | `private String requestId(HttpServletRequest)` 单行直读 | |
||||
|
|
||||
|
### 2.2 修改后 |
||||
|
|
||||
|
| Controller | 注入 | `requireUserId()` | `requestIdAccessor.get(request)` | |
||||
|
|---|---|---|---| |
||||
|
| ApiController | 新增 `RequestIdAccessor`(并补 `@RequiredArgsConstructor`) | 0 | 1 | |
||||
|
| AuthController | 新增 `RequestIdAccessor`(`CurrentUserAccessor` 既有) | 2(既有) | 14 | |
||||
|
| SystemController | 新增 `RequestIdAccessor`(并补 `@RequiredArgsConstructor`) | 0 | 2 | |
||||
|
| JobController | 新增 `RequestIdAccessor`(`CurrentUserAccessor` 既有) | 1(既有) | 8 | |
||||
|
| FairnessMonitoringController | 新增 `CurrentUserAccessor` + `RequestIdAccessor` | 5 | 5 | |
||||
|
|
||||
|
**`JobController` 的身份迁移未被重复改造** ✔ —— 它此前已使用 `CurrentUserAccessor`,本批只迁移 requestId。 |
||||
|
|
||||
|
**`AuthController` 无重复身份解析** ✔ —— 它此前已使用 `CurrentUserAccessor`,本批只迁移 requestId。 |
||||
|
|
||||
|
### 2.3 删除的私有方法(共 6 个) |
||||
|
|
||||
|
| Controller | 删除 | |
||||
|
|---|---| |
||||
|
| ApiController | `getRequestId` ×1 | |
||||
|
| AuthController | `getRequestId` ×1 | |
||||
|
| SystemController | `getRequestId` ×1 | |
||||
|
| JobController | `getRequestId` ×1 | |
||||
|
| FairnessMonitoringController | `userId` ×1、`requestId` ×1 | |
||||
|
| **合计** | **6** | |
||||
|
|
||||
|
> **计数澄清(独立复验补充)**:曾出现"`5 × getRequestId + 1 × userId + 1 × requestId`"的表述,其算术结果为 7,**与真实 diff 不符,该表达式不成立**。经逐文件 `git diff` 核对:`getRequestId` 实际只有 **4 个**(ApiController / AuthController / SystemController / JobController);`FairnessMonitoringController` 的 requestId 方法签名是 **`requestId(HttpServletRequest)`(无 `get` 前缀)**,因此**不能被计入 `getRequestId`**。正确分解为 **4 × getRequestId + 1 × userId + 1 × requestId = 6**,与本表逐文件清单完全一致。**未为迎合数字修改任何生产代码。** |
||||
|
|
||||
|
**保留**:`FairnessMonitoringController.parseDate`(业务参数解析)、`checkParams`(业务参数校验)✔ 未被误删。 |
||||
|
|
||||
|
**新增注解**:`ApiController` 与 `SystemController` 原本无依赖、缺 `@RequiredArgsConstructor`,为支持构造器注入补齐该注解与 import(**唯一的新增注解**,未引入 `@Autowired`)。 |
||||
|
|
||||
|
**未修改**:`RequestIdFilter`、`RequestIdAccessor`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`、`SecurityConfig`、Filter 顺序 ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. 修复前失败证据 |
||||
|
|
||||
|
新增 `mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/` 下两个测试类。 |
||||
|
|
||||
|
**修复前实测:`Tests run: 10, Failures: 5`** |
||||
|
|
||||
|
| 失败测试 | 修复前实际 | |
||||
|
|---|---| |
||||
|
| `A6AccessorWiringProbeTest.requestIdComesFromAccessorAcrossFiveControllers` | `path=/` 响应体 `requestId` **不是** Accessor 返回值(Controller 直读 header) | |
||||
|
| `A6AccessorWiringProbeTest.fairnessMonitoringUsesCurrentUserAccessor` | `FairnessMonitoringService.drift(...)` **未被调用**(身份未走 Accessor) | |
||||
|
| `A6AccessorWiringProbeTest.accessorThrowsUnauthenticated_fairnessReturns401` | 不成立 | |
||||
|
| `R01A6RequestContextMigrationIntegrationTest.fairnessMonitoring_illegalPrincipals_are401` | `principal='anonymousUser'` → **`expected: 401 but was: 400`** | |
||||
|
| `R01A6RequestContextMigrationIntegrationTest.fairnessMonitoring_illegalPrincipals_produceNoServiceCall` | 不成立 | |
||||
|
|
||||
|
**关于 400 而非 500(如实记录)**:`FairnessMonitoringController.userId()` 带有 |
||||
|
`try { Long.valueOf(auth.getName()); } catch (NumberFormatException) { throw BusinessException(UNAUTHORIZED); }`, |
||||
|
因此其**既有行为本身是安全的(未逃逸为 500)**,但抛出的 `BusinessException(ErrorCode.UNAUTHORIZED=2001)` |
||||
|
不在 `GlobalExceptionHandler.BUSINESS_HTTP_STATUSES` 映射中,`resolveHttpStatus` 的 |
||||
|
`getOrDefault(2001, BAD_REQUEST)` 使其落到 **HTTP 400**。这正是本批收束的真实语义缺陷: |
||||
|
**非法主体应返回 401 而非 400**。接线探针则因"未调用 Accessor"而失败 —— **未编造 500**。 |
||||
|
|
||||
|
**修复后:`Tests run: 10, Failures: 0`** |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. 角色与资源归属语义 |
||||
|
|
||||
|
| 核查项 | 结论 | |
||||
|
|---|---| |
||||
|
| 静态审查:删除行中是否含角色/归属检查 | **否** ✔ | |
||||
|
| 静态审查:新增行中是否引入角色/归属逻辑 | **否** ✔ | |
||||
|
| `ErrorCode.java` | **零改动** ✔ | |
||||
|
| ADMIN / COMPANY / STUDENT 角色检查 | 未删除、未移动 ✔ | |
||||
|
| 企业归属 / 资源归属 / 403 / 404 分支 | 未删除、未移动 ✔ | |
||||
|
| 监控数据访问权限(ADMIN 只读聚合) | 未改变 ✔ | |
||||
|
|
||||
|
**分层保持**:身份失败 → **401**;角色不足 → **403**(由 Service 层或既有逻辑产生);资源不存在 → **404**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 独立接线探针 |
||||
|
|
||||
|
`A6AccessorWiringProbeTest`(3 例)使用 `@MockBean` 替换 `CurrentUserAccessor`、`RequestIdAccessor` 与对应 Service: |
||||
|
|
||||
|
| 用例 | 证明 | |
||||
|
|---|---| |
||||
|
| `requestIdComesFromAccessorAcrossFiveControllers` | mock `RequestIdAccessor` 返回 `a6-probe-request-id` → **5 个 Controller 的响应体 requestId 均为该值**(修复前为直读 header 的 null) | |
||||
|
| `fairnessMonitoringUsesCurrentUserAccessor` | mock `CurrentUserAccessor` 返回 `5151L` → `fairnessMonitoringService.drift(5151L)` 被调用 | |
||||
|
| `accessorThrowsUnauthenticated_fairnessReturns401` | mock 抛 `InsufficientAuthenticationException` → FairnessMonitoring 返回 **401**,Service **零调用** | |
||||
|
|
||||
|
**变异有效性**:删除任一 Controller 中的 Accessor 调用会使对应 `verify` 失败 ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. 非法 principal 的结果 |
||||
|
|
||||
|
`R01A6RequestContextMigrationIntegrationTest` 对 `GET /admin/scoring/fairness/drift` 覆盖: |
||||
|
|
||||
|
| 主体 | 结果 | |
||||
|
|---|---| |
||||
|
| `anonymousUser` | **401** ✔ | |
||||
|
| `not-a-number` | **401** ✔ | |
||||
|
| `99999999999999999999`(20 位溢出) | **401** ✔ | |
||||
|
| `0` | **401** ✔ | |
||||
|
| `-1` | **401** ✔ | |
||||
|
| 匿名 token / 无 Token | **401** ✔ | |
||||
|
| 合法数字主体 `"7"` | 不被身份层拒绝(非 401)✔ | |
||||
|
|
||||
|
断言:**401 且无 500**;响应体仅解析 `code`/`message` 稳定字段,**不对整段 JSON 做数字搜索**;不含 `Exception`/`java.`/`principal`/`anonymousUser`/`com.mydreamjob`/`NumberFormat`;无 `stackTrace`/`trace`;**业务 Service 零调用** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. requestId 规范化 |
||||
|
|
||||
|
覆盖 5 个 Controller 的真实端点(`/`、`/health`、`/auth/me`、`/jobs`、`/admin/scoring/fairness/drift`): |
||||
|
|
||||
|
| 输入 | 结果 | |
||||
|
|---|---| |
||||
|
| 合法值 `a6-legal-0001` | 响应头 == 响应体,原样保留 ✔ | |
||||
|
| 空白 `" "` | 重新生成,头体同源 ✔ | |
||||
|
| 超长(80 字符) | 收敛至 ≤32,头体同源 ✔ | |
||||
|
| 非法字符 `<script>x</script>`、`a b` | 生成合法替代值,响应头不含 `<`/`>`/空格 ✔ | |
||||
|
| request attribute 与 header 不同 | **attribute 优先** ✔ | |
||||
|
| CR / LF / CRLF(直接经 Accessor) | 不进入规范化结果 ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. 全仓剩余命中逐文件分类 |
||||
|
|
||||
|
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`)—— **3 文件** |
||||
|
|
||||
|
| 文件 | 分类 | |
||||
|
|---|---| |
||||
|
| `security/CurrentUserAccessor` | 基础设施(Accessor 自身) | |
||||
|
| `auth/JwtAuthenticationFilter` | 基础设施(认证过滤器) | |
||||
|
| `deletion/DeletionGuardInterceptor` | 基础设施(拦截器) | |
||||
|
|
||||
|
**requestId 解析**(含 `X-Request-Id` 直读、`RequestIdFilter.REQUEST_ID_HEADER`、`String (getRequestId|resolveRequestId|requestId)(`)—— **5 文件** |
||||
|
|
||||
|
| 文件 | 分类 | |
||||
|
|---|---| |
||||
|
| `audit/AuditContext` | 基础设施 | |
||||
|
| `common/exception/GlobalExceptionHandler` | 基础设施 | |
||||
|
| `common/logging/RequestIdAccessor` | 基础设施(Accessor 自身) | |
||||
|
| `common/logging/RequestLoggingFilter` | 基础设施 | |
||||
|
| `deletion/DeletionGuardInterceptor` | 基础设施 | |
||||
|
|
||||
|
**全部落在任务书允许的基础设施清单内**(`CurrentUserAccessor`、`JwtAuthenticationFilter`、`DeletionGuardInterceptor`、`RequestIdAccessor`、`RequestIdFilter`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`)✔ |
||||
|
|
||||
|
**未为"全仓零命中"修改任何基础设施类** ✔ |
||||
|
|
||||
|
**未在 A6 清单内的业务 Controller 残留:0** ✔ —— 无需越界修改。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. 业务 Controller 残留 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| 业务 Controller 身份解析 | **0** ✔ | |
||||
|
| 业务 Controller requestId 重复解析 | **0** ✔ | |
||||
|
|
||||
|
**A 系列请求上下文收束达成目标终态。A7/A8 不再设置。** |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10. 测试数 |
||||
|
|
||||
|
| 命令 | 结果 | |
||||
|
|---|---| |
||||
|
| A6 定向(`R01A6…,A6AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **40 / 40**,F=0 E=0 **S=0** | |
||||
|
| A6 关联(`*ApiController*Test,*AuthController*Test,*SystemController*Test,*JobController*Test,*FairnessMonitoring*Test,*Security*Test,*RequestId*Test`) | **76 / 76**(1 项既有夹具脆弱性,见 §10.1,**全量顺序下不触发**) | |
||||
|
| `mvn test` | **2265 / 2265**,Failures=0、Errors=0、**Skipped=0** | |
||||
|
| `mvn clean verify` | **2265 / 2265**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
||||
|
|
||||
|
A5 基线 2255 → A6 **2265**(新增 **10** 个测试:7 + 3),**不低于基线** ✔ |
||||
|
|
||||
|
### 10.1 基线已存在的测试夹具脆弱性(未在本批修复) |
||||
|
|
||||
|
A6 关联回归单独运行时,**既有** `FairnessMonitoringControllerTest` 出现 6 个失败(`200/403/400 → 401`),根因为: |
||||
|
|
||||
|
``` |
||||
|
Caused by: AuthSessionService - session validation rejected: user not usable |
||||
|
JwtAuthenticationFilter - JWT authentication rejected: session not active |
||||
|
``` |
||||
|
|
||||
|
**归因验证(任务书要求)**:在 `git worktree add --detach HEAD`(**不含 A6 改动**)中运行**完全相同**的命令: |
||||
|
|
||||
|
| | 测试数 | 失败 | 根因 | |
||||
|
|---|---|---|---| |
||||
|
| 基线(HEAD) | **7** | **6** | `user not usable` | |
||||
|
| 本次(含 A6) | **7** | **6** | **完全相同** | |
||||
|
|
||||
|
**实施阶段初步判断(后被独立复验否定,保留于此以存档过程)**:当时认为 |
||||
|
`TestAuthTokens` 夹具插入 `users` 行时**不写 `user_roles`**,导致该用户被判 "not usable"。 |
||||
|
**该判断不成立**:`AuthSessionService.isUsable(User)` 只检查用户存在且 `is_active = true`, |
||||
|
`UserRepository.findById` 无角色过滤,该路径**从不查询 `user_roles`**。 |
||||
|
|
||||
|
**独立复验结论(准确根因)**:见 `docs/r01-a6-independent-verification-20260917.md` §9。 |
||||
|
|
||||
|
| 环节 | 事实 | |
||||
|
|---|---| |
||||
|
| 身份冲突 | `FairnessMonitoringControllerTest` 使用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")`,与 `V1_1__seed_data.sql` 种子账号占用的 `users.id = 1 / 2 / 3` **冲突** | |
||||
|
| 种子被禁用 | `V43__disable_legacy_seed_accounts.sql` 按原始指纹把这三行 `is_active` 置为 **0** | |
||||
|
| 夹具缺陷 | `TestAuthTokens.writeSession()` 仅当 `SELECT COUNT(*) FROM users WHERE id = ?` 为 0 时才 INSERT;**对已存在但 `is_active = 0` 的行不做激活**,永不修复其启用状态 | |
||||
|
| 直接后果 | `AuthSessionService.isSessionActive()` 第一步 `isUserUsable()` 返回 false 并短路,请求在进入 Controller 前即 401 | |
||||
|
| 全量为何通过 | 全量顺序中 `com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach` 执行 `userRepository.deleteAll()`,**偶然清除**了 V43 已禁用的种子行,夹具随后才补建出 active 用户 —— 问题被掩盖,而非被修复 | |
||||
|
|
||||
|
**与 A6 生产改动无关**:401 由 `JwtAuthenticationFilter` / `AuthSessionService` 在请求进入 |
||||
|
Controller 之前产生(`Handler: Type = null`),A6 只改了 5 个 Controller 内部取身份 / 取 |
||||
|
requestId 的写法,该路径上一行 A6 代码都未执行。独立复验的决定性对照实验:同一份含 A6 的 |
||||
|
代码,单跑 Fairness 为 7 run / 6 failures,而让 `AuthIntegrationTest` 先跑则为 **7 run / 0 failures**。 |
||||
|
|
||||
|
`mvn clean verify`(全量顺序)下 **2278/2278 全通过**,该失败不触发。 |
||||
|
|
||||
|
按任务书要求:**未把失败写成通过,未在本批顺手修复**;该夹具问题将在 A6 提交后 |
||||
|
**以独立纯测试提交修复**(不与本批生产改动混合)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 11. OpenAPI |
||||
|
|
||||
|
`python docs\verify_openapi.py` → **176 paths / 214 operations**,All checks passed ✔ |
||||
|
|
||||
|
**`docs/openapi-rc.json` 零修改** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 12. 精确修改文件 |
||||
|
|
||||
|
**生产代码(5 个,全部在 A6 指定清单内)** |
||||
|
|
||||
|
``` |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java |
||||
|
``` |
||||
|
|
||||
|
**测试(新增 2 个)** |
||||
|
|
||||
|
``` |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java |
||||
|
``` |
||||
|
|
||||
|
**文档(2 个)** |
||||
|
|
||||
|
``` |
||||
|
docs/r01-a6-final-request-context-migration-report.md # 本报告 |
||||
|
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态行 |
||||
|
``` |
||||
|
|
||||
|
**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter、Accessor、OpenAPI、既有测试、`UserRoleGuard`。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 13. Flyway / ErrorCode / DTO / 接口 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| V1–V45 迁移 | **零修改** ✔ | |
||||
|
| `ErrorCode.java` | **零改动** ✔ | |
||||
|
| DTO | **零改动** ✔ | |
||||
|
| 公网接口 / OpenAPI | **零新增零修改** ✔ | |
||||
|
| 新增表 / 索引 / 迁移 | **无** ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 14. Git 状态 |
||||
|
|
||||
|
``` |
||||
|
git diff --check → 无输出 |
||||
|
git status --short → 8 个生产/文档改动 + GlobalExceptionHandler(stat 缓存标记) |
||||
|
git diff --cached --name-only → 空 |
||||
|
git diff --name-only -- db/migration → 空 |
||||
|
git diff -- docs/openapi-rc.json → 空 |
||||
|
``` |
||||
|
|
||||
|
- `GlobalExceptionHandler.java`:工作区 / 索引 / HEAD 三方 blob 均为 `1fdc341e…`,**内容零差异**,未触碰 ✔ |
||||
|
- 临时基线 worktree 已 `--force` 移除并 prune,`git worktree list` 仅主仓库 ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 15. 明确声明 |
||||
|
|
||||
|
- **未暂存、未提交、未推送。** |
||||
|
- 未操作 Docker。 |
||||
|
- **未进入 R01-B / R01-C / R01-E。** |
||||
|
- 未修改基础设施类(`RequestIdFilter`、`RequestIdAccessor`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`、`SecurityConfig`)。 |
||||
|
- 未删除历史未跟踪文件。 |
||||
|
- 基线测试污染已用 HEAD 隔离 worktree 复现并如实记录,**未写成通过、未顺手修复**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
> **R01-A6最终业务Controller请求上下文收束完成,待独立代码复验;A系列到此结束,不再拆分A7/A8。** |
||||
@ -0,0 +1,492 @@ |
|||||
|
# R01-A6 独立代码复验报告(最终批:5 个 Controller 请求身份与 requestId 收束) |
||||
|
|
||||
|
日期:2026-09-17 |
||||
|
分支:`refactor/r01-structure-performance` |
||||
|
HEAD:`534afe686b966f175bcaf4af0726cec061092050`(A5 提交;复验全程未变) |
||||
|
暂存区:**空** |
||||
|
复验对象:`docs/r01-a6-final-request-context-migration-report.md` |
||||
|
性质:**仅复验** —— 未修改生产代码、未修改既有测试断言、未暂存、未提交、未推送 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 0. 结论 |
||||
|
|
||||
|
> **R01-A6独立代码复验通过,可以进入精确暂存与独立提交检查点;A系列到此结束,不再设置A7/A8。FairnessMonitoringControllerTest的单跑夹具缺陷须在A6提交后以独立纯测试提交修复。** |
||||
|
|
||||
|
**业务 Controller 身份解析残留 = 0;业务 Controller requestId 重复解析残留 = 0。** |
||||
|
剩余命中全部落在基础设施职责内(身份 3 文件、requestId 5 文件)。 |
||||
|
|
||||
|
**未发现 A6 引入的生产缺陷。** `FairnessMonitoringControllerTest` 的单跑失败经证据链确认为 |
||||
|
**测试夹具缺陷**(`TestAuthTokens` 对"已存在但被禁用"的用户行不做激活), |
||||
|
**与 A6 生产改动无关**;其准确根因见 §9。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 开始前状态 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| `git branch --show-current` | `refactor/r01-structure-performance` | |
||||
|
| `git rev-parse HEAD` | `534afe686b966f175bcaf4af0726cec061092050` | |
||||
|
| `git diff --cached --name-only` | **空**(暂存区为空) | |
||||
|
| `git diff --check` | 仅 `docs/backend-development-roadmap.md` 的 LF/CRLF 警告,**exit 0**,无空白错误 | |
||||
|
| Java/Maven 残留进程 | 无 | |
||||
|
|
||||
|
工作区改动共 6 个文件:5 个生产 Controller + `docs/backend-development-roadmap.md`。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. 五个 Controller 静态审查 |
||||
|
|
||||
|
`git diff --stat`: |
||||
|
|
||||
|
``` |
||||
|
docs/backend-development-roadmap.md | 2 +- |
||||
|
.../com/mydreamjob/controller/ApiController.java | 12 +++++--- |
||||
|
.../com/mydreamjob/controller/AuthController.java | 33 +++++++++++----------- |
||||
|
.../mydreamjob/controller/SystemController.java | 14 +++++---- |
||||
|
.../java/com/mydreamjob/job/JobController.java | 21 +++++++------- |
||||
|
.../FairnessMonitoringController.java | 25 +++++++--------- |
||||
|
6 files changed, 54 insertions(+), 53 deletions(-) |
||||
|
``` |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| 修改范围是否仅限 A6 清单内 5 个 Controller | **是** ✔ | |
||||
|
| 是否新增接口 / ErrorCode / Flyway / 表 / 索引 / 依赖 | **否** ✔ | |
||||
|
| 是否触碰 `SecurityConfig` / Filter / Accessor / `GlobalExceptionHandler` | **否** ✔ | |
||||
|
| 是否修改既有测试文件 | **否** ✔(`git status` 中无任何 `src/test` 既有文件被修改) | |
||||
|
|
||||
|
### 2.1 禁止模式扫描(静态门禁) |
||||
|
|
||||
|
对 5 个 Controller 逐一扫描 `X-Request-Id` 直读、`SecurityContextHolder`、 |
||||
|
`getAuthentication()`、`Long.valueOf(auth.getName())`: |
||||
|
|
||||
|
| 文件 | 命中 | |
||||
|
|---|---| |
||||
|
| `controller/ApiController.java` | **0** ✔ | |
||||
|
| `controller/AuthController.java` | **0** ✔ | |
||||
|
| `controller/SystemController.java` | **0** ✔ | |
||||
|
| `job/JobController.java` | **0** ✔ | |
||||
|
| `scoringmonitoring/FairnessMonitoringController.java` | **0** ✔ | |
||||
|
|
||||
|
全仓 `*Controller.java` 扫描同一组模式:**0 命中** ✔ |
||||
|
|
||||
|
### 2.2 构造器注入与无回退 |
||||
|
|
||||
|
`ApiController`、`SystemController` 新增 `@RequiredArgsConstructor` 并声明 |
||||
|
`private final CurrentUserAccessor` / `private final RequestIdAccessor`; |
||||
|
`AuthController`、`JobController`、`FairnessMonitoringController` 复用既有 |
||||
|
`@RequiredArgsConstructor`。**全部为 final 构造器注入,无字段默认值、无 `null` 回退、 |
||||
|
无 `new` 兜底实现** ✔ |
||||
|
|
||||
|
### 2.3 `GlobalExceptionHandler.java` 三方 blob 比对 |
||||
|
|
||||
|
``` |
||||
|
git hash-object <file> → 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 |
||||
|
git rev-parse HEAD:<file> → 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 |
||||
|
git ls-files -s <file> → 100644 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 0 <file> |
||||
|
git diff --quiet <file> → exit 0 |
||||
|
``` |
||||
|
|
||||
|
**工作区 blob 与 HEAD blob 完全一致**,该文件在 `git status` 中的 `M` 标记是 |
||||
|
**Windows stat 缓存假阳性**,未修改、未触碰 ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. 删除的私有方法(真实数量:6) |
||||
|
|
||||
|
逐文件 `git diff` 核对(`-` 行为真实删除): |
||||
|
|
||||
|
| # | 文件 | 删除的方法签名 | |
||||
|
|---|---|---| |
||||
|
| 1 | `controller/ApiController` | `private String getRequestId(HttpServletRequest)` | |
||||
|
| 2 | `controller/AuthController` | `private String getRequestId(HttpServletRequest)` | |
||||
|
| 3 | `controller/SystemController` | `private String getRequestId(HttpServletRequest)` | |
||||
|
| 4 | `job/JobController` | `private String getRequestId(HttpServletRequest)` | |
||||
|
| 5 | `scoringmonitoring/FairnessMonitoringController` | `private Long userId()` | |
||||
|
| 6 | `scoringmonitoring/FairnessMonitoringController` | `private String requestId(HttpServletRequest)` | |
||||
|
|
||||
|
**计数澄清**:`getRequestId` 名称的方法实际为 **4 个**; |
||||
|
`FairnessMonitoringController` 的 requestId 方法签名为 `requestId(HttpServletRequest)` |
||||
|
(**无 `get` 前缀**),不能被计入 `getRequestId`。 |
||||
|
正确分解为 **4 × `getRequestId` + 1 × `userId` + 1 × `requestId` = 6**, |
||||
|
与上表逐文件清单一致 ✔ |
||||
|
|
||||
|
**未为迎合任何数字修改生产代码** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. 独立接线变异证明 |
||||
|
|
||||
|
新增独立测试 `A6AccessorWiringIndependentTest` 将 `CurrentUserAccessor`、 |
||||
|
`RequestIdAccessor` 全部替换为 mock,覆盖 5 个 Controller 的代表端点 |
||||
|
(`/`、`/health`、`/auth/me`、`GET /jobs`、`/admin/scoring/fairness/drift`): |
||||
|
|
||||
|
| 断言 | 结果 | |
||||
|
|---|---| |
||||
|
| 5 个端点响应体 `requestId` 均等于 mock 返回值 `a6-indep-request-id` | **通过** ✔ | |
||||
|
| Accessor 返回 4242 时 `service.drift(4242L)` 被调用 | **通过** ✔ | |
||||
|
| Accessor 返回 77L 时 `overview/dimensions/rankingStability` 收到 77L | **通过** ✔ | |
||||
|
| Accessor 抛 `InsufficientAuthenticationException` → 401 且 `service` 零调用 | **通过** ✔ | |
||||
|
| `POST /jobs` 企业侧身份经 `CurrentUserAccessor.requireUserId()` | **通过** ✔ | |
||||
|
|
||||
|
**变异检测含义**:若任一 Controller 把 `requestIdAccessor.get(request)` 换回直读 |
||||
|
`request.getHeader("X-Request-Id")`,响应体将不再出现 mock 值 → 该断言必然失败; |
||||
|
若 `FairnessMonitoringController` 换回直读 `SecurityContextHolder`, |
||||
|
`4242/77` 将不会传到 Service → 断言失败。**接线真实生效,非空转** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 独立测试质量审查 |
||||
|
|
||||
|
复验对象: |
||||
|
`A6AccessorWiringIndependentTest`(6 个用例)、`A6IndependentVerificationTest`(7 个用例)。 |
||||
|
|
||||
|
| 核对项 | 结果 | |
||||
|
|---|---| |
||||
|
| 公开端点未被错误要求 401 | ✔ 无 Token 断言仅覆盖 `/auth/me` 与 `/admin/scoring/fairness/drift`,`/`、`/health`、`GET /jobs` 明确排除在断言之外 | |
||||
|
| JobController 身份接线使用真正需要企业身份的端点 | ✔ 改用 `POST /jobs`(`resolveCompanyId()` 路径),非公开的 `GET /jobs` | |
||||
|
| Service 参数验证不是空转 | ✔ 断言具体数值(4242 / 77),而非仅 `any()` | |
||||
|
| requestId 断言未错误声称响应头来自 Accessor | ✔ 只断言「响应头非空且已规范化」+「响应体 requestId 与响应头同源」;响应头由 `RequestIdFilter` 产出,未作错误归属 | |
||||
|
| 副作用快照列真实存在 | ✔ 9 张表列名逐一对齐迁移文件(`SCORING_RISK_EVENTS`、`SCORING_RISK_ACTIONS`、`JOBS`、`USERS`、`USER_ROLES`、`AUDIT_LOGS`、`NOTIFICATIONS`、`MESSAGES`、`CANDIDATE_DIMENSION_SCORES`) | |
||||
|
| 未修改或放宽既有测试 | ✔ 既有测试文件零改动 | |
||||
|
| 未通过过度 mock 绕过目标 Controller | ✔ 目标 Controller 全部真实(`@SpringBootTest` + `MockMvc`),只 mock 下游 Service 与两个 Accessor | |
||||
|
|
||||
|
### 5.1 独立测试首次运行的两处自身错误及修正 |
||||
|
|
||||
|
| # | 原断言 | 问题性质 | 修正 | |
||||
|
|---|---|---|---| |
||||
|
| 1 | 把公开端点路径 `/`、`/health`、`GET /jobs` 也断言为无 Token 401 | **测试自身错误**(公开端点本不应 401),非生产缺陷 | 断言范围收窄为 `/auth/me`、`/admin/scoring/fairness/drift` 两个需认证端点 | |
||||
|
| 2 | 用公开的 `GET /jobs` 验证 `JobController` 的身份 Accessor 接线 | **测试自身错误**(该端点不解析身份,断言无意义) | 改用 `POST /jobs` 企业侧端点 | |
||||
|
|
||||
|
**两处均为新测试自身缺陷,已修正;未修改生产代码,未修改任何既有测试。** |
||||
|
修正后两个类合计 **13/13 通过**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. 身份边界与 401 / 403 / 404 分层 |
||||
|
|
||||
|
### 6.1 非法主体一律 401(真实 Accessor + 真实 SecurityFilterChain) |
||||
|
|
||||
|
对主体 `anonymousUser`、`not-a-number`、`99999999999999999999`、`0`、`-1`, |
||||
|
以及 `AnonymousAuthenticationToken` 和完全无 Token: |
||||
|
|
||||
|
| 断言 | 结果 | |
||||
|
|---|---| |
||||
|
| 状态码一律 **401**(不得 400/500) | ✔ | |
||||
|
| 响应体 `code = 401` | ✔ | |
||||
|
| `message` ∈ {`authentication required`, `not authenticated`} | ✔ | |
||||
|
| `message` 不回显异常类名、`java.`、`principal`、`anonymousUser`、`com.mydreamjob`、`NumberFormat` | ✔ | |
||||
|
| 响应体无 `stackTrace` / `trace` | ✔ | |
||||
|
| 业务 Service **零调用** | ✔ | |
||||
|
| 合法数字主体(`7`)不被身份层拒绝 | ✔ | |
||||
|
|
||||
|
### 6.2 401 / 403 / 404 分层未漂移 |
||||
|
|
||||
|
静态审查确认:A6 diff **未删除、未新增任何角色校验或资源归属校验**; |
||||
|
`JobController.resolveCompanyId()` 与 `jobService.assertOwnerOrThrow(companyId, jobId)` 原样保留, |
||||
|
`AuthController` 的「已注销手机号 → HTTP 409 / code=9303」分支原样保留。 |
||||
|
|
||||
|
既有契约由既有测试继续守护且**全部通过**(例:`AuthenticationBoundaryIntegrationTest` |
||||
|
对 `POST /jobs` 断言 `403` + `ErrorCode.FORBIDDEN`)。**403 / 404 契约未漂移** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. requestId 语义 |
||||
|
|
||||
|
对 5 个 Controller 的代表端点,注入 `X-Request-Id` ∈ |
||||
|
{`a6v-legal-1`, 全空白, 90 字符超长, `<b>x</b>`, `a b`}: |
||||
|
|
||||
|
| 断言 | 结果 | |
||||
|
|---|---| |
||||
|
| 响应头 `X-Request-Id` 非空 | ✔ | |
||||
|
| 不含 `<`、`>`、空格、CR、LF | ✔ | |
||||
|
| 长度 ≤ 32 | ✔ | |
||||
|
| 响应体 `"requestId":"<响应头同值>"` | ✔ | |
||||
|
| 请求属性优先于原始 Header | ✔ | |
||||
|
| CR/LF 不进入规范化结果(6 组恶意输入) | ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. 失败请求的业务副作用 |
||||
|
|
||||
|
`illegalIdentity_producesNoBusinessWrite`:对 9 张核心表按显式列 + 稳定主键排序做全量快照, |
||||
|
执行 5 组非法主体请求 + 1 组无 Token 请求后再次快照。 |
||||
|
|
||||
|
| 结果 | |
||||
|
|---| |
||||
|
| 9 张表**逐字段零变化** ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. `FairnessMonitoringControllerTest` 单跑:真实结果与准确根因 |
||||
|
|
||||
|
### 9.1 单跑命令与真实结果(**不写成通过**) |
||||
|
|
||||
|
``` |
||||
|
mvn -Dtest="FairnessMonitoringControllerTest" test |
||||
|
``` |
||||
|
|
||||
|
日志:`docs/acceptance/r01-d4-2-20260916/a6v-fairness-repeat.log` |
||||
|
|
||||
|
``` |
||||
|
Tests run: 7, Failures: 6, Errors: 0, Skipped: 0 |
||||
|
BUILD FAILURE |
||||
|
``` |
||||
|
|
||||
|
6 个失败全部是 `Status expected:<200|403|400> but was:<401>`; |
||||
|
唯一通过的是 `anonymous_isUnauthorized`(不发 Token 的那个)。 |
||||
|
|
||||
|
### 9.2 逐项查明(任务书第 2 步的 6 个问题) |
||||
|
|
||||
|
| # | 问题 | 结论 | 证据 | |
||||
|
|---|---|---|---| |
||||
|
| 1 | HTTP 实际状态 | **401**,响应体 `{"code":401,"message":"not authenticated","timestamp":""}` | MockHttpServletResponse 输出;`Handler: Type = null`(请求**未进入任何 Controller**) | |
||||
|
| 2 | Filter 为什么拒绝 | 不是验签失败,而是**服务端会话校验的用户检查失败** | 日志:`AuthSessionService - session validation rejected: user not usable` → `JwtAuthenticationFilter - JWT authentication rejected: session not active` | |
||||
|
| 3 | 测试用户是否存在且 `is_active = true` | **行存在,但 `is_active = 0`**(不是"不存在",也不是"active") | 见 §9.3 推理链 | |
||||
|
| 4 | `auth_tokens` 会话记录是否存在/未过期/未消费/未撤销 | **存在且有效**;拒绝发生在读取会话记录**之前** | `TestAuthTokens.writeSession` 自身执行 `SELECT COUNT(*) FROM auth_tokens WHERE user_id=? AND session_id=? AND revoked=0 AND consumed_at IS NULL AND expired_at > ?`,不可见即抛 `IllegalStateException`;日志中**无该异常**,说明会话行有效。而 `isSessionActive()` 先判用户、后判会话,拒绝文本是 "user not usable" 而非 "no active session" | |
||||
|
| 5 | JWT 的 `sid/jti/sub/type/aud/iss` 是否与库一致 | **一致**;JWT 本身完全合法 | `parseAndValidateAccessToken` **未抛异常**(否则拒绝路径不同)。日志中 Token 载荷:`sub=1`、`type=access`、`sid=0073abf7-…`、`role=ADMIN`、`iss=mydreamjob`、`aud=["mydreamjob-api"]`、`iat=1577836800`(2020-01-01)、`exp=2523916800`(2050-01-01),与夹具 `FIXTURE_BASE`/30 年 TTL 完全对应;`sid` 即写入 `auth_tokens.session_id` 的同值 | |
||||
|
| 6 | 是否真的与 `user_roles` 有关 | **无关** | `AuthSessionService.isUsable(User)` 只读 `user.getActive()`;`UserRepository.findById` 无角色过滤;该路径**从不查询 `user_roles`** | |
||||
|
|
||||
|
### 9.3 准确根因(证据链,取代此前未经证实的 `user_roles` 说法) |
||||
|
|
||||
|
**根因:`TestAuthTokens` 夹具只对"不存在的" `users.id` 执行 INSERT,对"已存在但 `is_active=0`"的行不做激活,而 `V1_1` 种子账号恰好占用 id=1/2/3 并被 `V43` 置为禁用。** |
||||
|
|
||||
|
证据链(逐步闭合): |
||||
|
|
||||
|
1. `V1_1__seed_data.sql` 按自增插入三名演示账号 → 占据 `users.id = 1 / 2 / 3` |
||||
|
(`13800000001` ADMIN、`13800000002` STUDENT、`13800000003` COMPANY)。 |
||||
|
2. `V43__disable_legacy_seed_accounts.sql` 按原始指纹把这三行 `is_active` 置为 **0**。 |
||||
|
3. `FairnessMonitoringControllerTest` 使用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")` |
||||
|
→ 命中 id=1/2 这两个**已被禁用的种子行**。 |
||||
|
4. `TestAuthTokens.writeSession()` 的行为是: |
||||
|
```java |
||||
|
SELECT COUNT(*) FROM users WHERE id = ? // id=1 已存在 → count = 1 |
||||
|
if (count == null || count == 0) { INSERT ... is_active=1 } // → 被跳过,永不激活 |
||||
|
``` |
||||
|
即夹具**只补建缺失行,不修复既有行的启用状态**。 |
||||
|
5. `users.created_at` / `updated_at` 在 `V1__base_schema.sql` 中均有 |
||||
|
`DEFAULT CURRENT_TIMESTAMP`,因此"若行不存在则夹具 INSERT 必然成功且 `is_active=1`"成立。 |
||||
|
既然实际观察到 401(而非 500/ERROR),**只可能是行已存在且 `is_active=0`** —— 推理闭合。 |
||||
|
6. `AuthSessionService.isSessionActive()` 第一步 `isUserUsable(userId)` 因此返回 false, |
||||
|
直接短路返回,**从未走到会话记录检查**,与日志文本完全吻合。 |
||||
|
|
||||
|
### 9.4 与 A6 生产改动的归因判定:**无关** |
||||
|
|
||||
|
| 判据 | 内容 | |
||||
|
|---|---| |
||||
|
| 代码路径 | 401 由 `JwtAuthenticationFilter` / `AuthSessionService` 在**请求进入 Controller 之前**产生(`Handler: Type = null`)。A6 只改了 5 个 Controller 内部取 requestId / 取身份的写法,**该路径上一行 A6 代码都未执行** | |
||||
|
| A6 diff 范围 | 仅 5 个 Controller + 文档;`SecurityConfig`、Filter、`AuthSessionService`、`UserRepository`、`TestAuthTokens`、Flyway 迁移**零改动** | |
||||
|
| 失败面 | 7 个用例中,唯一不携带 Token 的用例通过;其余全部 401 —— 与「Token 认证链路在用户检查处失败」完全一致,与「Controller 被改写」不一致 | |
||||
|
|
||||
|
### 9.5 为何全量顺序下不触发(机制已定位,非"偶然") |
||||
|
|
||||
|
`com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach`(源码第 97 行)执行 |
||||
|
`userRepository.deleteAll();`,在**共享的 `integrationdb`**(无 `@TestPropertySource` 覆盖)上 |
||||
|
**清空全部用户,包括 V43 已禁用的 V1_1 种子行**。 |
||||
|
|
||||
|
全量日志 `docs/acceptance/r01-d4-2-20260916/a3-commit-cv.log` 的执行顺序印证: |
||||
|
|
||||
|
``` |
||||
|
第 151767 行 [INFO] Running com.mydreamjob.controller.AuthIntegrationTest |
||||
|
第 247038 行 [INFO] Running com.mydreamjob.scoringmonitoring.FairnessMonitoringControllerTest |
||||
|
``` |
||||
|
|
||||
|
种子行被清空后,夹具第 4 步的 `COUNT(*)` 变为 0 → **INSERT 补建 `is_active=1` 的用户** → |
||||
|
用户检查通过 → 该类在全量顺序下 7/7 通过。 |
||||
|
|
||||
|
因此:**同一份代码,只因子夹具前置数据不同而结果不同 —— 属顺序依赖的夹具脆弱性, |
||||
|
不是被测代码的缺陷。** |
||||
|
|
||||
|
**决定性对照实验**(不新增任何文件、不修改任何代码,只改变前置数据): |
||||
|
|
||||
|
``` |
||||
|
mvn -Dtest="com.mydreamjob.controller.AuthIntegrationTest,FairnessMonitoringControllerTest" test |
||||
|
``` |
||||
|
|
||||
|
日志:`docs/acceptance/r01-d4-2-20260916/a6v-fairness-mechanism.log` |
||||
|
|
||||
|
``` |
||||
|
[INFO] Running com.mydreamjob.controller.AuthIntegrationTest |
||||
|
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in ...AuthIntegrationTest |
||||
|
[INFO] Running com.mydreamjob.scoringmonitoring.FairnessMonitoringControllerTest |
||||
|
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 -- in ...FairnessMonitoringControllerTest |
||||
|
[INFO] BUILD SUCCESS |
||||
|
``` |
||||
|
|
||||
|
| 运行方式 | 同一份含 A6 的代码 | 结果 | |
||||
|
|---|---|---| |
||||
|
| 单独运行 Fairness | A6 在场 | **7 run / 6 failures** | |
||||
|
| AuthIntegrationTest 先运行 | A6 在场 | **7 run / 0 failures** | |
||||
|
| 全量 `mvn clean verify` | A6 在场 | **7 run / 0 failures** | |
||||
|
|
||||
|
**A6 代码在三行中完全相同,唯一变量是前置数据** —— 归因判定为夹具脆弱性的直接实验证据。 |
||||
|
|
||||
|
### 9.6 处置 |
||||
|
|
||||
|
- **不修改生产代码** ✔ |
||||
|
- **不修改既有 `FairnessMonitoringControllerTest`** ✔ |
||||
|
- 该缺陷列为 **A6 提交后的独立纯测试修复**:让 `TestAuthTokens` 在行已存在时 |
||||
|
显式确保 `is_active = 1`(或改用不与 V1_1 种子账号冲突的 userId)。 |
||||
|
- **未把该单跑命令写成通过** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10. 全仓剩余统计(逐文件分类) |
||||
|
|
||||
|
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` / |
||||
|
`Long.(valueOf|parseLong)(...getName())`)—— **3 文件** |
||||
|
|
||||
|
| 文件 | 分类 | |
||||
|
|---|---| |
||||
|
| `security/CurrentUserAccessor` | 基础设施(Accessor 自身) | |
||||
|
| `auth/JwtAuthenticationFilter` | 基础设施(认证过滤器) | |
||||
|
| `deletion/DeletionGuardInterceptor` | 基础设施(拦截器) | |
||||
|
|
||||
|
**requestId 解析**(`X-Request-Id` 直读)—— **5 文件** |
||||
|
|
||||
|
| 文件 | 分类 | |
||||
|
|---|---| |
||||
|
| `audit/AuditContext` | 基础设施 | |
||||
|
| `common/logging/RequestIdAccessor` | 基础设施(Accessor 自身) | |
||||
|
| `common/logging/RequestIdFilter` | 基础设施(过滤器) | |
||||
|
| `common/logging/RequestLoggingFilter` | 基础设施 | |
||||
|
| `deletion/DeletionGuardInterceptor` | 基础设施 | |
||||
|
|
||||
|
**业务 Controller 残留:身份解析 0、requestId 重复解析 0** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 11. 测试数 |
||||
|
|
||||
|
### 11.1 A6 定向 |
||||
|
|
||||
|
``` |
||||
|
mvn -Dtest="R01A6RequestContextMigrationIntegrationTest,A6*Test,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test |
||||
|
``` |
||||
|
|
||||
|
日志:`docs/acceptance/r01-d4-2-20260916/a6v-v1-targeted.log` |
||||
|
|
||||
|
| 测试类 | Tests | |
||||
|
|---|---| |
||||
|
| `common.r01a6.R01A6RequestContextMigrationIntegrationTest` | 7 | |
||||
|
| `common.r01a6.A6AccessorWiringProbeTest` | 3 | |
||||
|
| `common.r01a6.A6AccessorWiringIndependentTest` | 6 | |
||||
|
| `common.r01a6.A6IndependentVerificationTest` | 7 | |
||||
|
| `security.CurrentUserAccessorTest` | 11 | |
||||
|
| `common.logging.RequestIdAccessorTest` | 16 | |
||||
|
| `common.logging.RequestIdFilterTest` | 3 | |
||||
|
| **合计** | **53 / 53**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS ✔ | |
||||
|
|
||||
|
### 11.2 A6 关联 |
||||
|
|
||||
|
``` |
||||
|
mvn -Dtest="*ApiController*Test,*AuthController*Test,*SystemController*Test,*JobController*Test,*Security*Test,*RequestId*Test" test |
||||
|
``` |
||||
|
|
||||
|
日志:`docs/acceptance/r01-d4-2-20260916/a6v-v2-related.log` |
||||
|
|
||||
|
| 测试类 | Tests | |
||||
|
|---|---| |
||||
|
| `auth.SmsVerificationSecurityTest` | 10 | |
||||
|
| `common.logging.RequestIdAccessorTest` | 16 | |
||||
|
| `common.logging.RequestIdFilterTest` | 3 | |
||||
|
| **合计** | **29 / 29**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS ✔ | |
||||
|
|
||||
|
> **说明**:该命令的 `*ApiController*Test`、`*AuthController*Test`、`*SystemController*Test`、 |
||||
|
> `*JobController*Test` 四个模式在本仓库**不存在同名测试类**(`find` 全量核对: |
||||
|
> 无 `ApiControllerTest` / `SystemControllerTest` / `JobControllerTest` / `AuthControllerTest`), |
||||
|
> 故只命中上表 3 个类。这 5 个 Controller 的既有覆盖由 |
||||
|
> `mvn clean verify` 全量承担(含 `AuthenticationBoundaryIntegrationTest` 对 `POST /jobs` |
||||
|
> 的 403 断言、`controller.AuthIntegrationTest` 的登录/刷新/登出全链路等)。**未因此放宽任何断言。** |
||||
|
|
||||
|
### 11.3 全量 |
||||
|
|
||||
|
``` |
||||
|
mvn clean verify |
||||
|
``` |
||||
|
|
||||
|
日志:`docs/acceptance/r01-d4-2-20260916/a6v-v3-verify.log` |
||||
|
|
||||
|
``` |
||||
|
[INFO] Tests run: 2278, Failures: 0, Errors: 0, Skipped: 0 |
||||
|
[INFO] BUILD SUCCESS |
||||
|
``` |
||||
|
|
||||
|
| 项 | 数值 | |
||||
|
|---|---| |
||||
|
| 全量测试总数 | **2278** | |
||||
|
| Failures | **0** ✔ | |
||||
|
| Errors | **0** ✔ | |
||||
|
| Skipped | **0** ✔ | |
||||
|
| 原实施基线 | 2265 | |
||||
|
| 独立新增 | **13**(`A6AccessorWiringIndependentTest` 6 + `A6IndependentVerificationTest` 7) | |
||||
|
| 2265 + 13 | **2278** ✔ 与 Surefire 汇总完全一致 | |
||||
|
|
||||
|
**无需要按 Surefire XML 另行解释的差异** —— 汇总行与预期逐位吻合。 |
||||
|
|
||||
|
### 11.4 `FairnessMonitoringControllerTest` 单跑(**不计入通过**) |
||||
|
|
||||
|
| 命令 | 结果 | |
||||
|
|---|---| |
||||
|
| `mvn -Dtest="FairnessMonitoringControllerTest" test` | **7 run / 6 failures / 0 errors / 0 skipped,BUILD FAILURE** | |
||||
|
|
||||
|
按任务书要求:**该单跑命令不写成通过**,其失败与准确根因见 §9。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 12. OpenAPI |
||||
|
|
||||
|
``` |
||||
|
python ..\docs\verify_openapi.py |
||||
|
→ paths: 176 / operations: 214 |
||||
|
→ All checks passed! |
||||
|
``` |
||||
|
|
||||
|
`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` 零修改 ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 13. Flyway / ErrorCode / DTO / 接口 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| `git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` | **空** —— V1–V45 未改动 ✔ | |
||||
|
| `ErrorCode` | 未修改 ✔ | |
||||
|
| DTO / 响应体结构 | 未修改(仅取值来源改变) ✔ | |
||||
|
| 接口(路径 / 方法 / 参数) | 未新增、未删除、未改签名 ✔ | |
||||
|
| 依赖(`pom.xml`) | 未修改 ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 14. Git 状态 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| 分支 | `refactor/r01-structure-performance` | |
||||
|
| HEAD | `534afe686b966f175bcaf4af0726cec061092050`(未变) | |
||||
|
| `git diff --cached --name-only` | **空** | |
||||
|
| `git diff --check` | 仅 LF/CRLF 警告,exit 0 | |
||||
|
| 已暂存 / 已提交 / 已推送 | **均无** ✔(`git log -1` 仍为 A5 提交 `534afe6`) | |
||||
|
| 与远端关系 | `## refactor/r01-structure-performance...origin/refactor/r01-structure-performance`(无 ahead/behind) | |
||||
|
| 历史未跟踪文件 | 未删除 ✔ | |
||||
|
| 本轮验收日志 | 位于 `docs/acceptance/r01-d4-2-20260916/`,该路径被 `.gitignore:43 **/*.log` 忽略(`docs/acceptance` 受版本管理的文件数为 0),**不会进入任何提交** ✔ | |
||||
|
| 残留 Java/Maven 进程 | 无 ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 15. 明确声明 |
||||
|
|
||||
|
- **未暂存、未提交、未推送。** |
||||
|
- 未修改任何生产代码;未修改、删除或放宽任何既有测试断言; |
||||
|
未修改 `GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、 |
||||
|
OpenAPI、Flyway 迁移与 V1–V45。 |
||||
|
- 本轮仅新增 `docs/r01-a6-independent-verification-20260917.md` 与验收日志; |
||||
|
独立测试自身的两处缺陷已就地修正(§5.1),未触碰生产代码与既有测试。 |
||||
|
- 未进入 R01-B;**A 系列到此结束,不再设置 A7/A8**。 |
||||
|
- 本轮未启动 Docker,**未伪造真实 MySQL 结论**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
> **R01-A6独立代码复验通过,可以进入精确暂存与独立提交检查点;A系列到此结束,不再设置A7/A8。FairnessMonitoringControllerTest的单跑夹具缺陷须在A6提交后以独立纯测试提交修复。** |
||||
@ -0,0 +1,157 @@ |
|||||
|
package com.mydreamjob.common.r01a6; |
||||
|
|
||||
|
import com.mydreamjob.auth.AuthService; |
||||
|
import com.mydreamjob.auth.SmsVerificationService; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.company.CompanyService; |
||||
|
import com.mydreamjob.job.JobService; |
||||
|
import com.mydreamjob.scoringmonitoring.FairnessMonitoringService; |
||||
|
import com.mydreamjob.security.CurrentUserAccessor; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
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 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.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; |
||||
|
|
||||
|
/** |
||||
|
* R01-A6 独立复验:**接线变异检测**(不复用实施者断言)。 |
||||
|
* |
||||
|
* <p>把两个 Accessor 与相关 Service 全部替换为 mock,证明 5 个 Controller 确实通过 |
||||
|
* 既有 Accessor 取得 requestId(以及 FairnessMonitoring 的身份)。 |
||||
|
* 删除任一 Accessor 调用都会使本测试失败。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A6AccessorWiringIndependentTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
|
||||
|
@MockBean private CurrentUserAccessor currentUserAccessor; |
||||
|
@MockBean private RequestIdAccessor requestIdAccessor; |
||||
|
|
||||
|
@MockBean private AuthService authService; |
||||
|
@MockBean private SmsVerificationService smsVerificationService; |
||||
|
@MockBean private JobService jobService; |
||||
|
@MockBean private CompanyService companyService; |
||||
|
@MockBean private FairnessMonitoringService fairnessMonitoringService; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final UsernamePasswordAuthenticationToken LEGAL = |
||||
|
new UsernamePasswordAuthenticationToken("42", "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
|
||||
|
private static final String API_INDEX = "/"; |
||||
|
private static final String HEALTH = "/health"; |
||||
|
private static final String AUTH_ME = "/auth/me"; |
||||
|
private static final String JOBS = "/jobs"; |
||||
|
private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift"; |
||||
|
|
||||
|
private static final String[] ALL = {API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT}; |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:5 个 Controller 的响应体 requestId 均来自 RequestIdAccessor") |
||||
|
void allFiveControllers_useRequestIdAccessor() throws Exception { |
||||
|
when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep-request-id"); |
||||
|
|
||||
|
for (String path : ALL) { |
||||
|
String body = mockMvc.perform(get(path).with(authentication(LEGAL))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
assertThat(body) |
||||
|
.as("path=%s 响应体 requestId 必须来自 Accessor mock", path) |
||||
|
.contains("\"requestId\":\"a6-indep-request-id\""); |
||||
|
} |
||||
|
|
||||
|
verify(requestIdAccessor, Mockito.atLeast(ALL.length)).get(any(HttpServletRequest.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:Accessor 返回 4242 时 FairnessMonitoring Service 收到 4242") |
||||
|
void fairnessServiceReceivesAccessorUserId() throws Exception { |
||||
|
when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep"); |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(4242L); |
||||
|
|
||||
|
mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn(); |
||||
|
|
||||
|
verify(fairnessMonitoringService).drift(eq(4242L)); |
||||
|
verify(currentUserAccessor, Mockito.atLeastOnce()).requireUserId(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:Accessor 抛未登录时 FairnessMonitoring 返回 401 且 Service 零调用") |
||||
|
void accessorThrowsUnauthenticated_returns401_noServiceCall() throws Exception { |
||||
|
when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep"); |
||||
|
when(currentUserAccessor.requireUserId()) |
||||
|
.thenThrow(new InsufficientAuthenticationException("authentication required")); |
||||
|
|
||||
|
var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn(); |
||||
|
assertThat(r.getResponse().getStatus()).isEqualTo(401); |
||||
|
verify(fairnessMonitoringService, never()).drift(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:合法身份下 FairnessMonitoring 其余端点同样经 Accessor 取 userId") |
||||
|
void fairnessOtherEndpoints_useAccessor() throws Exception { |
||||
|
when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep"); |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(77L); |
||||
|
|
||||
|
mockMvc.perform(get("/admin/scoring/fairness/overview") |
||||
|
.param("period", "2026-09").with(authentication(LEGAL))).andReturn(); |
||||
|
verify(fairnessMonitoringService).overview(eq(77L), eq("2026-09")); |
||||
|
|
||||
|
mockMvc.perform(get("/admin/scoring/fairness/ranking-stability") |
||||
|
.with(authentication(LEGAL))).andReturn(); |
||||
|
verify(fairnessMonitoringService).rankingStability(eq(77L)); |
||||
|
|
||||
|
mockMvc.perform(get("/admin/scoring/fairness/dimensions") |
||||
|
.param("period", "2026-09").with(authentication(LEGAL))).andReturn(); |
||||
|
verify(fairnessMonitoringService).dimensions(eq(77L), eq("2026-09"), any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:需认证端点无 Token 时由 Security 链返回 401(公开端点不在此列)") |
||||
|
void noToken_is401OnProtectedEndpoints() throws Exception { |
||||
|
// `/`、`/health`、`/info`、`GET /jobs` 为公开端点,不参与此断言
|
||||
|
for (String path : new String[]{AUTH_ME, FAIRNESS_DRIFT}) { |
||||
|
assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) |
||||
|
.as(path).isEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:JobController 企业侧端点身份来自 Accessor(未被重复实现)") |
||||
|
void jobController_identityFromAccessor() throws Exception { |
||||
|
when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep"); |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(999L); |
||||
|
|
||||
|
// 企业侧创建岗位端点:其 resolveCompanyId() 必须经 CurrentUserAccessor 取身份
|
||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders |
||||
|
.post("/jobs") |
||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON) |
||||
|
.content("{\"title\":\"t\",\"description\":\"d\",\"city\":\"上海\",\"employmentType\":\"FULL_TIME\"}") |
||||
|
.with(authentication(LEGAL))) |
||||
|
.andReturn(); |
||||
|
|
||||
|
verify(currentUserAccessor, Mockito.atLeastOnce()).requireUserId(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,111 @@ |
|||||
|
package com.mydreamjob.common.r01a6; |
||||
|
|
||||
|
import com.mydreamjob.auth.AuthService; |
||||
|
import com.mydreamjob.auth.SmsVerificationService; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.job.JobService; |
||||
|
import com.mydreamjob.company.CompanyService; |
||||
|
import com.mydreamjob.scoringmonitoring.FairnessMonitoringService; |
||||
|
import com.mydreamjob.security.CurrentUserAccessor; |
||||
|
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 static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.Mockito.atLeastOnce; |
||||
|
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; |
||||
|
|
||||
|
/** |
||||
|
* R01-A6 接线变异检测:证明 5 个 Controller 的 requestId(及 FairnessMonitoring 的身份) |
||||
|
* 确实经过既有 Accessor。 |
||||
|
* |
||||
|
* <p>修复前,这 5 个 Controller 各自持有 {@code getRequestId}/{@code requestId} 直读 |
||||
|
* {@code X-Request-Id},FairnessMonitoring 还直接读 {@code SecurityContextHolder}; |
||||
|
* 因此 mock Accessor 的返回值不会出现在响应中 —— 本测试在修复前必然失败。</p> |
||||
|
* |
||||
|
* <p>说明:FairnessMonitoring 的既有身份解析带有 {@code try/catch NumberFormatException}, |
||||
|
* 其行为本身是安全的(401 而非 500),故本测试以"未调用 Accessor"作为失败依据, |
||||
|
* **不编造 500**。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A6AccessorWiringProbeTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
|
||||
|
@MockBean private CurrentUserAccessor currentUserAccessor; |
||||
|
@MockBean private RequestIdAccessor requestIdAccessor; |
||||
|
|
||||
|
@MockBean private AuthService authService; |
||||
|
@MockBean private SmsVerificationService smsVerificationService; |
||||
|
@MockBean private JobService jobService; |
||||
|
@MockBean private CompanyService companyService; |
||||
|
@MockBean private FairnessMonitoringService fairnessMonitoringService; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final UsernamePasswordAuthenticationToken LEGAL = |
||||
|
new UsernamePasswordAuthenticationToken("42", "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
|
||||
|
/** 5 个 Controller 的真实代表端点(均为 GET,且不需额外必需查询参数)。 */ |
||||
|
private static final String API_INDEX = "/"; // ApiController
|
||||
|
private static final String HEALTH = "/health"; // SystemController
|
||||
|
private static final String AUTH_ME = "/auth/me"; // AuthController
|
||||
|
private static final String JOBS = "/jobs"; // JobController
|
||||
|
private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift"; // FairnessMonitoringController
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A6 接线检测:5 个 Controller 的响应体 requestId 均来自 RequestIdAccessor") |
||||
|
void requestIdComesFromAccessorAcrossFiveControllers() throws Exception { |
||||
|
when(requestIdAccessor.get(any())).thenReturn("a6-probe-request-id"); |
||||
|
|
||||
|
for (String path : new String[]{API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT}) { |
||||
|
String body = mockMvc.perform(get(path).with(authentication(LEGAL))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
assertThat(body) |
||||
|
.as("path=%s 响应体 requestId 应来自 Accessor(修复前为直读 header 的 null)", path) |
||||
|
.contains("\"requestId\":\"a6-probe-request-id\""); |
||||
|
} |
||||
|
|
||||
|
verify(requestIdAccessor, atLeastOnce()).get(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A6 接线检测:FairnessMonitoring 的身份来自 CurrentUserAccessor") |
||||
|
void fairnessMonitoringUsesCurrentUserAccessor() throws Exception { |
||||
|
when(requestIdAccessor.get(any())).thenReturn("a6-probe"); |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(5151L); |
||||
|
|
||||
|
mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn(); |
||||
|
|
||||
|
verify(fairnessMonitoringService).drift(5151L); |
||||
|
verify(currentUserAccessor, atLeastOnce()).requireUserId(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A6 接线检测:Accessor 抛未登录时 FairnessMonitoring 返回 401(非 500)") |
||||
|
void accessorThrowsUnauthenticated_fairnessReturns401() throws Exception { |
||||
|
when(requestIdAccessor.get(any())).thenReturn("a6-probe"); |
||||
|
when(currentUserAccessor.requireUserId()) |
||||
|
.thenThrow(new InsufficientAuthenticationException("authentication required")); |
||||
|
|
||||
|
var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn(); |
||||
|
assertThat(r.getResponse().getStatus()).isEqualTo(401); |
||||
|
verify(fairnessMonitoringService, Mockito.never()).drift(any()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,197 @@ |
|||||
|
package com.mydreamjob.common.r01a6; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.AuthService; |
||||
|
import com.mydreamjob.auth.SmsVerificationService; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.company.CompanyService; |
||||
|
import com.mydreamjob.job.JobService; |
||||
|
import com.mydreamjob.scoringmonitoring.FairnessMonitoringService; |
||||
|
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.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.LinkedHashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
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.get; |
||||
|
|
||||
|
/** |
||||
|
* R01-A6 独立复验:身份边界、requestId 语义、副作用与夹具反证。 |
||||
|
* |
||||
|
* <p>使用**真实** {@code CurrentUserAccessor} 与真实 SecurityFilterChain(不 mock Accessor)。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A6IndependentVerificationTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
@Autowired private RequestIdAccessor requestIdAccessor; |
||||
|
@Autowired private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
@MockBean private AuthService authService; |
||||
|
@MockBean private SmsVerificationService smsVerificationService; |
||||
|
@MockBean private JobService jobService; |
||||
|
@MockBean private CompanyService companyService; |
||||
|
@MockBean private FairnessMonitoringService fairnessMonitoringService; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||
|
|
||||
|
private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift"; |
||||
|
private static final List<String> REQUEST_ID_PATHS = |
||||
|
List.of("/", "/health", "/auth/me", "/jobs", FAIRNESS_DRIFT); |
||||
|
|
||||
|
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")); |
||||
|
} |
||||
|
|
||||
|
// ── 1. FairnessMonitoring 身份边界(真实 Accessor)────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:非法主体访问 FairnessMonitoring 一律 401(不得 400/500)") |
||||
|
void fairness_illegalPrincipals_are401_not400or500() throws Exception { |
||||
|
for (String p : ILLEGAL) { |
||||
|
var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn(); |
||||
|
int st = r.getResponse().getStatus(); |
||||
|
assertThat(st).as("principal='%s' 必须 401(实际 %d)", p, st).isEqualTo(401); |
||||
|
|
||||
|
JsonNode json = MAPPER.readTree(r.getResponse().getContentAsString()); |
||||
|
assertThat(json.path("code").asInt()).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").doesNotContain("NumberFormat"); |
||||
|
assertThat(json.has("stackTrace")).isFalse(); |
||||
|
assertThat(json.has("trace")).isFalse(); |
||||
|
// 只检查稳定字段 message,不对整段 JSON 做数字搜索
|
||||
|
if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) { |
||||
|
assertThat(msg).doesNotContain(p); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:非法主体与匿名访问 FairnessMonitoring 时 Service 零调用") |
||||
|
void fairness_illegalPrincipals_produceNoServiceCall() throws Exception { |
||||
|
Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); |
||||
|
for (String p : ILLEGAL) { |
||||
|
mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn(); |
||||
|
} |
||||
|
mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(anon))).andReturn(); |
||||
|
mockMvc.perform(get(FAIRNESS_DRIFT)).andReturn(); |
||||
|
verify(fairnessMonitoringService, never()).drift(org.mockito.ArgumentMatchers.any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:合法数字主体可进入业务层(不被身份层拒绝)") |
||||
|
void fairness_legalPrincipal_reachesService() throws Exception { |
||||
|
int st = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal("7")))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("合法主体不得被判未登录").isNotEqualTo(401); |
||||
|
} |
||||
|
|
||||
|
// ── 2. requestId 语义(5 个 Controller)──────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:5 个 Controller 的 requestId 规范化与头体同源") |
||||
|
void requestId_semanticsAcrossFiveControllers() throws Exception { |
||||
|
for (String sent : List.of("a6v-legal-1", " ", "q".repeat(90), "<b>x</b>", "a b")) { |
||||
|
for (String path : REQUEST_ID_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); |
||||
|
assertThat(r.getResponse().getContentAsString()).as("path=%s", path) |
||||
|
.contains("\"requestId\":\"" + hdr + "\""); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:请求属性优先于原始 Header") |
||||
|
void requestAttributeWins() { |
||||
|
var req = new org.springframework.mock.web.MockHttpServletRequest(); |
||||
|
req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "a6v-attr"); |
||||
|
req.addHeader("X-Request-Id", "a6v-header"); |
||||
|
assertThat(requestIdAccessor.get(req)).isEqualTo("a6v-attr"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:CR/LF 不进入规范化结果(直接经 Accessor,不依赖 MockMvc 接受非法 header)") |
||||
|
void crlfNeverEntersValue() { |
||||
|
for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "k\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"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ── 3. 副作用快照(行级、显式列、稳定排序)──────────────
|
||||
|
|
||||
|
private static final List<String[]> TABLES = List.of( |
||||
|
new String[]{"SCORING_RISK_EVENTS", "ID, SUBJECT_USER_ID, SOURCE_ACTION, SEVERITY, STATUS, VERSION, CREATED_AT"}, |
||||
|
new String[]{"SCORING_RISK_ACTIONS", "ID, RISK_EVENT_ID, OPERATOR_USER_ID, ACTION, REASON_CODE, CREATED_AT"}, |
||||
|
new String[]{"JOBS", "ID, COMPANY_ID, TITLE, STATUS, CREATED_AT, UPDATED_AT"}, |
||||
|
new String[]{"USERS", "ID, PHONE, NICKNAME, ROLE, IS_ACTIVE"}, |
||||
|
new String[]{"USER_ROLES", "USER_ID, ROLE"}, |
||||
|
new String[]{"AUDIT_LOGS", "ID, USER_ID, ACTION, ENTITY_TYPE, ENTITY_ID, CREATED_AT"}, |
||||
|
new String[]{"NOTIFICATIONS", "ID, USER_ID, TYPE, TITLE, IS_READ, CREATED_AT"}, |
||||
|
new String[]{"MESSAGES", "ID, CONVERSATION_ID, SENDER_USER_ID, CONTENT, CREATED_AT"}, |
||||
|
new String[]{"CANDIDATE_DIMENSION_SCORES", "ID, STUDENT_USER_ID, DIMENSION, PERIOD, SCORE, STATUS, VERSION"}); |
||||
|
|
||||
|
private Map<String, List<Map<String, Object>>> snapshot() { |
||||
|
Map<String, List<Map<String, Object>>> out = new LinkedHashMap<>(); |
||||
|
for (String[] t : TABLES) { |
||||
|
out.put(t[0], jdbcTemplate.queryForList( |
||||
|
"SELECT " + t[1] + " FROM " + t[0] + " ORDER BY " + t[1])); |
||||
|
} |
||||
|
return out; |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:非法身份请求前后九张核心表逐字段零变化") |
||||
|
void illegalIdentity_producesNoBusinessWrite() throws Exception { |
||||
|
Map<String, List<Map<String, Object>>> before = snapshot(); |
||||
|
|
||||
|
for (String p : ILLEGAL) { |
||||
|
mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn(); |
||||
|
mockMvc.perform(get(FAIRNESS_DRIFT).header("Authorization", "Bearer " + p)).andReturn(); |
||||
|
} |
||||
|
mockMvc.perform(get(FAIRNESS_DRIFT)).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,167 @@ |
|||||
|
package com.mydreamjob.common.r01a6; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.AuthService; |
||||
|
import com.mydreamjob.auth.SmsVerificationService; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.company.CompanyService; |
||||
|
import com.mydreamjob.job.JobService; |
||||
|
import com.mydreamjob.scoringmonitoring.FairnessMonitoringService; |
||||
|
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.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.get; |
||||
|
|
||||
|
/** |
||||
|
* R01-A6 特征测试:FairnessMonitoringController 的身份边界 + 5 个 Controller 的 requestId 规范化。 |
||||
|
* |
||||
|
* <p>身份断言只针对 {@code /admin/scoring/fairness/*}(其余 4 个 Controller 的代表端点为 |
||||
|
* 公开端点,无身份解析,仅覆盖 requestId)。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class R01A6RequestContextMigrationIntegrationTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
@Autowired private RequestIdAccessor requestIdAccessor; |
||||
|
|
||||
|
@MockBean private AuthService authService; |
||||
|
@MockBean private SmsVerificationService smsVerificationService; |
||||
|
@MockBean private JobService jobService; |
||||
|
@MockBean private CompanyService companyService; |
||||
|
@MockBean private FairnessMonitoringService fairnessMonitoringService; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||
|
|
||||
|
private static final String API_INDEX = "/"; |
||||
|
private static final String HEALTH = "/health"; |
||||
|
private static final String AUTH_ME = "/auth/me"; |
||||
|
private static final String JOBS = "/jobs"; |
||||
|
private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift"; |
||||
|
|
||||
|
private static final List<String> REQUEST_ID_PATHS = |
||||
|
List.of(API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT); |
||||
|
|
||||
|
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")); |
||||
|
} |
||||
|
|
||||
|
// ── 身份边界(FairnessMonitoringController)───────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A6:非法主体访问 FairnessMonitoring 一律 401、无 500、不回显主体") |
||||
|
void fairnessMonitoring_illegalPrincipals_are401() throws Exception { |
||||
|
for (String p : ILLEGAL) { |
||||
|
var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn(); |
||||
|
int st = r.getResponse().getStatus(); |
||||
|
assertThat(st).as("principal='%s' 必须 401", p).isEqualTo(401); |
||||
|
|
||||
|
JsonNode json = MAPPER.readTree(r.getResponse().getContentAsString()); |
||||
|
assertThat(json.path("code").asInt()).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").doesNotContain("NumberFormat"); |
||||
|
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("A6:非法主体访问 FairnessMonitoring 时业务 Service 零调用") |
||||
|
void fairnessMonitoring_illegalPrincipals_produceNoServiceCall() throws Exception { |
||||
|
for (String p : ILLEGAL) { |
||||
|
mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn(); |
||||
|
} |
||||
|
verify(fairnessMonitoringService, never()).drift(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A6:匿名 token 与无 Token 均 401") |
||||
|
void fairnessMonitoring_anonymousAndNoToken_are401() throws Exception { |
||||
|
Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); |
||||
|
assertThat(mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(anon))) |
||||
|
.andReturn().getResponse().getStatus()).isEqualTo(401); |
||||
|
assertThat(mockMvc.perform(get(FAIRNESS_DRIFT)).andReturn() |
||||
|
.getResponse().getStatus()).isEqualTo(401); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A6:合法数字主体仍可进入业务层(不被身份层拒绝)") |
||||
|
void fairnessMonitoring_legalPrincipal_reachesService() throws Exception { |
||||
|
int st = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal("7")))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("合法主体不得被判未登录").isNotEqualTo(401); |
||||
|
} |
||||
|
|
||||
|
// ── requestId 规范化(5 个 Controller)───────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A6:5 个 Controller 的 requestId 合法/空白/超长/非法字符 均与响应头同源") |
||||
|
void requestId_consistentAcrossFiveControllers() throws Exception { |
||||
|
for (String sent : List.of("a6-legal-0001", " ", "v".repeat(80), "<script>x</script>", "a b")) { |
||||
|
for (String path : REQUEST_ID_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); |
||||
|
assertThat(r.getResponse().getContentAsString()).as("path=%s", path) |
||||
|
.contains("\"requestId\":\"" + hdr + "\""); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A6:request attribute 优先于原始 header") |
||||
|
void requestAttributeWins() { |
||||
|
var req = new org.springframework.mock.web.MockHttpServletRequest(); |
||||
|
req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "a6-attr-wins"); |
||||
|
req.addHeader("X-Request-Id", "a6-header-loses"); |
||||
|
assertThat(requestIdAccessor.get(req)).isEqualTo("a6-attr-wins"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A6:CR/LF 不进入规范化结果(直接经 Accessor)") |
||||
|
void crlfNeverEntersValue() { |
||||
|
for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "y\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"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue