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