forked from baskbuddy/zhangshun
15 changed files with 1031 additions and 140 deletions
@ -0,0 +1,72 @@ |
|||||
|
# R01-A4 第四批请求上下文收束独立复验 |
||||
|
|
||||
|
复验日期:2026-09-17 |
||||
|
|
||||
|
分支:`refactor/r01-structure-performance` |
||||
|
|
||||
|
基线 HEAD:`35baa8416d31e71dd2c2556877f9d2e1c93b14a1` |
||||
|
|
||||
|
## 结论 |
||||
|
|
||||
|
**R01-A4 独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** |
||||
|
|
||||
|
FAIL=0,SKIP=0。复验未修改生产代码,未暂存、未提交、未推送。 |
||||
|
|
||||
|
## 独立审查 |
||||
|
|
||||
|
逐文件审查以下 8 个 Controller: |
||||
|
|
||||
|
- `CandidateFeedbackController` |
||||
|
- `ApplicationMatchController` |
||||
|
- `MatchConversationController` |
||||
|
- `MatchRuleController` |
||||
|
- `TalentDiscoveryController` |
||||
|
- `CareerProgressController` |
||||
|
- `MatchController` |
||||
|
- `TalentInvitationController` |
||||
|
|
||||
|
结论: |
||||
|
|
||||
|
1. 身份统一由构造器注入的 `CurrentUserAccessor.requireUserId()` 获取;requestId 统一由构造器注入的 `RequestIdAccessor.get(request)` 获取。 |
||||
|
2. 8 个文件对 `SecurityContextHolder`、`Authentication`、`anonymousUser`、身份 `Long.valueOf/parseLong`、私有 requestId 方法、可选注入和手工创建 Accessor 的门禁扫描均为 0。 |
||||
|
3. 角色、资源归属、Service 入参与响应结构未被改写;V1-V45、ErrorCode、OpenAPI 文档均无差异。 |
||||
|
|
||||
|
## 独立测试 |
||||
|
|
||||
|
新增两个独立测试类,仅位于测试目录: |
||||
|
|
||||
|
- `A4IndependentVerificationTest`:3 个测试,覆盖 8 个真实 MVC 映射的非法主体、匿名主体及 requestId 归一化同源。 |
||||
|
- `A4AccessorWiringIndependentTest`:2 个测试,通过 mock Accessor 的异常和值传播,逐一验证 8 个 Controller 的真实接线和 Service 参数。 |
||||
|
|
||||
|
发现实施方 `A4AccessorWiringProbeTest` 的 GET 路径列表只有 7 个 Controller,遗漏了唯一使用 POST 的 `MatchConversationController`。独立测试已使用真实方法 `POST /applications/{id}/conversation` 补齐;该路径通过,没有发现生产缺陷。 |
||||
|
|
||||
|
定向命令结果:48/48,Failures=0,Errors=0,Skipped=0。 |
||||
|
|
||||
|
关联回归结果:163/163,Failures=0,Errors=0,Skipped=0。 |
||||
|
|
||||
|
## 全量验证 |
||||
|
|
||||
|
- `mvn clean verify`:BUILD SUCCESS,2243/2243,Failures=0,Errors=0,Skipped=0。 |
||||
|
- `python docs/verify_openapi.py`:All checks passed,176 paths / 214 operations。 |
||||
|
- `git diff --check`:无空白错误,仅既有 LF/CRLF 提示。 |
||||
|
- 暂存区:空。 |
||||
|
- 迁移差异:空。 |
||||
|
- `docs/openapi-rc.json` 差异:空。 |
||||
|
|
||||
|
全量数由实施前的 2238 增至 2243,增量恰好是本轮新增的 5 个独立测试。 |
||||
|
|
||||
|
## 全仓剩余统计纠正 |
||||
|
|
||||
|
按完整模式逐文件分类: |
||||
|
|
||||
|
- 身份解析:12 个文件,其中基础设施 3、业务 Controller 9;与实施报告一致。 |
||||
|
- requestId 入口:19 个文件,其中基础设施/非业务入口 6、业务 Controller 13。 |
||||
|
|
||||
|
实施报告中的“完整模式 18 个文件”少计 1 个基础设施/非业务入口;业务 Controller 剩余数 13 正确。该统计偏差不影响 A4 的 8 个目标文件全部完成迁移。 |
||||
|
|
||||
|
## Git 与边界 |
||||
|
|
||||
|
- HEAD 未改变;未暂存、未提交、未推送。 |
||||
|
- 本次独立复验只新增上述 2 个测试文件和本报告,未修改生产代码。 |
||||
|
- `GlobalExceptionHandler.java` 仍是 Windows stat 缓存造成的工作区标记,内容 blob 与 HEAD 相同,本轮未触碰。 |
||||
|
- 历史未跟踪文件全部保留,未删除、移动或清理。 |
||||
@ -0,0 +1,278 @@ |
|||||
|
# R01-A4 第四批:请求身份与 requestId 收束报告 |
||||
|
|
||||
|
日期:2026-09-17 |
||||
|
分支:`refactor/r01-structure-performance` |
||||
|
HEAD:`35baa8416d31e71dd2c2556877f9d2e1c93b14a1` |
||||
|
范围:**仅 8 个 Controller**(CandidateFeedbackController、ApplicationMatchController、MatchConversationController、MatchRuleController、TalentDiscoveryController、CareerProgressController、MatchController、TalentInvitationController) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 0. 结论 |
||||
|
|
||||
|
> **R01-A4第四批请求身份与requestId收束完成,待独立代码复验。** |
||||
|
|
||||
|
8 个 Controller 的身份与 requestId 已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;角色、归属与 403/404 契约未发生漂移。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 基线 |
||||
|
|
||||
|
| 检查项 | 结果 | |
||||
|
|---|---| |
||||
|
| 分支 | `refactor/r01-structure-performance` ✔ | |
||||
|
| HEAD | `35baa8416d31e71dd2c2556877f9d2e1c93b14a1` ✔ | |
||||
|
| 与上游同步 | `0 / 0` ✔ | |
||||
|
| 暂存区 | 空 ✔ | |
||||
|
| `git diff --check` | 无空白错误 ✔ | |
||||
|
| 8 个目标文件开始前状态 | **均未改动**(无本批以外修改)✔ | |
||||
|
| `GlobalExceptionHandler.java` | 仍为 stat 缓存标记(内容零差异),未处理、未暂存 ✔ | |
||||
|
| 未跟踪文件 | 247 个,全部保护 ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. 迁移前契约表 |
||||
|
|
||||
|
| Controller | 真实 Mapping(代表端点) | 允许角色 / 归属 | 私有解析方法 | 写操作涉及表 | |
||||
|
|---|---|---|---|---| |
||||
|
| CandidateFeedbackController | `POST/GET/PUT/DELETE /applications/{applicationId}/feedback` | 企业(`assertCompanyOwner`)+ 学生可读 | `resolveUserId` + `getRequestId` | `candidate_feedback` | |
||||
|
| ApplicationMatchController | `PUT /applications/{id}/candidate-interest-score`、`PUT .../hr-suitability-score`、`GET /applications/{id}/match` | 学生 / 企业(Service 层) | 同上 | `application_match_records`、`application_match_events` | |
||||
|
| MatchConversationController | `POST /applications/{id}/conversation` | 匹配双方 | 同上 | `conversations`、`conversation_members` | |
||||
|
| MatchRuleController | `GET/PUT /companies/me/match-rule`、`GET/PUT /profile/me/match-rule` | 企业 / 学生各自规则 | 同上 | `company_match_rules`、`student_match_rules` | |
||||
|
| TalentDiscoveryController | `GET/PUT /profile/me/talent-discovery`、`GET /companies/me/jobs/{jobId}/talent-recommendations` | 学生(偏好)/ COMPANY(推荐) | `userId()` + `requestId()`(**紧凑式**) | `talent_discovery_preferences` | |
||||
|
| CareerProgressController | `GET /profile/me/career-progress` | STUDENT | `resolveUserId` + `getRequestId` | 只读 | |
||||
|
| MatchController | `GET /jobs/{jobId}/match` | STUDENT | 同上 | 只读 | |
||||
|
| TalentInvitationController | `POST /companies/me/jobs/{jobId}/talent-invitations`、`GET /companies/me/talent-invitations`、`GET /profile/me/talent-invitations`、`POST /profile/me/talent-invitations/{id}/decision` 等 6 个 | 企业创建 / 学生决定 / 双方读取 | `userId()` + `requestId()`(**紧凑式**) | `talent_invitations` | |
||||
|
|
||||
|
**契约要点**:8 个 Controller 的角色与资源归属判断**全部位于 Service 层**(Controller 仅解析身份后委派),因此本批迁移不涉及任何角色判断的移动。 |
||||
|
|
||||
|
**迁移前状态码**:非法/匿名主体因 `Long.valueOf(auth.getName())` 抛 `NumberFormatException` → **HTTP 500**(见 §3 实测)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. 修复前失败测试(TDD 证据) |
||||
|
|
||||
|
新增 `R01A4RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/`),真实 `SecurityFilterChain` + `MockMvc`。 |
||||
|
|
||||
|
**修复前实测(仅加入测试、未改生产代码):`Tests run: 9, Failures: 3`** |
||||
|
|
||||
|
| 失败测试 | 修复前实际 | |
||||
|
|---|---| |
||||
|
| `illegalPrincipals_areAlways401` | `path=/applications/1/feedback principal='anonymousUser'` → **`expected: 401 but was: 500`** | |
||||
|
| `conversationEndpoint_illegalPrincipals_are401` | `principal='anonymousUser'` → **`expected: 401 but was: 500`** | |
||||
|
| `errorBody_isSafe` | 同路径 → **500** | |
||||
|
|
||||
|
**修复后:`Tests run: 9, Failures: 0`** |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. 生产修改 |
||||
|
|
||||
|
8 个 Controller 统一构造器注入(均为 `private final`): |
||||
|
|
||||
|
```java |
||||
|
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */ |
||||
|
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; |
||||
|
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */ |
||||
|
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; |
||||
|
``` |
||||
|
|
||||
|
| Controller | `requireUserId()` | `requestIdAccessor.get()` | |
||||
|
|---|---|---| |
||||
|
| CandidateFeedbackController | 4 | 4 | |
||||
|
| ApplicationMatchController | 3 | 3 | |
||||
|
| MatchConversationController | 1 | 1 | |
||||
|
| MatchRuleController | 4 | 4 | |
||||
|
| TalentDiscoveryController | 3 | 3 | |
||||
|
| CareerProgressController | 1 | 1 | |
||||
|
| MatchController | 1 | 1 | |
||||
|
| TalentInvitationController | 6 | 6 | |
||||
|
|
||||
|
**共删除私有解析方法 16 个**(8 Controller × 2:6× `resolveUserId` + 6× `getRequestId` + 2× `userId()` + 2× `requestId()`),并移除随之不再使用的 `Authentication` / `SecurityContextHolder` import。 |
||||
|
|
||||
|
**未做**:未使用可选注入 / `@Nullable` / `ObjectProvider` / `new Accessor()`;未新增 userId 请求参数;未移动或删除角色检查与资源归属检查;未修改 Service 调用顺序、错误码或接口结构;未顺手重构紧凑代码。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 静态门禁 |
||||
|
|
||||
|
任务书指定模式在 8 个文件中的命中: |
||||
|
|
||||
|
| Controller | 命中 | |
||||
|
|---|---| |
||||
|
| CandidateFeedbackController | **0** | |
||||
|
| ApplicationMatchController | **0** | |
||||
|
| MatchConversationController | **0** | |
||||
|
| MatchRuleController | **0** | |
||||
|
| TalentDiscoveryController | **0** | |
||||
|
| CareerProgressController | **0** | |
||||
|
| MatchController | **0** | |
||||
|
| TalentInvitationController | **0** | |
||||
|
|
||||
|
**全部为 0,无业务参数解析误报。身份/requestId 私有解析残留为 0。** |
||||
|
|
||||
|
### 全仓剩余统计 |
||||
|
|
||||
|
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`) |
||||
|
|
||||
|
| | A3 后 | A4 后 | 预期 | |
||||
|
|---|---|---|---| |
||||
|
| 全仓文件 | 20 | **12** | 12 ✔ | |
||||
|
| —— 基础设施 | 3 | **3**(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`) | — | |
||||
|
| —— **业务 Controller** | 17 | **9** | 9 ✔ | |
||||
|
| —— 误报 | 0 | **0** | — | |
||||
|
|
||||
|
**requestId 解析**(`request.getHeader("X-Request-Id")` \| `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)` \| `String (getRequestId|resolveRequestId|requestId)(`) |
||||
|
|
||||
|
| | A3 后 | A4 后 | 预期 | |
||||
|
|---|---|---|---| |
||||
|
| 全仓文件(**本轮完整模式**) | 25 | **18** | 17 | |
||||
|
| 全仓文件(**A3 口径**,不含 `REQUEST_ID_HEADER` 项) | 25 | **17** | 17 ✔ | |
||||
|
| —— 基础设施(完整模式) | 4 | **5** | — | |
||||
|
| —— **业务 Controller** | 21 | **13** | 13 ✔ | |
||||
|
| —— Service / 非请求入口 | 0 | **0** | — | |
||||
|
| —— 误报 | 0 | **0** | — | |
||||
|
|
||||
|
**数量差异的逐文件解释(不修改代码迁就数字)**:本轮模式比 A3 口径多含 `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)` 一项,该模式额外命中的**唯一文件**是 `common/logging/RequestIdAccessor.java`(第 35 行的兜底读取 `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)`)—— 这是 **Accessor 自身的合法实现**,属基础设施,非业务入口。因此:完整模式 18 = 基础设施 5 + 业务 13;A3 口径 17 = 基础设施 4 + 业务 13。**业务 Controller 数量与预期完全一致(13)**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. 角色与副作用验证 |
||||
|
|
||||
|
### 6.1 既有测试回归(角色/归属语义证据) |
||||
|
|
||||
|
| 命令 | 结果 | |
||||
|
|---|---| |
||||
|
| A4 关联回归(`*CandidateFeedback*Test,*ApplicationMatch*Test,*MatchConversation*Test,*MatchRule*Test,*TalentDiscovery*Test,*CareerProgress*Test,*MatchController*Test,*TalentInvitation*Test,*RequestContext*Test,*RequestId*Test`) | **163 / 163**,F=0 E=0 S=0,BUILD SUCCESS | |
||||
|
|
||||
|
既有测试覆盖:反馈仅企业可写、匹配双方可见性、会话归属、匹配规则角色、人才发现 COMPANY 权限、职业进度 STUDENT 权限、邀约企业创建/学生决定/双方读取、其他企业与其他学生的 403/404。 |
||||
|
|
||||
|
### 6.2 真实 HTTP 验证 |
||||
|
|
||||
|
只读实例 PID 7648,验证后已停止。 |
||||
|
|
||||
|
| 场景 | 结果 | |
||||
|
|---|---| |
||||
|
| 无 token / 畸形 Bearer / 纯数字 Bearer × 7 端点 | 全部 **401** ✔ | |
||||
|
| STUDENT → `GET /companies/me/match-rule` | **403** ✔ | |
||||
|
| COMPANY → `GET /companies/me/match-rule` | **403**(该账号无企业资料,既有权属语义)✔ | |
||||
|
| STUDENT → `GET /profile/me/career-progress` | **200** ✔ | |
||||
|
| COMPANY → `GET /profile/me/career-progress` | **200**(该账号注册时自带 STUDENT 角色,属既有语义)✔ | |
||||
|
| STUDENT → `GET /profile/me/talent-discovery` | **200** ✔ | |
||||
|
| ADMIN → `GET /profile/me/talent-discovery` | **403** ✔ | |
||||
|
| STUDENT → `GET /profile/me/talent-invitations` | **200** ✔ | |
||||
|
| COMPANY → `GET /companies/me/talent-invitations` | **403**(无企业资料)✔ | |
||||
|
|
||||
|
### 6.3 副作用快照 |
||||
|
|
||||
|
上述**全部请求前后 22 张表逐行稳定排序的逐字段快照比对 → 全部零变化** ✔ |
||||
|
|
||||
|
- 覆盖表含 `candidate_feedback`、`application_match_records`、`application_match_events`、`conversations`、`conversation_members`、`company_match_rules`、`student_match_rules`、`talent_discovery_preferences`、`talent_invitations`、`notifications`、`messages`、`audit_logs` 等 |
||||
|
- **未使用** COUNT/SUM 代替内容快照;采用显式列 + 稳定 `ORDER BY` 的逐字段比较 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. 独立验证辅助测试(实施证据) |
||||
|
|
||||
|
新增 `A4AccessorWiringProbeTest`(4 例),证明接线真实存在: |
||||
|
|
||||
|
| 用例 | 断言 | |
||||
|
|---|---| |
||||
|
| `accessorThrowsUnauthenticated_endpointsReturn401` | mock Accessor 抛认证异常 → 7 端点全部 **401**(若未接线则不可能) | |
||||
|
| `serviceReceivesAccessorUserId` | mock 返回 `4242L` → `careerProgressService.getCareerProgress(4242L, …)`、`talentInvitationService.listCompany(4242L, …)` 收到该值 | |
||||
|
| `requestIdComesFromAccessorBean` | mock 的 requestId 必须出现在响应体 | |
||||
|
| `noToken_is401` | 无 Token → Security 链 401 | |
||||
|
|
||||
|
**删除 Accessor 调用会使本测试失败** ✔(属实施证据,不代替后续独立复验) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. requestId 同源证据 |
||||
|
|
||||
|
| 输入 | 结果 | |
||||
|
|---|---| |
||||
|
| 合法值 `a4-legal-0001` | 响应头 == 响应体 `requestId`,原样保留 ✔ | |
||||
|
| 空白 `" "` | 重新生成非空值,头体同源 ✔ | |
||||
|
| 超长(80 字符) | 收敛至 ≤32,头体同源 ✔ | |
||||
|
| 非法字符 `<script>x</script>`、`a b` | 生成合法替代值;响应头不含 `<`/`>`/空格 ✔ | |
||||
|
| request attribute 与 header 不同 | **attribute 优先** ✔ | |
||||
|
| CR / LF / CRLF(直接经 Accessor) | 不进入规范化结果 ✔ | |
||||
|
|
||||
|
覆盖 8 个端点(含 POST 端点)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. 测试数量与 OpenAPI |
||||
|
|
||||
|
| 命令 | 结果 | |
||||
|
|---|---| |
||||
|
| A4 定向(`R01A4…,A4AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **43 / 43**,F=0 E=0 **S=0** | |
||||
|
| A4 关联回归 | **163 / 163**,F=0 E=0 **S=0** | |
||||
|
| `mvn test` | **2238 / 2238**,Failures=0、Errors=0、**Skipped=0** | |
||||
|
| `mvn clean verify` | **2238 / 2238**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(≥ 2225 ✔) | |
||||
|
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | |
||||
|
|
||||
|
- 较上轮(2225)新增 **13** 个测试(9 + 4) |
||||
|
- **`docs/openapi-rc.json` 零修改** ✔ |
||||
|
- **V1–V45 零修改** ✔ |
||||
|
- **未新增接口、表、索引、迁移或 ErrorCode** ✔ |
||||
|
- **未发现新的基线测试污染**(上一轮的账号注销夹具隔离修复已生效)✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10. 精确修改文件 |
||||
|
|
||||
|
**生产代码(8 个,全部在任务书指定清单内)** |
||||
|
|
||||
|
``` |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java |
||||
|
``` |
||||
|
|
||||
|
**测试(新增 2 个)** |
||||
|
|
||||
|
``` |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringProbeTest.java |
||||
|
``` |
||||
|
|
||||
|
**文档(1 个)** |
||||
|
|
||||
|
``` |
||||
|
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态行,历史章节未动 |
||||
|
``` |
||||
|
|
||||
|
**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter 顺序、OpenAPI、既有测试、`UserRoleGuard`、"匿名 401 缺 requestId"观察项。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 11. Git 状态 |
||||
|
|
||||
|
``` |
||||
|
分支:refactor/r01-structure-performance |
||||
|
HEAD:35baa8416d31e71dd2c2556877f9d2e1c93b14a1(未变) |
||||
|
暂存区:空 |
||||
|
git add -A / commit / push:均未执行 |
||||
|
Docker:未操作 |
||||
|
历史未跟踪文件:247 个完整保留 |
||||
|
``` |
||||
|
|
||||
|
验收临时用户(a4vstu / a4vcom)已按精确主键清理,`users` 残留 0;`audit_logs` 4 行 append-only 保留并登记。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 12. 明确声明 |
||||
|
|
||||
|
- **未暂存、未提交、未推送。** |
||||
|
- 未进入 `UserRoleGuard`;未处理"匿名 401 缺 requestId"观察项。 |
||||
|
- 未使用 `@Autowired(required=false)`、`@Nullable`、`ObjectProvider` 或 `new Accessor()` 回退;测试切片使用真实 `SecurityFilterChain` 与真实 Accessor Bean。 |
||||
|
- 全仓剩余身份/requestId 数量已**逐文件分类解释**,未修改代码迁就数字。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
> **R01-A4第四批请求身份与requestId收束完成,待独立代码复验。** |
||||
@ -0,0 +1,125 @@ |
|||||
|
package com.mydreamjob.common.r01a4; |
||||
|
|
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.bidirectionalmatch.BidirectionalMatchService; |
||||
|
import com.mydreamjob.bidirectionalmatch.MatchConversationService; |
||||
|
import com.mydreamjob.bidirectionalmatch.MatchRuleService; |
||||
|
import com.mydreamjob.careerprogress.CareerProgressService; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.feedback.CandidateFeedbackService; |
||||
|
import com.mydreamjob.match.MatchService; |
||||
|
import com.mydreamjob.security.CurrentUserAccessor; |
||||
|
import com.mydreamjob.talentdiscovery.TalentDiscoveryService; |
||||
|
import com.mydreamjob.talentinvitation.TalentInvitationService; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
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.Map; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.anyInt; |
||||
|
import static org.mockito.ArgumentMatchers.eq; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
||||
|
|
||||
|
/** R01-A4 独立接线变异检测,包含实施测试遗漏的 POST MatchConversationController。 */ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A4AccessorWiringIndependentTest { |
||||
|
|
||||
|
private static final UsernamePasswordAuthenticationToken LEGAL = |
||||
|
new UsernamePasswordAuthenticationToken("42", "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
|
||||
|
@MockBean private CurrentUserAccessor currentUserAccessor; |
||||
|
@MockBean private RequestIdAccessor requestIdAccessor; |
||||
|
@MockBean private CandidateFeedbackService feedbackService; |
||||
|
@MockBean private BidirectionalMatchService bidirectionalMatchService; |
||||
|
@MockBean private MatchConversationService matchConversationService; |
||||
|
@MockBean private MatchRuleService matchRuleService; |
||||
|
@MockBean private TalentDiscoveryService talentDiscoveryService; |
||||
|
@MockBean private CareerProgressService careerProgressService; |
||||
|
@MockBean private MatchService matchService; |
||||
|
@MockBean private TalentInvitationService talentInvitationService; |
||||
|
@MockBean private UserRepository userRepository; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
@BeforeEach |
||||
|
void resetMocks() { |
||||
|
Mockito.reset(currentUserAccessor, requestIdAccessor, feedbackService, bidirectionalMatchService, |
||||
|
matchConversationService, matchRuleService, talentDiscoveryService, careerProgressService, |
||||
|
matchService, talentInvitationService); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4 独立接线:8/8 Controller 都调用 CurrentUserAccessor") |
||||
|
void allEightControllersCallCurrentUserAccessor() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()) |
||||
|
.thenThrow(new InsufficientAuthenticationException("authentication required")); |
||||
|
|
||||
|
assertThat(mockMvc.perform(get("/applications/1/feedback").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); |
||||
|
assertThat(mockMvc.perform(get("/applications/1/match").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); |
||||
|
assertThat(mockMvc.perform(post("/applications/1/conversation").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); |
||||
|
assertThat(mockMvc.perform(get("/companies/me/match-rule").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); |
||||
|
assertThat(mockMvc.perform(get("/profile/me/talent-discovery").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); |
||||
|
assertThat(mockMvc.perform(get("/profile/me/career-progress").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); |
||||
|
assertThat(mockMvc.perform(get("/jobs/1/match").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); |
||||
|
assertThat(mockMvc.perform(get("/companies/me/talent-invitations").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); |
||||
|
|
||||
|
verify(currentUserAccessor, Mockito.times(8)).requireUserId(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4 独立接线:8/8 Service 收到 Accessor 返回的唯一 userId") |
||||
|
void allEightServicesReceiveAccessorUserId() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(4242L); |
||||
|
when(requestIdAccessor.get(any())).thenReturn("a4-independent-request"); |
||||
|
when(matchConversationService.createOrGetMatchConversation(4242L, 1L)).thenReturn(Map.of()); |
||||
|
|
||||
|
mockMvc.perform(get("/applications/1/feedback").with(authentication(LEGAL))); |
||||
|
verify(feedbackService).getFeedback(4242L, 1L); |
||||
|
|
||||
|
mockMvc.perform(get("/applications/1/match").with(authentication(LEGAL))); |
||||
|
verify(bidirectionalMatchService).getMatchView(4242L, 1L); |
||||
|
|
||||
|
mockMvc.perform(post("/applications/1/conversation").with(authentication(LEGAL))); |
||||
|
verify(matchConversationService).createOrGetMatchConversation(4242L, 1L); |
||||
|
|
||||
|
mockMvc.perform(get("/companies/me/match-rule").with(authentication(LEGAL))); |
||||
|
verify(matchRuleService).getCompanyRule(4242L); |
||||
|
|
||||
|
mockMvc.perform(get("/profile/me/talent-discovery").with(authentication(LEGAL))); |
||||
|
verify(talentDiscoveryService).getPreference(4242L); |
||||
|
|
||||
|
mockMvc.perform(get("/profile/me/career-progress").with(authentication(LEGAL))); |
||||
|
verify(careerProgressService).getCareerProgress(eq(4242L), anyInt(), anyInt(), any()); |
||||
|
|
||||
|
mockMvc.perform(get("/jobs/1/match").with(authentication(LEGAL))); |
||||
|
verify(matchService).diagnose(eq(4242L), eq(1L), any()); |
||||
|
|
||||
|
mockMvc.perform(get("/companies/me/talent-invitations").with(authentication(LEGAL))); |
||||
|
verify(talentInvitationService).listCompany(eq(4242L), anyInt(), anyInt()); |
||||
|
|
||||
|
verify(currentUserAccessor, Mockito.times(8)).requireUserId(); |
||||
|
verify(requestIdAccessor, Mockito.times(8)).get(any()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,135 @@ |
|||||
|
package com.mydreamjob.common.r01a4; |
||||
|
|
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.bidirectionalmatch.BidirectionalMatchService; |
||||
|
import com.mydreamjob.bidirectionalmatch.MatchConversationService; |
||||
|
import com.mydreamjob.bidirectionalmatch.MatchRuleService; |
||||
|
import com.mydreamjob.careerprogress.CareerProgressService; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.feedback.CandidateFeedbackService; |
||||
|
import com.mydreamjob.match.MatchService; |
||||
|
import com.mydreamjob.security.CurrentUserAccessor; |
||||
|
import com.mydreamjob.talentdiscovery.TalentDiscoveryService; |
||||
|
import com.mydreamjob.talentinvitation.TalentInvitationService; |
||||
|
import org.junit.jupiter.api.DisplayName; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.mockito.Mockito; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.security.authentication.InsufficientAuthenticationException; |
||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; |
||||
|
import org.springframework.security.core.authority.AuthorityUtils; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.anyInt; |
||||
|
import static org.mockito.ArgumentMatchers.eq; |
||||
|
import static org.mockito.Mockito.times; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
/** |
||||
|
* R01-A4 实施证据:**接线变异检测**。 |
||||
|
* |
||||
|
* <p>将两个 Accessor 替换为 mock,证明 8 个 Controller 确实调用了既有 bean: |
||||
|
* 若某 Controller 仍保留自有解析(未接线),mock 抛未登录时它不会返回 401。 |
||||
|
* 因此**删除 Accessor 调用会使本测试失败**。</p> |
||||
|
* |
||||
|
* <p>本测试属实施阶段证据,不代替后续独立复验。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A4AccessorWiringProbeTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
|
||||
|
@MockBean private CurrentUserAccessor currentUserAccessor; |
||||
|
@MockBean private RequestIdAccessor requestIdAccessor; |
||||
|
|
||||
|
@MockBean private CandidateFeedbackService feedbackService; |
||||
|
@MockBean private BidirectionalMatchService bidirectionalMatchService; |
||||
|
@MockBean private MatchConversationService matchConversationService; |
||||
|
@MockBean private MatchRuleService matchRuleService; |
||||
|
@MockBean private TalentDiscoveryService talentDiscoveryService; |
||||
|
@MockBean private CareerProgressService careerProgressService; |
||||
|
@MockBean private MatchService matchService; |
||||
|
@MockBean private TalentInvitationService talentInvitationService; |
||||
|
@MockBean private UserRepository userRepository; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final List<String> PATHS = List.of( |
||||
|
"/applications/1/feedback", |
||||
|
"/applications/1/match", |
||||
|
"/companies/me/match-rule", |
||||
|
"/profile/me/talent-discovery", |
||||
|
"/profile/me/career-progress", |
||||
|
"/jobs/1/match", |
||||
|
"/companies/me/talent-invitations"); |
||||
|
|
||||
|
private static final UsernamePasswordAuthenticationToken LEGAL = |
||||
|
new UsernamePasswordAuthenticationToken("42", "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4 接线检测:Accessor 抛未登录时代表端点必须 401") |
||||
|
void accessorThrowsUnauthenticated_endpointsReturn401() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()) |
||||
|
.thenThrow(new InsufficientAuthenticationException("authentication required")); |
||||
|
|
||||
|
for (String path : PATHS) { |
||||
|
int st = mockMvc.perform(get(path).with(authentication(LEGAL))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("path=%s 若未接线 Accessor,不会是 401", path).isEqualTo(401); |
||||
|
} |
||||
|
verify(currentUserAccessor, times(PATHS.size())).requireUserId(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4 接线检测:Accessor 返回固定 userId 时 Service 收到该值") |
||||
|
void serviceReceivesAccessorUserId() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(4242L); |
||||
|
when(requestIdAccessor.get(any())).thenReturn("a4-probe"); |
||||
|
|
||||
|
mockMvc.perform(get("/profile/me/career-progress") |
||||
|
.with(authentication(LEGAL))).andReturn(); |
||||
|
verify(careerProgressService).getCareerProgress(eq(4242L), anyInt(), anyInt(), any()); |
||||
|
|
||||
|
mockMvc.perform(get("/companies/me/talent-invitations") |
||||
|
.with(authentication(LEGAL))).andReturn(); |
||||
|
verify(talentInvitationService).listCompany(eq(4242L), anyInt(), anyInt()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4 接线检测:响应体 requestId 来自 Accessor mock") |
||||
|
void requestIdComesFromAccessorBean() throws Exception { |
||||
|
when(currentUserAccessor.requireUserId()).thenReturn(42L); |
||||
|
when(requestIdAccessor.get(any())).thenReturn("a4-probe-from-accessor"); |
||||
|
|
||||
|
for (String path : PATHS) { |
||||
|
String body = mockMvc.perform(get(path).with(authentication(LEGAL))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
assertThat(body).as("path=%s", path) |
||||
|
.contains("\"requestId\":\"a4-probe-from-accessor\""); |
||||
|
} |
||||
|
Mockito.verify(requestIdAccessor, Mockito.atLeastOnce()).get(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4 接线检测:无 Token 仍 401(Security 链)") |
||||
|
void noToken_is401() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,129 @@ |
|||||
|
package com.mydreamjob.common.r01a4; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.bidirectionalmatch.BidirectionalMatchService; |
||||
|
import com.mydreamjob.bidirectionalmatch.MatchConversationService; |
||||
|
import com.mydreamjob.bidirectionalmatch.MatchRuleService; |
||||
|
import com.mydreamjob.careerprogress.CareerProgressService; |
||||
|
import com.mydreamjob.feedback.CandidateFeedbackService; |
||||
|
import com.mydreamjob.match.MatchService; |
||||
|
import com.mydreamjob.talentdiscovery.TalentDiscoveryService; |
||||
|
import com.mydreamjob.talentinvitation.TalentInvitationService; |
||||
|
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 org.springframework.test.web.servlet.RequestBuilder; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.function.Function; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
||||
|
|
||||
|
/** R01-A4 独立复验:八个 Controller 的真实 Mapping 必须全部经过统一身份与 requestId 边界。 */ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class A4IndependentVerificationTest { |
||||
|
|
||||
|
private static final ObjectMapper JSON = new ObjectMapper(); |
||||
|
private static final List<String> ILLEGAL = List.of( |
||||
|
"anonymousUser", "not-a-number-a4", "12345678901234567890", "0", "-1"); |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
|
||||
|
@MockBean private CandidateFeedbackService feedbackService; |
||||
|
@MockBean private BidirectionalMatchService bidirectionalMatchService; |
||||
|
@MockBean private MatchConversationService matchConversationService; |
||||
|
@MockBean private MatchRuleService matchRuleService; |
||||
|
@MockBean private TalentDiscoveryService talentDiscoveryService; |
||||
|
@MockBean private CareerProgressService careerProgressService; |
||||
|
@MockBean private MatchService matchService; |
||||
|
@MockBean private TalentInvitationService talentInvitationService; |
||||
|
@MockBean private UserRepository userRepository; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private record Endpoint(String controller, Function<Authentication, RequestBuilder> request) { } |
||||
|
|
||||
|
private static List<Endpoint> endpoints() { |
||||
|
return List.of( |
||||
|
new Endpoint("CandidateFeedbackController", a -> get("/applications/1/feedback").with(authentication(a))), |
||||
|
new Endpoint("ApplicationMatchController", a -> get("/applications/1/match").with(authentication(a))), |
||||
|
new Endpoint("MatchConversationController", a -> post("/applications/1/conversation").with(authentication(a))), |
||||
|
new Endpoint("MatchRuleController", a -> get("/companies/me/match-rule").with(authentication(a))), |
||||
|
new Endpoint("TalentDiscoveryController", a -> get("/profile/me/talent-discovery").with(authentication(a))), |
||||
|
new Endpoint("CareerProgressController", a -> get("/profile/me/career-progress").with(authentication(a))), |
||||
|
new Endpoint("MatchController", a -> get("/jobs/1/match").with(authentication(a))), |
||||
|
new Endpoint("TalentInvitationController", a -> get("/companies/me/talent-invitations").with(authentication(a)))); |
||||
|
} |
||||
|
|
||||
|
private static Authentication principal(String name) { |
||||
|
return new UsernamePasswordAuthenticationToken(name, "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4 独立复验:8/8 真实 Mapping 对非法主体均返回安全的 401") |
||||
|
void allEightMappingsRejectIllegalPrincipalsWithoutLeakage() throws Exception { |
||||
|
assertThat(endpoints()).hasSize(8); |
||||
|
for (Endpoint endpoint : endpoints()) { |
||||
|
for (String illegal : ILLEGAL) { |
||||
|
var response = mockMvc.perform(endpoint.request().apply(principal(illegal))) |
||||
|
.andReturn().getResponse(); |
||||
|
assertThat(response.getStatus()) |
||||
|
.as("%s principal=%s", endpoint.controller(), illegal).isEqualTo(401); |
||||
|
JsonNode body = JSON.readTree(response.getContentAsString()); |
||||
|
assertThat(body.path("code").asInt()).isEqualTo(401); |
||||
|
assertThat(body.path("message").asText()) |
||||
|
.doesNotContain("Exception", "java.", "principal", "NumberFormatException", |
||||
|
"not-a-number-a4", "12345678901234567890"); |
||||
|
assertThat(body.has("stackTrace")).isFalse(); |
||||
|
assertThat(body.has("trace")).isFalse(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4 独立复验:8/8 真实 Mapping 对匿名 token 均返回 401") |
||||
|
void allEightMappingsRejectAnonymousToken() throws Exception { |
||||
|
Authentication anonymous = new AnonymousAuthenticationToken("a4-key", "anonymousUser", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); |
||||
|
for (Endpoint endpoint : endpoints()) { |
||||
|
assertThat(mockMvc.perform(endpoint.request().apply(anonymous)).andReturn().getResponse().getStatus()) |
||||
|
.as(endpoint.controller()).isEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4 独立复验:8/8 真实 Mapping 的响应 body 与规范化响应头 requestId 同源") |
||||
|
void allEightMappingsUseNormalizedRequestId() throws Exception { |
||||
|
Authentication legal = principal("4242"); |
||||
|
for (Endpoint endpoint : endpoints()) { |
||||
|
var request = endpoint.request().apply(legal); |
||||
|
if (request instanceof org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder builder) { |
||||
|
builder.header("X-Request-Id", "a4/<unsafe value>" + "x".repeat(80)); |
||||
|
} |
||||
|
var response = mockMvc.perform(request).andReturn().getResponse(); |
||||
|
assertThat(response.getStatus()).as(endpoint.controller()).isNotEqualTo(401); |
||||
|
String requestId = response.getHeader("X-Request-Id"); |
||||
|
assertThat(requestId).isNotBlank().hasSizeLessThanOrEqualTo(32) |
||||
|
.doesNotContain("<", ">", " ", "\r", "\n"); |
||||
|
assertThat(response.getContentAsString()) |
||||
|
.contains("\"requestId\":\"" + requestId + "\""); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,214 @@ |
|||||
|
package com.mydreamjob.common.r01a4; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.bidirectionalmatch.BidirectionalMatchService; |
||||
|
import com.mydreamjob.bidirectionalmatch.MatchConversationService; |
||||
|
import com.mydreamjob.bidirectionalmatch.MatchRuleService; |
||||
|
import com.mydreamjob.careerprogress.CareerProgressService; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.feedback.CandidateFeedbackService; |
||||
|
import com.mydreamjob.match.MatchService; |
||||
|
import com.mydreamjob.talentdiscovery.TalentDiscoveryService; |
||||
|
import com.mydreamjob.talentinvitation.TalentInvitationService; |
||||
|
import org.junit.jupiter.api.DisplayName; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.security.authentication.AnonymousAuthenticationToken; |
||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; |
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.authority.AuthorityUtils; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
/** |
||||
|
* R01-A4 第四批:请求身份与 requestId 收束的特征测试。 |
||||
|
* |
||||
|
* <p>代表端点 URL 全部取自源码真实 Mapping。身份只来自 |
||||
|
* {@code CurrentUserAccessor#requireUserId()},requestId 只来自 |
||||
|
* {@code RequestIdAccessor#get(HttpServletRequest)}。</p> |
||||
|
* |
||||
|
* <p>对溢出/非数字主体不做整响应数字搜索;无 Token 请求由 Security 链提前返回, |
||||
|
* 本批只断言 401、不要求其携带 requestId。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class R01A4RequestContextMigrationIntegrationTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
@Autowired private RequestIdAccessor requestIdAccessor; |
||||
|
|
||||
|
@MockBean private CandidateFeedbackService feedbackService; |
||||
|
@MockBean private BidirectionalMatchService bidirectionalMatchService; |
||||
|
@MockBean private MatchConversationService matchConversationService; |
||||
|
@MockBean private MatchRuleService matchRuleService; |
||||
|
@MockBean private TalentDiscoveryService talentDiscoveryService; |
||||
|
@MockBean private CareerProgressService careerProgressService; |
||||
|
@MockBean private MatchService matchService; |
||||
|
@MockBean private TalentInvitationService talentInvitationService; |
||||
|
@MockBean private UserRepository userRepository; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||
|
|
||||
|
/** 8 个 Controller 的真实代表端点(GET,取自源码 Mapping)。 */ |
||||
|
private static final List<String> PATHS = List.of( |
||||
|
"/applications/1/feedback", // CandidateFeedbackController
|
||||
|
"/applications/1/match", // ApplicationMatchController
|
||||
|
"/companies/me/match-rule", // MatchRuleController
|
||||
|
"/profile/me/talent-discovery", // TalentDiscoveryController
|
||||
|
"/profile/me/career-progress", // CareerProgressController
|
||||
|
"/jobs/1/match", // MatchController
|
||||
|
"/companies/me/talent-invitations"); // TalentInvitationController
|
||||
|
|
||||
|
/** MatchConversationController 只有 POST 端点,单独以 POST 覆盖。 */ |
||||
|
private static final String CONVERSATION_PATH = "/applications/1/conversation"; |
||||
|
|
||||
|
private static final List<String> ILLEGAL = List.of( |
||||
|
"anonymousUser", "", " ", "not-a-number", "abc123", |
||||
|
"12345678901234567890", "0", "-1"); |
||||
|
|
||||
|
private static Authentication principal(String name) { |
||||
|
return new UsernamePasswordAuthenticationToken(name, "n/a", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); |
||||
|
} |
||||
|
|
||||
|
private List<String> allPaths() { |
||||
|
List<String> all = new java.util.ArrayList<>(PATHS); |
||||
|
all.add(CONVERSATION_PATH); |
||||
|
return all; |
||||
|
} |
||||
|
|
||||
|
// ── 身份 ─────────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4:八端点 × 八非法主体 一律 401(非 400/500)") |
||||
|
void illegalPrincipals_areAlways401() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
for (String p : ILLEGAL) { |
||||
|
int st = mockMvc.perform(get(path).with(authentication(principal(p)))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("path=%s principal='%s' 必须 401", path, p).isEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4:POST 端点(MatchConversation)非法主体同样 401") |
||||
|
void conversationEndpoint_illegalPrincipals_are401() throws Exception { |
||||
|
for (String p : ILLEGAL) { |
||||
|
int st = mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders |
||||
|
.post(CONVERSATION_PATH).with(authentication(principal(p)))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("principal='%s'", p).isEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4:匿名 token 与无 Token 均 401") |
||||
|
void anonymousAndNoToken_are401() throws Exception { |
||||
|
Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); |
||||
|
for (String path : allPaths()) { |
||||
|
assertThat(mockMvc.perform(get(path).with(authentication(anon))).andReturn() |
||||
|
.getResponse().getStatus()).as(path).isEqualTo(401); |
||||
|
mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4:错误体安全,不回显 principal/类名/堆栈") |
||||
|
void errorBody_isSafe() throws Exception { |
||||
|
for (String path : PATHS) { |
||||
|
for (String p : ILLEGAL) { |
||||
|
String body = mockMvc.perform(get(path).with(authentication(principal(p)))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
JsonNode json = MAPPER.readTree(body); |
||||
|
assertThat(json.path("code").asInt()).as("path=%s", path).isEqualTo(401); |
||||
|
String msg = json.path("message").asText(); |
||||
|
assertThat(msg).isIn("authentication required", "not authenticated"); |
||||
|
assertThat(msg).doesNotContain("Exception").doesNotContain("java.") |
||||
|
.doesNotContain("principal").doesNotContain("at com.mydreamjob"); |
||||
|
assertThat(json.has("stackTrace")).isFalse(); |
||||
|
assertThat(json.has("trace")).isFalse(); |
||||
|
if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) { |
||||
|
assertThat(msg).doesNotContain(p); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4:溢出主体无 NumberFormatException") |
||||
|
void overflow_hasNoNumberFormatException() throws Exception { |
||||
|
for (String path : allPaths()) { |
||||
|
String body = mockMvc.perform(get(path) |
||||
|
.with(authentication(principal("12345678901234567890")))) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
assertThat(body).doesNotContain("NumberFormatException"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4:合法主体进入原角色/归属判断(不被身份层拒绝)") |
||||
|
void legalPrincipal_reachesRoleLayer() throws Exception { |
||||
|
for (String path : allPaths()) { |
||||
|
int st = mockMvc.perform(get(path).with(authentication(principal("7")))) |
||||
|
.andReturn().getResponse().getStatus(); |
||||
|
assertThat(st).as("path=%s 合法主体不应 401", path).isNotEqualTo(401); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ── requestId ────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4:requestId 合法/空白/超长/非法字符 均与响应头同源") |
||||
|
void requestId_consistentAcrossInputs() throws Exception { |
||||
|
for (String sent : List.of("a4-legal-0001", " ", "w".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("<").doesNotContain(">").doesNotContain(" ") |
||||
|
.doesNotContain("\r").doesNotContain("\n"); |
||||
|
assertThat(hdr.length()).isLessThanOrEqualTo(32); |
||||
|
assertThat(r.getResponse().getContentAsString()).as("path=%s", path) |
||||
|
.contains("\"requestId\":\"" + hdr + "\""); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4:request attribute 优先于原始 header") |
||||
|
void requestAttributeWins() { |
||||
|
var req = new org.springframework.mock.web.MockHttpServletRequest(); |
||||
|
req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "a4-attr-wins"); |
||||
|
req.addHeader("X-Request-Id", "a4-header-loses"); |
||||
|
assertThat(requestIdAccessor.get(req)).isEqualTo("a4-attr-wins"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("A4:CR/LF 不进入规范化结果(直接经 Accessor)") |
||||
|
void crlfNeverEntersValue() { |
||||
|
for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "z\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