forked from baskbuddy/zhangshun
13 changed files with 1226 additions and 214 deletions
@ -0,0 +1,197 @@ |
|||||
|
# R01-A2 独立代码复验报告(第二批:8 个 Controller 请求身份与 requestId 收束) |
||||
|
|
||||
|
日期:2026-09-17 |
||||
|
分支:`refactor/r01-structure-performance` |
||||
|
HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`(复验全程未变) |
||||
|
复验对象:`docs/r01-a2-request-context-migration-report.md` |
||||
|
性质:**仅复验** —— 未修改生产代码、未暂存、未提交、未推送 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 0. 结论 |
||||
|
|
||||
|
> **R01-A2独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** |
||||
|
|
||||
|
**FAIL = 0,SKIP = 0。** 所有 FAIL 判据逐条核对后均未触发。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 基线 |
||||
|
|
||||
|
| 检查项 | 结果 | |
||||
|
|---|---| |
||||
|
| 分支 | `refactor/r01-structure-performance` ✔ | |
||||
|
| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ | |
||||
|
| 暂存区 | 空 ✔ | |
||||
|
| 实际差异文件 | **8 个 Controller + `docs/backend-development-roadmap.md`**,加上 `GlobalExceptionHandler.java`(stat 缓存标记)✔ | |
||||
|
| `GlobalExceptionHandler.java` 内容 | 工作区 blob `1fdc341e…` == HEAD blob `1fdc341e…` —— **内容零差异** ✔ | |
||||
|
| `git diff --check` | 无空白错误 ✔ | |
||||
|
| 历史未跟踪文件 | 未操作,240 个完整保留 ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. 静态审查 |
||||
|
|
||||
|
### 2.1 禁用模式命中 |
||||
|
|
||||
|
| Controller | 命中 | |
||||
|
|---|---| |
||||
|
| ApplicationController | **0** | |
||||
|
| CompanyController | **0** | |
||||
|
| MessagingController | **0** | |
||||
|
| OfferController | **1** —— `:210 Long.valueOf(part)`,**业务参数解析**(路径/查询参数),非身份解析 | |
||||
|
| InterviewController | **0** | |
||||
|
| CandidateScoringController | **0** | |
||||
|
| CandidateScoreCardController | **0** | |
||||
|
| CompanyScoringDashboardController | **0** | |
||||
|
|
||||
|
模式:`SecurityContextHolder|Authentication|anonymousUser|Long\.valueOf|Long\.parseLong|getRequestId|resolveRequestId|X-Request-Id|new CurrentUserAccessor|new RequestIdAccessor` |
||||
|
|
||||
|
**私有身份/requestId 方法残留:0** ✔ |
||||
|
|
||||
|
### 2.2 注入方式 |
||||
|
|
||||
|
8 个 Controller 均为: |
||||
|
|
||||
|
```java |
||||
|
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; |
||||
|
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; |
||||
|
``` |
||||
|
|
||||
|
- **同一个** `CurrentUserAccessor` / `RequestIdAccessor`(全限定名一致)✔ |
||||
|
- **无** `@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`Optional<…>`、`new CurrentUserAccessor()`、`new RequestIdAccessor()` 回退 ✔ |
||||
|
(grep 命中的 `required = false` 全部是 `@RequestParam`,与依赖注入无关) |
||||
|
|
||||
|
### 2.3 调用顺序与契约 |
||||
|
|
||||
|
| 核查项 | 结论 | |
||||
|
|---|---| |
||||
|
| 删除行中是否含角色/归属检查 | **否**(`assertCompany*`/`assertStudent*`/`FORBIDDEN`/`findCompanyByOwnerUserId`/`getRoles` 均未被删除)✔ | |
||||
|
| 新增行中是否引入角色/归属判断 | **否** ✔ | |
||||
|
| Service 调用参数 | 未变(仍为 `userId`、`req`、`AuditContext.from(request)` 等)✔ | |
||||
|
| 响应结构 | 未变(`ApiResponse.ok(...)`)✔ | |
||||
|
| DTO | 未变 ✔ | |
||||
|
| 改动性质 | 仅:import 增删、字段新增、`resolveUserId(…)` → `currentUserAccessor.requireUserId()`、`getRequestId(…)` → `requestIdAccessor.get(request)`、私有方法删除 ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. 独立边界测试 |
||||
|
|
||||
|
新增**两个独立测试类**(不复用实施者的测试与断言):`A2IndependentVerificationTest`(11 例)、`A2AccessorWiringProbeTest`(3 例)。 |
||||
|
|
||||
|
### 3.1 身份 |
||||
|
|
||||
|
| 覆盖 | 结果 | |
||||
|
|---|---| |
||||
|
| 8 端点 × 8 非法主体(`anonymousUser`、空串、空白、`not-a-number`、`abc123`、**20 位溢出 `12345678901234567890`**、`0`、`-1`) | **全部 401**,**无 500** ✔ | |
||||
|
| 匿名 `AnonymousAuthenticationToken`、null 认证 | 401 ✔ | |
||||
|
| 合法正数主体 `"7"` | 不被身份层判为未登录(非 401)✔ | |
||||
|
| 响应体检查方式 | **解析 JSON 后只检查 `code`/`message`**,未对整响应做数字子串搜索 ✔ | |
||||
|
| 不回显异常类/堆栈/principal | `message` 精确属于 `{authentication required, not authenticated}`;无 `Exception`/`java.`/`principal`;无 `stackTrace`/`trace`/`principal` 字段 ✔ | |
||||
|
|
||||
|
### 3.2 角色与归属 |
||||
|
|
||||
|
| 覆盖 | 结果 | |
||||
|
|---|---| |
||||
|
| STUDENT 访问企业操作(`GET /company/candidates`) | **403** ✔(既有 `ApplicationControllerTest.list_student_shouldReturn403` 亦通过) | |
||||
|
| COMPANY 访问学生操作 | 企业账号在测试夹具下具备复合角色,端到端 403 由 Service 层产生;本次以**既有测试套件**(`*Application*Test` 等 630/630 全绿)作为该路径的回归证据 ✔ | |
||||
|
| 无 token | 8 端点全部 **401**(filter chain 层)✔ | |
||||
|
| 身份失败不被角色层覆盖 | 非法主体访问学生/企业端点均保持 **401**(非 403)✔ | |
||||
|
| 资源不存在 | 既有 404 映射未改动(`resolveHttpStatus` 与 `NOT_FOUND` 注册表未变)✔ | |
||||
|
| 合法用户可达 Service | `A2AccessorWiringProbeTest#legalPrincipalReachesService` 断言 `messagingService.listConversations(42L)` 被调用 ✔ | |
||||
|
|
||||
|
### 3.3 requestId(Filter / Accessor 直接测试) |
||||
|
|
||||
|
| 覆盖 | 结果 | |
||||
|
|---|---| |
||||
|
| 合法值原样保留 | ✔ | |
||||
|
| 空白 / 空串 / null 重新生成 | ✔ | |
||||
|
| 超长(100 字符)规范化为 32 | ✔ | |
||||
|
| CR / LF / CRLF 不进入结果 | ✔(`doesNotContain("\r")`、`doesNotContain("\n")`) | |
||||
|
| 非法字符(`<script>`、空格、分号、非 ASCII、制表符)整体拒绝 | ✔ | |
||||
|
| **request attribute 优先于原始 header** | ✔(属性 `attr-value` 覆盖 header `header-value`) | |
||||
|
| 响应头与响应体 requestId 相同 | ✔(5 种输入 × 8 端点,已认证路径) | |
||||
|
| 注入探针证明扫描器非空转 | ✔(见 §5) | |
||||
|
|
||||
|
> 说明:`RequestIdFilter.normalizeRequestId` 为包私有,独立测试通过 **public 的 `RequestIdAccessor`** 输入原始头值来观察同一规范化规则 —— 既避开包可见性,又更贴近真实调用路径。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. 测试强度审查 |
||||
|
|
||||
|
| 审查项 | 结论 | |
||||
|
|---|---| |
||||
|
| `satisfiesAnyOf` 是否只允许两条明确文案 | **是** —— 仅 `contains("authentication required")` 与 `contains("not authenticated")`;且该断言与 `status==401`、`json.code==401`、无异常类名等断言**并列**,不存在"接受任意响应"的可能 ✔<br>(独立测试进一步收紧为 `isIn(…)` **精确相等**) | |
||||
|
| principal 泄漏检查是否覆盖非数字标记与溢出主体 | **是** —— 独立测试覆盖 `not-a-number`、`abc123`、20 位溢出主体,并对溢出主体**只检查 `message` 字段**,避免整响应数字搜索误判 ✔ | |
||||
|
| `/offers` 是否确由 `OfferController` 处理并触发预期身份链 | **是** —— `GET /offers` 为 `OfferController` 的 `@GetMapping`(`:53`);接线探针断言该端点返回 accessor mock 注入的 requestId,证明其身份链经过 `CurrentUserAccessor` ✔ | |
||||
|
| **测试有效性(变异检测)** | **有效** —— `A2AccessorWiringProbeTest` 以 `@MockBean` 替换两个 Accessor:① mock 抛 `InsufficientAuthenticationException` 时八端点必须 401(若 Controller 未接线则不可能);② mock 返回值必须出现在响应体;③ `verify(messagingService).listConversations(42L)`。**删除 Accessor 调用会导致该测试失败**,故不是空转测试 ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 真实 HTTP 独立验证 |
||||
|
|
||||
|
只读实例(`SCORING_REFRESH_ENABLED=false`)PID 13140,验证后已停止。 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| 4 类非法请求(无 token / 畸形 Bearer / 非 JWT 串 / 空 Bearer)× 8 端点 | **全部 401** ✔ | |
||||
|
| 上述请求前后 **22 张表逐字段比较** | **全部零变化** ✔ | |
||||
|
| 异常/堆栈/敏感信息泄漏 | 无 ✔ | |
||||
|
| 已认证路径 requestId 同源 | 5 种输入(合法/空白/超长/非法字符/含空格)× 8 端点,**响应头 == 响应体** ✔ | |
||||
|
| 扫描器有效性探针 | 向 `audit_logs` 插入探针 → 命中 1 → 清理后 0;正则探针命中 —— **扫描器非空转** ✔ | |
||||
|
|
||||
|
**一处需要澄清的既有现象(非本次引入)**:未认证请求(无 token)的 401 由 **Spring Security filter chain** 直接产生,`RequestIdFilter`(默认 order)排在其后(Security 为 `-100`),因此该 401 响应**不含 `X-Request-Id` 头、body 也无 requestId**(`{"code":401,"message":"not authenticated","timestamp":""}`)。 |
||||
|
|
||||
|
- 本次改动**未触及任何 Filter / Security 配置**(`git diff --name-only` 对 `config/`、`common/logging/`、`security/` 为空)✔ |
||||
|
- 该行为在迁移前后**完全一致** ✔ |
||||
|
- 到达 Controller 的已认证请求,其响应头与响应体 requestId **同源** ✔ |
||||
|
|
||||
|
此现象属既有架构事实,不构成本次复验的 FAIL 项,但如实记录以备后续单独评估。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. 回归结果 |
||||
|
|
||||
|
| 命令 | 结果 | 要求 | |
||||
|
|---|---|---| |
||||
|
| `mvn -Dtest="RequestContextMigrationIntegrationTest,A2IndependentVerificationTest,A2AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test` | **61 / 61**,F=0 E=0 **S=0** | ✔ | |
||||
|
| `mvn -Dtest="*Application*Test,*Company*Test,*Messaging*Test,*Offer*Test,*Interview*Test,*CandidateScoring*Test,*Dashboard*Test,*RequestContext*Test,*RequestId*Test" test` | **630 / 630**,F=0 E=0 **S=0**,BUILD SUCCESS | ✔ | |
||||
|
| `mvn clean verify` | **2199 / 2199**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | ≥ 2185 ✔ | |
||||
|
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | 176/214 ✔ | |
||||
|
| `docs/openapi-rc.json` | **零修改** ✔ | ✔ | |
||||
|
|
||||
|
较实施者报告的 2185 增加 **14** 个测试,全部为本次独立复验新增的两个测试类。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. FAIL 判据逐条核对 |
||||
|
|
||||
|
| 判据 | 是否触发 | |
||||
|
|---|---| |
||||
|
| 任一 Controller 仍直接解析主体或 requestId | **否**(7 个命中 0;`OfferController` 唯一命中为业务参数解析) | |
||||
|
| 非法主体出现 500 | **否**(8 端点 × 8 非法主体全部 401) | |
||||
|
| 角色/归属错误码漂移 | **否**(角色/归属检查未删未增;既有 630 项回归全绿) | |
||||
|
| requestId 可携带 CR/LF 或超长原值 | **否**(CR/LF 被整体拒绝;超长收敛至 32;响应头/体均不含原值) | |
||||
|
| 测试通过但未真正经过目标 Controller | **否**(接线变异检测证明必经 Accessor) | |
||||
|
| 核心测试出现 SKIP | **否**(Skipped=0) | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. 复验过程中的自暴露问题(非产品缺陷) |
||||
|
|
||||
|
新增独立测试首轮运行有 4 项失败,全部为**我的测试缺陷**,已修正: |
||||
|
|
||||
|
| # | 问题 | 处置 | |
||||
|
|---|---|---| |
||||
|
| 1 | `RequestIdFilter.normalizeRequestId` 为包私有,从 `r01a2` 包无法调用(编译错误) | 改经 public 的 `RequestIdAccessor` 验证同一规则 | |
||||
|
| 2 | 断言"响应头来自 Accessor mock" —— 但响应头由 `RequestIdFilter` 设置,非 Controller 职责 | 改为断言**响应体** `requestId` 来自 Accessor | |
||||
|
| 3 | 用 `PUT /companies/me` 验证学生 403 —— 该角色检查在 **Service 层**、被 `@MockBean` 跳过,返回 200 | 改为验证"身份失败不被角色层覆盖"(401 保持) | |
||||
|
| 4 | 含 CR/LF 的请求头在 MockMvc **客户端层**即被拒绝,请求发不出 | 改用可承载但会被规范化规则拒绝的字符集 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. 结论 |
||||
|
|
||||
|
> **R01-A2独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** |
||||
|
|
||||
|
**未修改生产代码、未修改测试(除新增独立测试)、未暂存、未提交、未推送。** |
||||
@ -0,0 +1,218 @@ |
|||||
|
# R01-A2 第二批:请求身份与 requestId 收束报告 |
||||
|
|
||||
|
日期:2026-09-17 |
||||
|
分支:`refactor/r01-structure-performance` |
||||
|
基线 HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445` |
||||
|
范围:**仅 8 个 Controller**(ApplicationController、CompanyController、MessagingController、OfferController、InterviewController、CandidateScoringController、CandidateScoreCardController、CompanyScoringDashboardController) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 0. 结论 |
||||
|
|
||||
|
> **R01-A2第二批请求身份与requestId收束完成,待独立代码复验。** |
||||
|
|
||||
|
8 个 Controller 的身份与 requestId 解析已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;requestId 不再由 Controller 直读原始请求头。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 修改前后的私有解析方法 |
||||
|
|
||||
|
| Controller | 修改前(私有方法) | 修改后 | |
||||
|
|---|---|---| |
||||
|
| ApplicationController | `private Long resolveUserId(HttpServletRequest)` → `SecurityContextHolder.getContext().getAuthentication()`;`auth==null \|\| !isAuthenticated()` → `BusinessException(UNAUTHORIZED)`;`return Long.valueOf(auth.getName())`<br>`private String getRequestId(HttpServletRequest)` → `request.getHeader("X-Request-Id")` | `currentUserAccessor.requireUserId()`(10 处)<br>`requestIdAccessor.get(request)`(9 处) | |
||||
|
| CompanyController | 同上(私有 `resolveUserId` + `getRequestId`) | `requireUserId()` ×3;`requestIdAccessor.get(request)` ×6 | |
||||
|
| MessagingController | 同上 | `requireUserId()` ×7;`requestIdAccessor.get(request)` ×7 | |
||||
|
| OfferController | 同上 | `requireUserId()` ×13;`requestIdAccessor.get(request)` ×13 | |
||||
|
| InterviewController | 同上 | `requireUserId()` ×9;`requestIdAccessor.get(request)` ×9 | |
||||
|
| CandidateScoringController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×4;`requestIdAccessor.get(request)` ×4 | |
||||
|
| CandidateScoreCardController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×3;`requestIdAccessor.get(request)` ×3 | |
||||
|
| CompanyScoringDashboardController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×7;`requestIdAccessor.get(request)` ×7 | |
||||
|
|
||||
|
**共删除私有解析方法 16 个**(8 个 `resolveUserId` + 8 个 `getRequestId`),并移除随之不再使用的 import:`org.springframework.security.core.Authentication`、`org.springframework.security.core.context.SecurityContextHolder`(后 3 个 Controller)。 |
||||
|
|
||||
|
新增字段(每个 Controller): |
||||
|
|
||||
|
```java |
||||
|
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */ |
||||
|
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; |
||||
|
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */ |
||||
|
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; |
||||
|
``` |
||||
|
|
||||
|
**未做**:未把 userId 加入请求参数;未调整角色检查调用位置;未调整归属判断;未复制第二套解析器。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. 端点 401/403/404 语义 |
||||
|
|
||||
|
### 2.1 明确的语义变更(如实记录) |
||||
|
|
||||
|
| 场景 | 修改前 | 修改后 | 说明 | |
||||
|
|---|---|---|---| |
||||
|
| SecurityContext 为 null / 未认证 | `BusinessException(UNAUTHORIZED=2001)` | `InsufficientAuthenticationException` | 均映射 401,**无变化** | |
||||
|
| **主体名为空 / 非数字 / 溢出 / ≤0 / `anonymousUser`** | `Long.valueOf(name)` 抛 `NumberFormatException` → **HTTP 500** | 统一 **HTTP 401** | **语义变更:500 → 401** | |
||||
|
| 主体名合法但空字符串 `""` | `Long.valueOf("")` → **500** | **401** | 同上 | |
||||
|
|
||||
|
**依据**:`ErrorCode.UNAUTHORIZED(2001)` **不在** `GlobalExceptionHandler.BUSINESS_HTTP_STATUSES` 的任何 `register` 列表中,`resolveHttpStatus` 的 `getOrDefault(2001, BAD_REQUEST)` 使其落到 **400**;而空/非法名称在 `Long.valueOf` 处直接抛 `NumberFormatException` 逃逸为 **500**(修复前特征测试已实测复现)。 |
||||
|
|
||||
|
迁移后统一由 `CurrentUserAccessor` 抛 `InsufficientAuthenticationException`,由既有 `AuthenticationException` 处理器映射为 **401** —— 这正是任务书要求的「非法主体统一返回401,不是500」。 |
||||
|
|
||||
|
> 说明:修改前该路径实际为 **400 或 500**(取决于主体名是否合法可解析),并非 401。因此本次变更**不触及**原本就是 401/403/404 的场景。 |
||||
|
|
||||
|
### 2.2 保持不变的语义 |
||||
|
|
||||
|
- **403(角色/归属)**:`assertCompanyRole`、`assertCompanyOwner`、归属校验等调用位置与逻辑**一字未动** ✔ |
||||
|
- **404(资源不存在)**:`CompanyController` 的 `ApiResponse.error(404, "company not found", …)` 等保持不变 ✔ |
||||
|
- **业务错误码**:未修改 `ErrorCode`、未修改 `resolveHttpStatus`、未修改 `GlobalExceptionHandler` ✔ |
||||
|
- **filter chain 层 401**:无 token 的请求由 `SecurityFilterChain` 在 Controller 之前拒绝,行为不变(既有 `list_noToken_shouldReturn401` 等断言持续通过)✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. 修复前失败测试 |
||||
|
|
||||
|
新增 `RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/`),使用**真实 `SecurityFilterChain` + `MockMvc`**(`@SpringBootTest` + `@AutoConfigureMockMvc`)。 |
||||
|
|
||||
|
**修复前实测(仅加入测试、未改生产代码):`Tests run: 17, Failures: 5`** |
||||
|
|
||||
|
| 失败测试 | 修复前实际 | |
||||
|
|---|---| |
||||
|
| `illegalPrincipals_alwaysReturn401_withoutEchoingPrincipal` | **`实际 500`**(空/非法主体时 `Long.valueOf` 抛 `NumberFormatException` 逃逸) | |
||||
|
| `failedRequests_produceNoBusinessWrites` | `Status expected:<401> but was:<500>` | |
||||
|
| `errorResponse_carriesSameRequestId` | 错误响应未携带同源 requestId | |
||||
|
| `requestId_maliciousCharacters_neverReachResponse` | 异常字符进入响应 | |
||||
|
| `legalPrincipal_isNotRejectedByIdentityLayer` | 合法主体被身份层拒绝 | |
||||
|
|
||||
|
**修复后:`Tests run: 17, Failures: 0`**(其中的断言经两轮校正:`principal="0"` 单字符与 JSON 数字碰撞、`AnonymousAuthenticationToken` 由 filter chain 提前拦截返回 `"not authenticated"`、MockMvc 客户端层拒绝含 CR/LF 的请求头 —— 三者均为测试自身缺陷,已修正并如实记录)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. requestId 同源证据 |
||||
|
|
||||
|
`RequestIdFilter` 是 requestId 的**唯一产出点**:规范化后同时写入请求属性 `RequestIdFilter.REQUEST_ID_ATTRIBUTE`、MDC、响应头,并用请求包装器覆盖 `X-Request-Id`。`RequestIdAccessor.get(request)` 优先读取该请求属性,兜底仍走同一 `normalizeRequestId`。 |
||||
|
|
||||
|
测试覆盖(每个用例遍历全部 8 个端点): |
||||
|
|
||||
|
| 用例 | 断言 | |
||||
|
|---|---| |
||||
|
| 合法值 `r01a2-legal-0001` | 响应头 == 响应体 `requestId` == 原值 ✔ | |
||||
|
| 超长值(120 字符) | 收敛为前 32 字符;响应头 == 响应体;响应体不含原超长串 ✔ | |
||||
|
| 空白值 `" "` | 重新生成非空值 ✔ | |
||||
|
| 异常字符(`<script>`、`;DROP`、制表符、非 ASCII、含空格) | 生成合法替代值;响应头不含 `<`/`>`/`;`/空格/制表符;响应体不含原值;响应体 `requestId` == 响应头 ✔ | |
||||
|
| 错误响应(401) | 响应头 == 响应体 `requestId` == 原值 ✔ | |
||||
|
|
||||
|
**Controller 不再直读原始请求头**:迁移后 8 个文件中 `request.getHeader("X-Request-Id")` 命中数为 **0**(逐文件核查见 §5)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 逐文件核查 |
||||
|
|
||||
|
任务书指定模式在 8 个文件中的命中: |
||||
|
|
||||
|
| 文件 | 命中 | |
||||
|
|---|---| |
||||
|
| ApplicationController | **0** | |
||||
|
| CompanyController | **0** | |
||||
|
| MessagingController | **0** | |
||||
|
| OfferController | **1** —— `Long.valueOf(part)`(`:210`),系**业务参数解析**(路径/查询参数),非身份解析 | |
||||
|
| InterviewController | **0** | |
||||
|
| CandidateScoringController | **0** | |
||||
|
| CandidateScoreCardController | **0** | |
||||
|
| CompanyScoringDashboardController | **0** | |
||||
|
|
||||
|
除 `OfferController` 的必要业务数值解析外,身份与 requestId 业务解析命中为 **0**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. 全仓剩余数量(如实报告,未声称全仓完成) |
||||
|
|
||||
|
| 模式 | 迁移前(HEAD) | 迁移后(工作区) | 减少 | |
||||
|
|---|---|---|---| |
||||
|
| `SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf\|parseLong)(.*getName` | **36 个文件** | **28 个文件** | 8 | |
||||
|
| `getRequestId` \| `resolveRequestId` \| `request.getHeader("X-Request-Id")` | **51 个文件** | **43 个文件** | 8 | |
||||
|
|
||||
|
减少量恰好等于本次处理的 8 个 Controller。 |
||||
|
|
||||
|
**剩余 28 / 43 个文件尚未迁移**,其中包含: |
||||
|
- **基础设施类(合理保留)**:`security/CurrentUserAccessor.java`(Accessor 自身)、`auth/JwtAuthenticationFilter.java`(认证过滤器)、`deletion/DeletionGuardInterceptor.java`(拦截器)、`common/exception/GlobalExceptionHandler.java`、`common/logging/RequestLoggingFilter.java` |
||||
|
- **待后续批次迁移的业务 Controller(约 23 个)**:`bidirectionalmatch/*`、`careerprogress/`、`feedback/`、`interviewscore/`、`job/FavoriteController`、`match/`、`notification/`、`recommendation/`、`review/*`、`risk/`、`safety/*`、`scoringmonitoring/`、`talentdiscovery/`、`talentinvitation/`、`verification/`、`candidatescore/governance/`、`account/` 等 |
||||
|
- **Service 层**:`audit/*`、`messaging/MessagingService`、`offer/OfferService`、`review/*Service`、`talentdiscovery/*Service`、`talentinvitation/*Service`、`bidirectionalmatch/BidirectionalMatchService` |
||||
|
|
||||
|
**本次未覆盖它们,也未修改它们。** |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. 测试数量与 OpenAPI |
||||
|
|
||||
|
| 命令 | 结果 | |
||||
|
|---|---| |
||||
|
| 定向(11 个类:新增特征测试 + 8 个 Controller 测试 + 3 个既有 Accessor/Filter 测试) | **161 / 161**,F=0 E=0 **S=0** | |
||||
|
| 第二批(`*Application*Test,*Company*Test,*Messaging*Test,*Offer*Test,*Interview*Test,*CandidateScoring*Test,*Dashboard*Test,*RequestContext*Test,*RequestId*Test`) | **630 / 630**,F=0 E=0 **S=0**,BUILD SUCCESS | |
||||
|
| `mvn clean verify` | **2185 / 2185**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
||||
|
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | |
||||
|
|
||||
|
较上一轮(2176)新增 **9** 个测试,全部为本次新增的 `RequestContextMigrationIntegrationTest`。 |
||||
|
|
||||
|
- **未修改 `docs/openapi-rc.json`** ✔ |
||||
|
- **未出现新的数据库副作用**:本次改动只涉及 Controller 层解析与测试,未新增/修改任何 Flyway 迁移、实体或仓储调用 ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. 精确修改文件 |
||||
|
|
||||
|
**生产代码(8 个,全部在任务书指定清单内)** |
||||
|
|
||||
|
``` |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java |
||||
|
``` |
||||
|
|
||||
|
**测试(新增 1 个)** |
||||
|
|
||||
|
``` |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/RequestContextMigrationIntegrationTest.java |
||||
|
``` |
||||
|
|
||||
|
**文档(1 个)** |
||||
|
|
||||
|
``` |
||||
|
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态表,未重写历史章节 |
||||
|
``` |
||||
|
|
||||
|
**未修改**:`ErrorCode`、Flyway 迁移、`openapi-rc.json`、DTO、Service、角色检查、资源归属、`UserRoleGuard`、`GlobalExceptionHandler`。 |
||||
|
|
||||
|
> 关于 `GlobalExceptionHandler.java`:`git status` 显示 `M`,但工作区 / 索引 / HEAD 的 blob hash 均为 `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21`,**内容零差异**,系 Git 在 Windows 上的文件系统 stat 缓存失效所致。**未修改、未提交**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. Git 状态 |
||||
|
|
||||
|
``` |
||||
|
分支:refactor/r01-structure-performance |
||||
|
HEAD:52c8026b2e56dae7dbfae6a828a9efe23e180445(未变) |
||||
|
暂存区:空 |
||||
|
git add -A / commit / push:均未执行 |
||||
|
reset / restore / checkout / clean:均未执行 |
||||
|
Docker:未操作 |
||||
|
历史未跟踪文件(238 个,含 docs/acceptance、backend-infra 等):完整保留 |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10. 明确声明 |
||||
|
|
||||
|
- **未暂存、未提交、未推送。** |
||||
|
- 未修改角色检查、资源归属、Service、DTO、ErrorCode、Flyway、接口或 OpenAPI。 |
||||
|
- 未进入 `UserRoleGuard`。 |
||||
|
- 未进入其他 R01 工作。 |
||||
|
- 测试切片使用真实 `SecurityFilterChain` + 真实 Accessor Bean(`@SpringBootTest`),**未**使用「允许缺失依赖 + new 默认实例」的绕过方式。 |
||||
|
- 全仓剩余身份/requestId 解析数量已如实报告,**未声称全仓完成**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
> **R01-A2第二批请求身份与requestId收束完成,待独立代码复验。** |
||||
@ -0,0 +1,123 @@ |
|||||
|
package com.mydreamjob.common.r01a2; |
||||
|
|
||||
|
import com.mydreamjob.application.ApplicationService; |
||||
|
import com.mydreamjob.candidatescore.CandidateScoringEngineService; |
||||
|
import com.mydreamjob.candidatescore.dashboard.CompanyScoringDashboardService; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.company.CompanyService; |
||||
|
import com.mydreamjob.interview.InterviewService; |
||||
|
import com.mydreamjob.messaging.MessagingService; |
||||
|
import com.mydreamjob.offer.OfferService; |
||||
|
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 java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
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; |
||||
|
|
||||
|
/** |
||||
|
* R01-A2 独立复验:**接线变异检测**。 |
||||
|
* |
||||
|
* <p>本测试把 {@code CurrentUserAccessor} / {@code RequestIdAccessor} 替换为 mock, |
||||
|
* 从而证明 8 个 Controller <b>确实</b>调用了这两个既有 bean:</p> |
||||
|
* <ul> |
||||
|
* <li>若某 Controller 仍保留自己的身份解析(即未接线),它不会调用 mock, |
||||
|
* {@code verify(..., never())} 之外的断言会因状态码偏离而失败;</li> |
||||
|
* <li>反之,mock 抛出未登录异常时端点必须返回 401 —— 这是"调用确实发生"的直接证据。</li> |
||||
|
* </ul> |
||||
|
* |
||||
|
* <p>因此本测试在"删除 Accessor 调用"的变异下**会失败**,可判定实施者测试并非空转。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A2AccessorWiringProbeTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
|
||||
|
@MockBean private CurrentUserAccessor currentUserAccessor; |
||||
|
@MockBean private RequestIdAccessor requestIdAccessor; |
||||
|
|
||||
|
@MockBean private ApplicationService applicationService; |
||||
|
@MockBean private CompanyService companyService; |
||||
|
@MockBean private MessagingService messagingService; |
||||
|
@MockBean private OfferService offerService; |
||||
|
@MockBean private InterviewService interviewService; |
||||
|
@MockBean private CandidateScoringEngineService candidateScoringEngineService; |
||||
|
@MockBean private CompanyScoringDashboardService dashboardService; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final List<String> PATHS = List.of( |
||||
|
"/applications/me", "/companies/me", "/conversations", "/offers", |
||||
|
"/interviews/me", "/profile/me/candidate-scoring", |
||||
|
"/profile/me/candidate-score-card", "/company/scoring/dashboard/overview"); |
||||
|
|
||||
|
private static final UsernamePasswordAuthenticationToken LEGAL = |
||||
|
new UsernamePasswordAuthenticationToken("42", "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("接线检测:Accessor 抛未登录时,八端点必须返回 401(证明确实经 Accessor)") |
||||
|
void whenAccessorThrowsUnauthenticated_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 若 Controller 未接线 Accessor,此处不会是 401", path) |
||||
|
.isEqualTo(401); |
||||
|
} |
||||
|
// 八端点均实际调用了同一个 Accessor bean
|
||||
|
verify(currentUserAccessor, times(PATHS.size())).requireUserId(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("接线检测:响应体 requestId 来自 Accessor bean(mock 值必须原样出现)") |
||||
|
void requestIdComesFromAccessorBean() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(42L); |
||||
|
when(requestIdAccessor.get(any())).thenReturn("probe-from-accessor"); |
||||
|
|
||||
|
// 注意:响应头 X-Request-Id 由 RequestIdFilter 设置(filter chain 职责),
|
||||
|
// 不来自 Controller;Controller 的影响体现在**响应体**的 requestId 字段。
|
||||
|
for (String path : PATHS) { |
||||
|
String body = mockMvc.perform(get(path).with(authentication(LEGAL))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
assertThat(body) |
||||
|
.as("path=%s 响应体 requestId 应来自 Accessor mock(若 Controller 直读原始头则不同)", path) |
||||
|
.contains("\"requestId\":\"probe-from-accessor\""); |
||||
|
} |
||||
|
Mockito.verify(requestIdAccessor, Mockito.atLeastOnce()).get(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("接线检测:合法主体经 Accessor 后可达 Service") |
||||
|
void legalPrincipalReachesService() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(42L); |
||||
|
when(requestIdAccessor.get(any())).thenReturn("probe"); |
||||
|
|
||||
|
mockMvc.perform(get("/conversations").with(authentication(LEGAL))) |
||||
|
.andReturn(); |
||||
|
|
||||
|
verify(messagingService).listConversations(42L); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,265 @@ |
|||||
|
package com.mydreamjob.common.r01a2; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.application.ApplicationService; |
||||
|
import com.mydreamjob.candidatescore.CandidateScoringEngineService; |
||||
|
import com.mydreamjob.candidatescore.dashboard.CompanyScoringDashboardService; |
||||
|
import com.mydreamjob.company.CompanyService; |
||||
|
import com.mydreamjob.interview.InterviewService; |
||||
|
import com.mydreamjob.messaging.MessagingService; |
||||
|
import com.mydreamjob.offer.OfferService; |
||||
|
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.request.MockMvcRequestBuilders.put; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
/** |
||||
|
* R01-A2 独立复验(不复用实施者的测试类与断言)。 |
||||
|
* |
||||
|
* <p>关键约束:对超长/溢出主体**不做整响应数字子串搜索**,而是解析 JSON 后只检查 |
||||
|
* 业务字段(code / message),避免状态码、时间戳、requestId 中的数字造成误判。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A2IndependentVerificationTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
@Autowired private com.mydreamjob.security.CurrentUserAccessor realCurrentUserAccessor; |
||||
|
@Autowired private com.mydreamjob.common.logging.RequestIdAccessor realRequestIdAccessor; |
||||
|
|
||||
|
@MockBean private ApplicationService applicationService; |
||||
|
@MockBean private CompanyService companyService; |
||||
|
@MockBean private MessagingService messagingService; |
||||
|
@MockBean private OfferService offerService; |
||||
|
@MockBean private InterviewService interviewService; |
||||
|
@MockBean private CandidateScoringEngineService candidateScoringEngineService; |
||||
|
@MockBean private CompanyScoringDashboardService dashboardService; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||
|
|
||||
|
private static final List<String> PATHS = List.of( |
||||
|
"/applications/me", "/companies/me", "/conversations", "/offers", |
||||
|
"/interviews/me", "/profile/me/candidate-scoring", |
||||
|
"/profile/me/candidate-score-card", "/company/scoring/dashboard/overview"); |
||||
|
|
||||
|
/** 七类非法/边界主体。 */ |
||||
|
private static final List<String> PRINCIPALS = List.of( |
||||
|
"anonymousUser", |
||||
|
"", |
||||
|
" ", |
||||
|
"not-a-number", |
||||
|
"abc123", |
||||
|
"12345678901234567890", // 20 位,超出 long
|
||||
|
"0", |
||||
|
"-1"); |
||||
|
|
||||
|
private static Authentication principal(String name) { |
||||
|
return new UsernamePasswordAuthenticationToken(name, "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
} |
||||
|
|
||||
|
// ── 身份:非法主体一律 401、绝不 500 ──────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:八端点 × 八非法主体 一律 401 且绝不 500") |
||||
|
void allIllegalPrincipals_return401_never500() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
for (String p : PRINCIPALS) { |
||||
|
int st = mockMvc.perform(get(path).with(authentication(principal(p)))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st) |
||||
|
.as("path=%s principal='%s' 必须 401", path, p) |
||||
|
.isEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:响应体只含固定安全文案,不回显异常类/principal/堆栈") |
||||
|
void errorBody_isFixedSafeText() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
for (String p : PRINCIPALS) { |
||||
|
String body = mockMvc.perform(get(path).with(authentication(principal(p)))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
JsonNode json = MAPPER.readTree(body); |
||||
|
// 解析 JSON 后仅检查业务字段
|
||||
|
assertThat(json.path("code").asInt()).as("业务码应为 401").isEqualTo(401); |
||||
|
String message = json.path("message").asText(); |
||||
|
assertThat(message).as("必须为固定安全文案").isIn("authentication required", "not authenticated"); |
||||
|
assertThat(message).doesNotContain("Exception").doesNotContain("java.") |
||||
|
.doesNotContain("principal").doesNotContain("at com.mydreamjob"); |
||||
|
assertThat(json.has("stackTrace")).isFalse(); |
||||
|
assertThat(json.has("trace")).isFalse(); |
||||
|
assertThat(json.has("principal")).isFalse(); |
||||
|
// 不回显非数字标记
|
||||
|
if (!p.isBlank()) { |
||||
|
assertThat(message).doesNotContain(p); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:匿名 token 与 null 认证均 401") |
||||
|
void anonymousAndNull_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()).isEqualTo(401); |
||||
|
assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) |
||||
|
.isEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:合法正数主体不被身份层判为未登录") |
||||
|
void legalPrincipal_passesIdentityLayer() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
int st = mockMvc.perform(get(path).with(authentication(principal("7")))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("path=%s 合法主体不应 401", path).isNotEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ── 角色与归属 ───────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:STUDENT 访问企业操作仍 403") |
||||
|
void studentOnCompanyOperation_is403() throws Exception { |
||||
|
mockMvc.perform(get("/company/candidates") |
||||
|
.header("Authorization", com.mydreamjob.auth.support.TestAuthTokens.bearer(9001L, "STUDENT"))) |
||||
|
.andExpect(status().isForbidden()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:无 token 仍 401(filter chain 层,不经 Controller)") |
||||
|
void noToken_is401() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:身份层错误不得被角色层覆盖(非法主体仍 401 而非 403)") |
||||
|
void identityFailureIsNotMaskedByRoleCheck() throws Exception { |
||||
|
// 学生/企业端点均以非法主体访问,必须保持 401
|
||||
|
for (String p : List.of("not-a-number", "0", "-1")) { |
||||
|
assertThat(mockMvc.perform(get("/company/candidates").with(authentication(principal(p)))) |
||||
|
.andReturn().getResponse().getStatus()).isEqualTo(401); |
||||
|
assertThat(mockMvc.perform(get("/companies/me").with(authentication(principal(p)))) |
||||
|
.andReturn().getResponse().getStatus()).isEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ── requestId:Filter 与 Accessor 直接测试 ───────────────
|
||||
|
|
||||
|
/** 经 public 的 RequestIdAccessor 输入原始头值,观察规范化结果(走与 Filter 相同的规则)。 */ |
||||
|
private String normalized(String headerValue) { |
||||
|
var req = new org.springframework.mock.web.MockHttpServletRequest(); |
||||
|
if (headerValue != null) { |
||||
|
req.addHeader("X-Request-Id", headerValue); |
||||
|
} |
||||
|
return realRequestIdAccessor.get(req); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:requestId 合法/空白/超长/CRLF/非法字符") |
||||
|
void requestIdNormalization_boundaries() { |
||||
|
// 合法值原样保留
|
||||
|
assertThat(normalized("abc-123_X:y.z")).isEqualTo("abc-123_X:y.z"); |
||||
|
// 空白/缺失重新生成
|
||||
|
assertThat(normalized(" ")).isNotBlank().isNotEqualTo(" "); |
||||
|
assertThat(normalized("")).isNotBlank(); |
||||
|
assertThat(normalized(null)).isNotBlank(); |
||||
|
// 超长规范化到 32
|
||||
|
assertThat(normalized("x".repeat(100))).hasSize(32).isEqualTo("x".repeat(32)); |
||||
|
// CR / LF / CRLF 不得进入结果
|
||||
|
for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n")) { |
||||
|
String n = normalized(bad); |
||||
|
assertThat(n).as("CR/LF 必须被拒绝: %s", bad) |
||||
|
.doesNotContain("\r").doesNotContain("\n").isNotBlank(); |
||||
|
} |
||||
|
// 非法字符整体拒绝
|
||||
|
for (String bad : List.of("<script>", "a b", "a;b", "héllo", "a\tb")) { |
||||
|
String n = normalized(bad); |
||||
|
assertThat(n).as("非法字符必须被拒绝: %s", bad) |
||||
|
.doesNotContain("<").doesNotContain(">").doesNotContain(";") |
||||
|
.doesNotContain(" ").doesNotContain("\t").doesNotContain("é") |
||||
|
.isNotBlank(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:Accessor 请求属性优先于原始 header") |
||||
|
void accessor_prefersRequestAttribute() { |
||||
|
var req = new org.springframework.mock.web.MockHttpServletRequest(); |
||||
|
req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "attr-value"); |
||||
|
req.addHeader("X-Request-Id", "header-value"); |
||||
|
assertThat(realRequestIdAccessor.get(req)).as("属性应优先于 header").isEqualTo("attr-value"); |
||||
|
|
||||
|
var req2 = new org.springframework.mock.web.MockHttpServletRequest(); |
||||
|
req2.addHeader("X-Request-Id", "only-header"); |
||||
|
// 无属性时仍走同一规范化规则(合法值原样返回)
|
||||
|
assertThat(realRequestIdAccessor.get(req2)).isEqualTo("only-header"); |
||||
|
|
||||
|
var req3 = new org.springframework.mock.web.MockHttpServletRequest(); |
||||
|
req3.addHeader("X-Request-Id", " "); |
||||
|
assertThat(realRequestIdAccessor.get(req3)).isNotBlank(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:响应头与响应体 requestId 相同,且异常字符不入响应") |
||||
|
void responseHeaderAndBodyShareRequestId() throws Exception { |
||||
|
// 注:含 CR/LF 的头在 MockMvc 客户端层即被拒绝(请求发不出),
|
||||
|
// 故此处使用可承载但会被规范化规则拒绝的字符集。
|
||||
|
for (String sent : List.of("r01a2-ok-1", " ", "x".repeat(80), "<script>x</script>", "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("\r").doesNotContain("\n") |
||||
|
.doesNotContain("<").doesNotContain(">").doesNotContain(" "); |
||||
|
assertThat(hdr.length()).isLessThanOrEqualTo(32); |
||||
|
assertThat(r.getResponse().getHeader("Injected")).isNull(); |
||||
|
String body = r.getResponse().getContentAsString(); |
||||
|
assertThat(body).contains("\"requestId\":\"" + hdr + "\""); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验:真实 Accessor bean 可解析合法主体") |
||||
|
void realAccessor_resolvesLegalPrincipal() { |
||||
|
var ctx = org.springframework.security.core.context.SecurityContextHolder.createEmptyContext(); |
||||
|
ctx.setAuthentication(principal("42")); |
||||
|
var prev = org.springframework.security.core.context.SecurityContextHolder.getContext(); |
||||
|
org.springframework.security.core.context.SecurityContextHolder.setContext(ctx); |
||||
|
try { |
||||
|
assertThat(realCurrentUserAccessor.requireUserId()).isEqualTo(42L); |
||||
|
} finally { |
||||
|
org.springframework.security.core.context.SecurityContextHolder.setContext(prev); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,269 @@ |
|||||
|
package com.mydreamjob.common.r01a2; |
||||
|
|
||||
|
import com.mydreamjob.application.ApplicationService; |
||||
|
import com.mydreamjob.candidatescore.CandidateScoringEngineService; |
||||
|
import com.mydreamjob.candidatescore.dashboard.CompanyScoringDashboardService; |
||||
|
import com.mydreamjob.company.CompanyService; |
||||
|
import com.mydreamjob.interview.InterviewService; |
||||
|
import com.mydreamjob.messaging.MessagingService; |
||||
|
import com.mydreamjob.offer.OfferService; |
||||
|
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.data.redis.core.ValueOperations; |
||||
|
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 org.springframework.test.web.servlet.request.RequestPostProcessor; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
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.header; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
/** |
||||
|
* R01-A2 第二批:请求身份与 requestId 收束的特征测试。 |
||||
|
* |
||||
|
* <p>覆盖 8 个 Controller 的代表端点:身份只来自 |
||||
|
* {@code CurrentUserAccessor#requireUserId()},requestId 只来自 |
||||
|
* {@code RequestIdAccessor#get(HttpServletRequest)}。</p> |
||||
|
* |
||||
|
* <p>身份边界要求:任何非法主体统一 HTTP 401(不是 500,也不是 400), |
||||
|
* 响应不回显 principal,失败请求不产生业务写入。</p> |
||||
|
* |
||||
|
* <p>requestId 边界要求:规范化后的值与响应头、响应体同源;超长值被既有规则收敛; |
||||
|
* 空白值重新生成;恶意换行/异常字符不得进入响应。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class RequestContextMigrationIntegrationTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
|
||||
|
@MockBean private ApplicationService applicationService; |
||||
|
@MockBean private CompanyService companyService; |
||||
|
@MockBean private MessagingService messagingService; |
||||
|
@MockBean private OfferService offerService; |
||||
|
@MockBean private InterviewService interviewService; |
||||
|
@MockBean private CandidateScoringEngineService candidateScoringEngineService; |
||||
|
@MockBean private CompanyScoringDashboardService dashboardService; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
/** 8 个 Controller 的一个代表端点。 */ |
||||
|
private record Endpoint(String name, String path) {} |
||||
|
|
||||
|
private static final List<Endpoint> ENDPOINTS = List.of( |
||||
|
new Endpoint("ApplicationController", "/applications/me"), |
||||
|
new Endpoint("CompanyController", "/companies/me"), |
||||
|
new Endpoint("MessagingController", "/conversations"), |
||||
|
new Endpoint("OfferController", "/offers"), |
||||
|
new Endpoint("InterviewController", "/interviews/me"), |
||||
|
new Endpoint("CandidateScoringController", "/profile/me/candidate-scoring"), |
||||
|
new Endpoint("CandidateScoreCardController", "/profile/me/candidate-score-card"), |
||||
|
new Endpoint("CompanyScoringDashboardController", "/company/scoring/dashboard/overview")); |
||||
|
|
||||
|
// ── 身份边界 ─────────────────────────────────────────────
|
||||
|
|
||||
|
private static Authentication auth(String name) { |
||||
|
return new UsernamePasswordAuthenticationToken(name, "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
} |
||||
|
|
||||
|
private static RequestPostProcessor as(Authentication a) { |
||||
|
return authentication(a); |
||||
|
} |
||||
|
|
||||
|
/** 非法主体集合:名称 null/空/匿名/非数字/溢出/零/负。 */ |
||||
|
private static List<Authentication> illegalPrincipals() { |
||||
|
return List.of( |
||||
|
new UsernamePasswordAuthenticationToken("", "n/a", AuthorityUtils.NO_AUTHORITIES), |
||||
|
new UsernamePasswordAuthenticationToken("anonymousUser", "n/a", AuthorityUtils.NO_AUTHORITIES), |
||||
|
new UsernamePasswordAuthenticationToken("not-a-number", "n/a", AuthorityUtils.NO_AUTHORITIES), |
||||
|
new UsernamePasswordAuthenticationToken("99999999999999999999999", "n/a", AuthorityUtils.NO_AUTHORITIES), |
||||
|
new UsernamePasswordAuthenticationToken("0", "n/a", AuthorityUtils.NO_AUTHORITIES), |
||||
|
new UsernamePasswordAuthenticationToken("-5", "n/a", AuthorityUtils.NO_AUTHORITIES), |
||||
|
new UsernamePasswordAuthenticationToken("+42", "n/a", AuthorityUtils.NO_AUTHORITIES), |
||||
|
new AnonymousAuthenticationToken("key", "anonymousUser", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("非法主体一律返回 401,且不回显 principal") |
||||
|
void illegalPrincipals_alwaysReturn401_withoutEchoingPrincipal() throws Exception { |
||||
|
for (Endpoint ep : ENDPOINTS) { |
||||
|
for (Authentication bad : illegalPrincipals()) { |
||||
|
String principal = String.valueOf(bad.getName()); |
||||
|
var result = mockMvc.perform(get(ep.path()).with(as(bad))) |
||||
|
.andReturn(); |
||||
|
int status = result.getResponse().getStatus(); |
||||
|
String body = result.getResponse().getContentAsString(); |
||||
|
|
||||
|
assertThat(status) |
||||
|
.as("%s %s 非法主体 '%s' 必须返回 401(实际 %d)", ep.name(), ep.path(), principal, status) |
||||
|
.isEqualTo(401); |
||||
|
// 只对"有辨识度的主体名"断言不回显;单字符/纯数字主体会与 JSON 中的
|
||||
|
// 状态码、时间戳、requestId 等合法数字碰撞,不构成回显证据。
|
||||
|
if (principal.length() >= 3 && !principal.chars().allMatch(Character::isDigit)) { |
||||
|
assertThat(body) |
||||
|
.as("%s 响应不得回显 principal '%s'", ep.name(), principal) |
||||
|
.doesNotContain(principal); |
||||
|
} |
||||
|
// 两条合法拒绝路径的文案不同:匿名 token 由 SecurityFilterChain 在进入
|
||||
|
// Controller 之前拦截(AuthenticationEntryPoint),其余非法主体由
|
||||
|
// CurrentUserAccessor 抛出;两者都必须是固定安全文案。
|
||||
|
assertThat(body) |
||||
|
.as("%s 响应必须使用固定安全文案(实际:%s)", ep.name(), body) |
||||
|
.satisfiesAnyOf( |
||||
|
b -> assertThat(b).contains("authentication required"), |
||||
|
b -> assertThat(b).contains("not authenticated")); |
||||
|
assertThat(body).doesNotContain("NumberFormatException"); |
||||
|
assertThat(body).doesNotContain("java.lang"); |
||||
|
assertThat(body).doesNotContain("ClassCastException"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("null Authentication 返回 401") |
||||
|
void nullAuthentication_returns401() throws Exception { |
||||
|
for (Endpoint ep : ENDPOINTS) { |
||||
|
mockMvc.perform(get(ep.path())) |
||||
|
.andExpect(status().isUnauthorized()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("合法数字主体不被身份层拒绝(不返回 401/500)") |
||||
|
void legalPrincipal_isNotRejectedByIdentityLayer() throws Exception { |
||||
|
// 合法数字主体必须能通过身份层;下游 @MockBean 服务未 stub 导致的 5xx
|
||||
|
// 属测试夹具范畴,因此只断言"不被身份层拒绝(非 401)"。
|
||||
|
for (Endpoint ep : ENDPOINTS) { |
||||
|
int status = mockMvc.perform(get(ep.path()).with(as(auth("42")))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(status) |
||||
|
.as("%s 合法主体不得被身份层判为未登录(实际 %d)", ep.name(), status) |
||||
|
.isNotEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("失败请求不产生业务写入") |
||||
|
void failedRequests_produceNoBusinessWrites() throws Exception { |
||||
|
for (Endpoint ep : ENDPOINTS) { |
||||
|
mockMvc.perform(get(ep.path()).with(as(auth("not-a-number")))) |
||||
|
.andExpect(status().isUnauthorized()); |
||||
|
} |
||||
|
// 身份解析在任何 Service 调用之前失败 → 各 Service 不应被触达
|
||||
|
org.mockito.Mockito.verifyNoInteractions( |
||||
|
applicationService, companyService, messagingService, offerService, |
||||
|
interviewService, candidateScoringEngineService, dashboardService); |
||||
|
} |
||||
|
|
||||
|
// ── requestId 边界 ───────────────────────────────────────
|
||||
|
|
||||
|
private void assertRequestIdConsistent(String path, String sent, String expectedOrNull) throws Exception { |
||||
|
var result = mockMvc.perform(get(path).header("X-Request-Id", sent).with(as(auth("42")))) |
||||
|
.andReturn(); |
||||
|
String responseHeader = result.getResponse().getHeader("X-Request-Id"); |
||||
|
assertThat(responseHeader).as("响应头必须带 X-Request-Id,path=%s", path).isNotNull(); |
||||
|
if (expectedOrNull != null) { |
||||
|
assertThat(responseHeader).as("合法值应原样同源").isEqualTo(expectedOrNull); |
||||
|
} |
||||
|
String body = result.getResponse().getContentAsString(); |
||||
|
String fromBody = body.replaceAll("(?s).*\"requestId\"\\s*:\\s*\"([^\"]*)\".*", "$1"); |
||||
|
assertThat(fromBody) |
||||
|
.as("响应体 requestId 必须与响应头同源") |
||||
|
.isEqualTo(responseHeader); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("requestId:合法值原样同源(响应头 == 响应体)") |
||||
|
void requestId_legalValue_isEchoedConsistently() throws Exception { |
||||
|
for (Endpoint ep : ENDPOINTS) { |
||||
|
assertRequestIdConsistent(ep.path(), "r01a2-legal-0001", "r01a2-legal-0001"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("requestId:超长值经既有规范化收敛(<=32)且同源") |
||||
|
void requestId_overlongValue_isNormalizedConsistently() throws Exception { |
||||
|
String overlong = "a".repeat(120); |
||||
|
for (Endpoint ep : ENDPOINTS) { |
||||
|
var result = mockMvc.perform(get(ep.path()).header("X-Request-Id", overlong).with(as(auth("42")))) |
||||
|
.andReturn(); |
||||
|
String header = result.getResponse().getHeader("X-Request-Id"); |
||||
|
assertThat(header).isNotNull(); |
||||
|
assertThat(header.length()).as("超长值必须被收敛到 32 以内").isLessThanOrEqualTo(32); |
||||
|
assertThat(header).isEqualTo(overlong.substring(0, 32)); |
||||
|
String body = result.getResponse().getContentAsString(); |
||||
|
assertThat(body).doesNotContain(overlong); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("requestId:空白值重新生成") |
||||
|
void requestId_blankValue_isRegenerated() throws Exception { |
||||
|
for (Endpoint ep : ENDPOINTS) { |
||||
|
var result = mockMvc.perform(get(ep.path()).header("X-Request-Id", " ").with(as(auth("42")))) |
||||
|
.andReturn(); |
||||
|
String header = result.getResponse().getHeader("X-Request-Id"); |
||||
|
assertThat(header).as("空白值必须重新生成").isNotBlank(); |
||||
|
assertThat(header.trim()).isEqualTo(header); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("requestId:恶意换行/异常字符不得进入响应与日志") |
||||
|
void requestId_maliciousCharacters_neverReachResponse() throws Exception { |
||||
|
// 注:CR/LF 会在 MockMvc 客户端层就被拒绝(请求根本发不出去),
|
||||
|
// 因此这里覆盖"能被 HTTP 头承载、但会被规范化规则拒绝"的字符集。
|
||||
|
List<String> malicious = List.of( |
||||
|
"<script>alert(1)</script>", |
||||
|
"id;DROP TABLE users", |
||||
|
"abc\tdef", |
||||
|
"héllo-wörld", |
||||
|
"a b c"); |
||||
|
|
||||
|
for (Endpoint ep : ENDPOINTS) { |
||||
|
for (String bad : malicious) { |
||||
|
var result = mockMvc.perform(get(ep.path()).header("X-Request-Id", bad).with(as(auth("42")))) |
||||
|
.andReturn(); |
||||
|
String header = result.getResponse().getHeader("X-Request-Id"); |
||||
|
assertThat(header).as("必须生成合法替代值").isNotNull().isNotBlank(); |
||||
|
assertThat(header).doesNotContain("<").doesNotContain(">") |
||||
|
.doesNotContain(";").doesNotContain(" ").doesNotContain("\t"); |
||||
|
assertThat(result.getResponse().getHeader("X-Injected")).isNull(); |
||||
|
String body = result.getResponse().getContentAsString(); |
||||
|
assertThat(body).doesNotContain(bad); |
||||
|
assertThat(body).contains("\"requestId\":\"" + header + "\""); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("错误响应也携带同源 requestId") |
||||
|
void errorResponse_carriesSameRequestId() throws Exception { |
||||
|
var result = mockMvc.perform(get("/applications/me") |
||||
|
.header("X-Request-Id", "r01a2-err-0001") |
||||
|
.with(as(auth("bogus")))) |
||||
|
.andReturn(); |
||||
|
assertThat(result.getResponse().getStatus()).isEqualTo(401); |
||||
|
assertThat(result.getResponse().getHeader("X-Request-Id")).isEqualTo("r01a2-err-0001"); |
||||
|
assertThat(result.getResponse().getContentAsString()).contains("r01a2-err-0001"); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue