PATHS = List.of(
+ "/profile/me/deletion",
+ "/companies/me/verification",
+ "/admin/scoring/risk/overview",
+ "/admin/reports",
+ "/reports/me",
+ "/blocks/me",
+ "/notifications",
+ "/profile/me/job-recommendations");
+
+ private static final UsernamePasswordAuthenticationToken LEGAL =
+ new UsernamePasswordAuthenticationToken("42", "n/a",
+ AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
+
+ @Test
+ @DisplayName("A3 接线检测: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("A3 接线检测:响应体 requestId 来自 Accessor mock")
+ void requestIdComesFromAccessorBean() throws Exception {
+ when(currentUserAccessor.requireUserId()).thenReturn(42L);
+ when(requestIdAccessor.get(any())).thenReturn("a3-probe-from-accessor");
+
+ for (String path : PATHS) {
+ String body = mockMvc.perform(get(path).with(authentication(LEGAL)))
+ .andReturn().getResponse().getContentAsString();
+ assertThat(body).as("path=%s 响应体 requestId 应来自 Accessor", path)
+ .contains("\"requestId\":\"a3-probe-from-accessor\"");
+ }
+ Mockito.verify(requestIdAccessor, Mockito.atLeastOnce()).get(any());
+ }
+
+ @Test
+ @DisplayName("A3 接线检测:合法主体经 Accessor 后可达 Service")
+ void legalPrincipalReachesService() throws Exception {
+ when(currentUserAccessor.requireUserId()).thenReturn(42L);
+ when(requestIdAccessor.get(any())).thenReturn("a3-probe");
+
+ mockMvc.perform(get("/notifications").with(authentication(LEGAL))).andReturn();
+
+ verify(notificationService).list(eq(42L), anyInt(), anyInt());
+ }
+
+ @Test
+ @DisplayName("A3 接线检测:无 Token 仍 401(Security 链,不经 Controller)")
+ void noToken_is401() throws Exception {
+ for (String path : PATHS) {
+ mockMvc.perform(get(path)).andExpect(status().isUnauthorized());
+ }
+ }
+}
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3IndependentVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3IndependentVerificationTest.java
new file mode 100644
index 0000000..e23c3f7
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3IndependentVerificationTest.java
@@ -0,0 +1,220 @@
+package com.mydreamjob.common.r01a3;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.mydreamjob.auth.UserRepository;
+import com.mydreamjob.common.logging.RequestIdAccessor;
+import com.mydreamjob.deletion.AccountDeletionService;
+import com.mydreamjob.notification.NotificationService;
+import com.mydreamjob.recommendation.JobRecommendationService;
+import com.mydreamjob.risk.RiskGovernanceService;
+import com.mydreamjob.safety.BlockService;
+import com.mydreamjob.safety.ReportService;
+import com.mydreamjob.verification.CompanyVerificationService;
+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.mockito.ArgumentMatchers.anyLong;
+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-A3 独立复验(不复用实施者的测试类与断言)。
+ *
+ * 超长/溢出主体不做整响应数字搜索,解析 JSON 后仅检查业务字段。
+ *
+ * 说明:无 Token 请求由 Security 链提前返回 401,本批不要求其携带 requestId,
+ * 因此对该类请求**只断言 401**。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("integration-test")
+class A3IndependentVerificationTest {
+
+ @Autowired private MockMvc mockMvc;
+ @Autowired private RequestIdAccessor requestIdAccessor;
+
+ @MockBean private AccountDeletionService accountDeletionService;
+ @MockBean private CompanyVerificationService verificationService;
+ @MockBean private RiskGovernanceService riskGovernanceService;
+ @MockBean private ReportService reportService;
+ @MockBean private BlockService blockService;
+ @MockBean private NotificationService notificationService;
+ @MockBean private JobRecommendationService jobRecommendationService;
+ @MockBean private UserRepository userRepository;
+ @MockBean private RedisTemplate redisTemplate;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final List PATHS = List.of(
+ "/profile/me/deletion",
+ "/companies/me/verification",
+ "/admin/scoring/risk/overview",
+ "/admin/reports",
+ "/reports/me",
+ "/blocks/me",
+ "/notifications",
+ "/profile/me/job-recommendations");
+
+ 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"));
+ }
+
+ @Test
+ @DisplayName("独立复验:八端点 × 八非法主体 一律 401、无 500")
+ void illegalPrincipals_are401_never500() 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'", path, p).isEqualTo(401);
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("独立复验:匿名 token 与无 Token 均 401(无 Token 只断言 401)")
+ void anonymousAndNoToken_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()).as(path).isEqualTo(401);
+ mockMvc.perform(get(path)).andExpect(status().isUnauthorized());
+ }
+ }
+
+ @Test
+ @DisplayName("独立复验:错误体安全,不回显非数字标记/类名/堆栈")
+ 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("独立复验:Long 溢出主体无 NumberFormatException")
+ void overflow_hasNoNumberFormatException() throws Exception {
+ for (String path : PATHS) {
+ String body = mockMvc.perform(get(path)
+ .with(authentication(principal("12345678901234567890"))))
+ .andReturn().getResponse().getContentAsString();
+ assertThat(body).doesNotContain("NumberFormatException");
+ }
+ }
+
+ @Test
+ @DisplayName("独立复验:合法主体不被身份层拒绝")
+ void legalPrincipal_notRejectedByIdentity() throws Exception {
+ for (String path : PATHS) {
+ int st = mockMvc.perform(get(path).with(authentication(principal("7"))))
+ .andReturn().getResponse().getStatus();
+ assertThat(st).as("path=%s", path).isNotEqualTo(401);
+ }
+ }
+
+ @Test
+ @DisplayName("独立复验:角色层拒绝仍映射 403(身份通过后由角色层裁决)")
+ void roleDenial_stillMapsTo403() throws Exception {
+ when(redisTemplate.opsForValue())
+ .thenReturn(org.mockito.Mockito.mock(
+ org.springframework.data.redis.core.ValueOperations.class));
+
+ // CompanyVerificationController 的角色裁决委托给 Service,显式让其抛 FORBIDDEN,
+ // 验证"身份通过之后由角色层裁决"的语义未漂移为 401。
+ org.mockito.Mockito.doThrow(new com.mydreamjob.common.exception.BusinessException(
+ com.mydreamjob.common.exception.ErrorCode.FORBIDDEN))
+ .when(verificationService).assertAdminRole(anyLong());
+
+ int st = mockMvc.perform(get("/admin/companies/verifications")
+ .with(authentication(principal("7"))))
+ .andReturn().getResponse().getStatus();
+ assertThat(st).as("角色拒绝必须仍为 403,而非 401").isEqualTo(403);
+
+ // AdminRiskController 的角色裁决在 Controller 内(userRepository → FORBIDDEN)
+ when(userRepository.findById(anyLong()))
+ .thenReturn(java.util.Optional.of(com.mydreamjob.auth.User.builder()
+ .id(7L).phone("1").role(com.mydreamjob.auth.User.Role.STUDENT)
+ .roles(java.util.Set.of(com.mydreamjob.auth.User.Role.STUDENT))
+ .active(true).build()));
+ int st2 = mockMvc.perform(get("/admin/scoring/risk/overview")
+ .with(authentication(principal("7"))))
+ .andReturn().getResponse().getStatus();
+ assertThat(st2).as("非 ADMIN 必须仍为 403,而非 401").isEqualTo(403);
+ }
+
+ @Test
+ @DisplayName("独立复验:request attribute 优先于原始 header")
+ void requestAttributeWins() {
+ var req = new org.springframework.mock.web.MockHttpServletRequest();
+ req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "attr-a3-wins");
+ req.addHeader("X-Request-Id", "header-a3-loses");
+ assertThat(requestIdAccessor.get(req)).isEqualTo("attr-a3-wins");
+ }
+
+ @Test
+ @DisplayName("独立复验:响应 body 使用规范化 requestId(与响应头同源)")
+ void responseBodyUsesNormalizedRequestId() throws Exception {
+ for (String sent : List.of("a3v-legal-1", " ", "z".repeat(90), "x", "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);
+ String body = r.getResponse().getContentAsString();
+ assertThat(body).as("path=%s", path)
+ .contains("\"requestId\":\"" + hdr + "\"");
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("独立复验:CR/LF 不入规范化结果(直接经 Accessor)")
+ void crlfNeverEntersValue() {
+ for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "x\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");
+ }
+ }
+}
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java
new file mode 100644
index 0000000..da611ee
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java
@@ -0,0 +1,195 @@
+package com.mydreamjob.common.r01a3;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.mydreamjob.auth.UserRepository;
+import com.mydreamjob.common.logging.RequestIdAccessor;
+import com.mydreamjob.deletion.AccountDeletionService;
+import com.mydreamjob.notification.NotificationService;
+import com.mydreamjob.recommendation.JobRecommendationService;
+import com.mydreamjob.risk.RiskGovernanceService;
+import com.mydreamjob.safety.BlockService;
+import com.mydreamjob.safety.ReportService;
+import com.mydreamjob.security.CurrentUserAccessor;
+import com.mydreamjob.verification.CompanyVerificationService;
+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-A3 第三批:请求身份与 requestId 收束的特征测试。
+ *
+ * 覆盖 8 个 Controller 的代表端点(以源码真实 Mapping 为准)。
+ *
+ * 对溢出/非数字主体**不做整响应数字子串搜索**,而是解析 JSON 后只检查业务字段。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("integration-test")
+class R01A3RequestContextMigrationIntegrationTest {
+
+ @Autowired private MockMvc mockMvc;
+ @Autowired private RequestIdAccessor requestIdAccessor;
+
+ @MockBean private AccountDeletionService accountDeletionService;
+ @MockBean private CompanyVerificationService verificationService;
+ @MockBean private RiskGovernanceService riskGovernanceService;
+ @MockBean private ReportService reportService;
+ @MockBean private BlockService blockService;
+ @MockBean private NotificationService notificationService;
+ @MockBean private JobRecommendationService jobRecommendationService;
+ @MockBean private UserRepository userRepository;
+ @MockBean private RedisTemplate redisTemplate;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** 8 个 Controller 的真实代表端点。 */
+ private static final List PATHS = List.of(
+ "/profile/me/deletion", // AccountDeletionController
+ "/companies/me/verification", // CompanyVerificationController
+ "/admin/scoring/risk/overview", // AdminRiskController
+ "/admin/reports", // AdminReportController
+ "/reports/me", // ReportController
+ "/blocks/me", // BlockController
+ "/notifications", // NotificationController
+ "/profile/me/job-recommendations"); // JobRecommendationController
+
+ private static final List ILLEGAL = List.of(
+ "anonymousUser", "", " ", "not-a-number", "abc123",
+ "12345678901234567890", "0", "-1");
+
+ private static Authentication auth(String name) {
+ return new UsernamePasswordAuthenticationToken(name, "n/a",
+ AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
+ }
+
+ // ── 身份矩阵 ─────────────────────────────────────────────
+
+ @Test
+ @DisplayName("A3:八端点 × 八非法主体 一律 401(非 400/500)")
+ void illegalPrincipals_areAlways401() throws Exception {
+ for (String path : PATHS) {
+ for (String p : ILLEGAL) {
+ int st = mockMvc.perform(get(path).with(authentication(auth(p))))
+ .andReturn().getResponse().getStatus();
+ assertThat(st).as("path=%s principal='%s' 必须 401", path, p).isEqualTo(401);
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("A3:匿名 token 与无认证均 401")
+ void anonymousAndMissing_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()).as(path).isEqualTo(401);
+ assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus())
+ .as(path).isEqualTo(401);
+ }
+ }
+
+ @Test
+ @DisplayName("A3:错误体为固定安全文案,不回显类名/堆栈/principal")
+ void errorBody_isSafeAndDoesNotEchoPrincipal() throws Exception {
+ for (String path : PATHS) {
+ for (String p : ILLEGAL) {
+ String body = mockMvc.perform(get(path).with(authentication(auth(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("A3:溢出主体不产生 NumberFormatException(响应中无该异常)")
+ void overflowPrincipal_hasNoNumberFormatException() throws Exception {
+ for (String path : PATHS) {
+ String body = mockMvc.perform(get(path)
+ .with(authentication(auth("12345678901234567890"))))
+ .andReturn().getResponse().getContentAsString();
+ assertThat(body).doesNotContain("NumberFormatException");
+ }
+ }
+
+ @Test
+ @DisplayName("A3:合法主体继续进入原角色/归属检查(不被身份层拒绝)")
+ void legalPrincipal_reachesRoleLayer() throws Exception {
+ for (String path : PATHS) {
+ int st = mockMvc.perform(get(path).with(authentication(auth("7"))))
+ .andReturn().getResponse().getStatus();
+ assertThat(st).as("path=%s 合法主体不应 401", path).isNotEqualTo(401);
+ }
+ }
+
+ // ── requestId 矩阵 ───────────────────────────────────────
+
+ @Test
+ @DisplayName("A3:requestId 合法/空白/超长/非法字符 均同源")
+ void requestId_consistentAcrossInputs() throws Exception {
+ for (String sent : List.of("a3-legal-0001", " ", "y".repeat(80), "", "a b")) {
+ for (String path : PATHS) {
+ var r = mockMvc.perform(get(path).header("X-Request-Id", sent)
+ .with(authentication(auth("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);
+ String body = r.getResponse().getContentAsString();
+ assertThat(body).as("path=%s 响应体 requestId 应与响应头同源", path)
+ .contains("\"requestId\":\"" + hdr + "\"");
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("A3:已存在规范化 request attribute 时以 attribute 为准")
+ void requestAttributeWinsOverHeader() {
+ var req = new org.springframework.mock.web.MockHttpServletRequest();
+ req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "attr-wins");
+ req.addHeader("X-Request-Id", "header-loses");
+ assertThat(requestIdAccessor.get(req)).isEqualTo("attr-wins");
+ }
+
+ @Test
+ @DisplayName("A3:CR/LF 不进入规范化结果(直接调用 Accessor)")
+ void crlfNeverEntersNormalizedValue() {
+ for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "a\r\nX-Injected: 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");
+ }
+ }
+}