" + "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 + "\"");
+ }
+ }
+}
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java
new file mode 100644
index 0000000..95a9468
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java
@@ -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 收束的特征测试。
+ *
+ * 代表端点 URL 全部取自源码真实 Mapping。身份只来自
+ * {@code CurrentUserAccessor#requireUserId()},requestId 只来自
+ * {@code RequestIdAccessor#get(HttpServletRequest)}。
+ *
+ * 对溢出/非数字主体不做整响应数字搜索;无 Token 请求由 Security 链提前返回,
+ * 本批只断言 401、不要求其携带 requestId。
+ */
+@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 redisTemplate;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** 8 个 Controller 的真实代表端点(GET,取自源码 Mapping)。 */
+ private static final List 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 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 allPaths() {
+ List 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), "", "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");
+ }
+ }
+}