diff --git a/docs/r01-a2-independent-verification-20260917.md b/docs/r01-a2-independent-verification-20260917.md new file mode 100644 index 0000000..60001cc --- /dev/null +++ b/docs/r01-a2-independent-verification-20260917.md @@ -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")`) | +| 非法字符(`", "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); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/RequestContextMigrationIntegrationTest.java new file mode 100644 index 0000000..ac20570 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/RequestContextMigrationIntegrationTest.java @@ -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 收束的特征测试。 + * + *

覆盖 8 个 Controller 的代表端点:身份只来自 + * {@code CurrentUserAccessor#requireUserId()},requestId 只来自 + * {@code RequestIdAccessor#get(HttpServletRequest)}。

+ * + *

身份边界要求:任何非法主体统一 HTTP 401(不是 500,也不是 400), + * 响应不回显 principal,失败请求不产生业务写入。

+ * + *

requestId 边界要求:规范化后的值与响应头、响应体同源;超长值被既有规则收敛; + * 空白值重新生成;恶意换行/异常字符不得进入响应。

+ */ +@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 redisTemplate; + + /** 8 个 Controller 的一个代表端点。 */ + private record Endpoint(String name, String path) {} + + private static final List 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 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 malicious = List.of( + "", + "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"); + } +}