把两个 Accessor 与相关 Service 全部替换为 mock,证明 5 个 Controller 确实通过
+ * 既有 Accessor 取得 requestId(以及 FairnessMonitoring 的身份)。
+ * 删除任一 Accessor 调用都会使本测试失败。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("integration-test")
+class A6AccessorWiringIndependentTest {
+
+ @Autowired private MockMvc mockMvc;
+
+ @MockBean private CurrentUserAccessor currentUserAccessor;
+ @MockBean private RequestIdAccessor requestIdAccessor;
+
+ @MockBean private AuthService authService;
+ @MockBean private SmsVerificationService smsVerificationService;
+ @MockBean private JobService jobService;
+ @MockBean private CompanyService companyService;
+ @MockBean private FairnessMonitoringService fairnessMonitoringService;
+ @MockBean private RedisTemplate redisTemplate;
+
+ private static final UsernamePasswordAuthenticationToken LEGAL =
+ new UsernamePasswordAuthenticationToken("42", "n/a",
+ AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
+
+ private static final String API_INDEX = "/";
+ private static final String HEALTH = "/health";
+ private static final String AUTH_ME = "/auth/me";
+ private static final String JOBS = "/jobs";
+ private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift";
+
+ private static final String[] ALL = {API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT};
+
+ @Test
+ @DisplayName("独立复验:5 个 Controller 的响应体 requestId 均来自 RequestIdAccessor")
+ void allFiveControllers_useRequestIdAccessor() throws Exception {
+ when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep-request-id");
+
+ for (String path : ALL) {
+ String body = mockMvc.perform(get(path).with(authentication(LEGAL)))
+ .andReturn().getResponse().getContentAsString();
+ assertThat(body)
+ .as("path=%s 响应体 requestId 必须来自 Accessor mock", path)
+ .contains("\"requestId\":\"a6-indep-request-id\"");
+ }
+
+ verify(requestIdAccessor, Mockito.atLeast(ALL.length)).get(any(HttpServletRequest.class));
+ }
+
+ @Test
+ @DisplayName("独立复验:Accessor 返回 4242 时 FairnessMonitoring Service 收到 4242")
+ void fairnessServiceReceivesAccessorUserId() throws Exception {
+ when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep");
+ when(currentUserAccessor.requireUserId()).thenReturn(4242L);
+
+ mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn();
+
+ verify(fairnessMonitoringService).drift(eq(4242L));
+ verify(currentUserAccessor, Mockito.atLeastOnce()).requireUserId();
+ }
+
+ @Test
+ @DisplayName("独立复验:Accessor 抛未登录时 FairnessMonitoring 返回 401 且 Service 零调用")
+ void accessorThrowsUnauthenticated_returns401_noServiceCall() throws Exception {
+ when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep");
+ when(currentUserAccessor.requireUserId())
+ .thenThrow(new InsufficientAuthenticationException("authentication required"));
+
+ var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn();
+ assertThat(r.getResponse().getStatus()).isEqualTo(401);
+ verify(fairnessMonitoringService, never()).drift(any());
+ }
+
+ @Test
+ @DisplayName("独立复验:合法身份下 FairnessMonitoring 其余端点同样经 Accessor 取 userId")
+ void fairnessOtherEndpoints_useAccessor() throws Exception {
+ when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep");
+ when(currentUserAccessor.requireUserId()).thenReturn(77L);
+
+ mockMvc.perform(get("/admin/scoring/fairness/overview")
+ .param("period", "2026-09").with(authentication(LEGAL))).andReturn();
+ verify(fairnessMonitoringService).overview(eq(77L), eq("2026-09"));
+
+ mockMvc.perform(get("/admin/scoring/fairness/ranking-stability")
+ .with(authentication(LEGAL))).andReturn();
+ verify(fairnessMonitoringService).rankingStability(eq(77L));
+
+ mockMvc.perform(get("/admin/scoring/fairness/dimensions")
+ .param("period", "2026-09").with(authentication(LEGAL))).andReturn();
+ verify(fairnessMonitoringService).dimensions(eq(77L), eq("2026-09"), any());
+ }
+
+ @Test
+ @DisplayName("独立复验:需认证端点无 Token 时由 Security 链返回 401(公开端点不在此列)")
+ void noToken_is401OnProtectedEndpoints() throws Exception {
+ // `/`、`/health`、`/info`、`GET /jobs` 为公开端点,不参与此断言
+ for (String path : new String[]{AUTH_ME, FAIRNESS_DRIFT}) {
+ assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus())
+ .as(path).isEqualTo(401);
+ }
+ }
+
+ @Test
+ @DisplayName("独立复验:JobController 企业侧端点身份来自 Accessor(未被重复实现)")
+ void jobController_identityFromAccessor() throws Exception {
+ when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep");
+ when(currentUserAccessor.requireUserId()).thenReturn(999L);
+
+ // 企业侧创建岗位端点:其 resolveCompanyId() 必须经 CurrentUserAccessor 取身份
+ mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+ .post("/jobs")
+ .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
+ .content("{\"title\":\"t\",\"description\":\"d\",\"city\":\"上海\",\"employmentType\":\"FULL_TIME\"}")
+ .with(authentication(LEGAL)))
+ .andReturn();
+
+ verify(currentUserAccessor, Mockito.atLeastOnce()).requireUserId();
+ }
+}
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java
new file mode 100644
index 0000000..ccd94ce
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java
@@ -0,0 +1,111 @@
+package com.mydreamjob.common.r01a6;
+
+import com.mydreamjob.auth.AuthService;
+import com.mydreamjob.auth.SmsVerificationService;
+import com.mydreamjob.common.logging.RequestIdAccessor;
+import com.mydreamjob.job.JobService;
+import com.mydreamjob.company.CompanyService;
+import com.mydreamjob.scoringmonitoring.FairnessMonitoringService;
+import com.mydreamjob.security.CurrentUserAccessor;
+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 static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+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;
+
+/**
+ * R01-A6 接线变异检测:证明 5 个 Controller 的 requestId(及 FairnessMonitoring 的身份)
+ * 确实经过既有 Accessor。
+ *
+ * 修复前,这 5 个 Controller 各自持有 {@code getRequestId}/{@code requestId} 直读
+ * {@code X-Request-Id},FairnessMonitoring 还直接读 {@code SecurityContextHolder};
+ * 因此 mock Accessor 的返回值不会出现在响应中 —— 本测试在修复前必然失败。
+ *
+ * 说明:FairnessMonitoring 的既有身份解析带有 {@code try/catch NumberFormatException},
+ * 其行为本身是安全的(401 而非 500),故本测试以"未调用 Accessor"作为失败依据,
+ * **不编造 500**。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("integration-test")
+class A6AccessorWiringProbeTest {
+
+ @Autowired private MockMvc mockMvc;
+
+ @MockBean private CurrentUserAccessor currentUserAccessor;
+ @MockBean private RequestIdAccessor requestIdAccessor;
+
+ @MockBean private AuthService authService;
+ @MockBean private SmsVerificationService smsVerificationService;
+ @MockBean private JobService jobService;
+ @MockBean private CompanyService companyService;
+ @MockBean private FairnessMonitoringService fairnessMonitoringService;
+ @MockBean private RedisTemplate redisTemplate;
+
+ private static final UsernamePasswordAuthenticationToken LEGAL =
+ new UsernamePasswordAuthenticationToken("42", "n/a",
+ AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
+
+ /** 5 个 Controller 的真实代表端点(均为 GET,且不需额外必需查询参数)。 */
+ private static final String API_INDEX = "/"; // ApiController
+ private static final String HEALTH = "/health"; // SystemController
+ private static final String AUTH_ME = "/auth/me"; // AuthController
+ private static final String JOBS = "/jobs"; // JobController
+ private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift"; // FairnessMonitoringController
+
+ @Test
+ @DisplayName("A6 接线检测:5 个 Controller 的响应体 requestId 均来自 RequestIdAccessor")
+ void requestIdComesFromAccessorAcrossFiveControllers() throws Exception {
+ when(requestIdAccessor.get(any())).thenReturn("a6-probe-request-id");
+
+ for (String path : new String[]{API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT}) {
+ String body = mockMvc.perform(get(path).with(authentication(LEGAL)))
+ .andReturn().getResponse().getContentAsString();
+ assertThat(body)
+ .as("path=%s 响应体 requestId 应来自 Accessor(修复前为直读 header 的 null)", path)
+ .contains("\"requestId\":\"a6-probe-request-id\"");
+ }
+
+ verify(requestIdAccessor, atLeastOnce()).get(any());
+ }
+
+ @Test
+ @DisplayName("A6 接线检测:FairnessMonitoring 的身份来自 CurrentUserAccessor")
+ void fairnessMonitoringUsesCurrentUserAccessor() throws Exception {
+ when(requestIdAccessor.get(any())).thenReturn("a6-probe");
+ when(currentUserAccessor.requireUserId()).thenReturn(5151L);
+
+ mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn();
+
+ verify(fairnessMonitoringService).drift(5151L);
+ verify(currentUserAccessor, atLeastOnce()).requireUserId();
+ }
+
+ @Test
+ @DisplayName("A6 接线检测:Accessor 抛未登录时 FairnessMonitoring 返回 401(非 500)")
+ void accessorThrowsUnauthenticated_fairnessReturns401() throws Exception {
+ when(requestIdAccessor.get(any())).thenReturn("a6-probe");
+ when(currentUserAccessor.requireUserId())
+ .thenThrow(new InsufficientAuthenticationException("authentication required"));
+
+ var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn();
+ assertThat(r.getResponse().getStatus()).isEqualTo(401);
+ verify(fairnessMonitoringService, Mockito.never()).drift(any());
+ }
+}
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6IndependentVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6IndependentVerificationTest.java
new file mode 100644
index 0000000..a990ce3
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6IndependentVerificationTest.java
@@ -0,0 +1,197 @@
+package com.mydreamjob.common.r01a6;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.mydreamjob.auth.AuthService;
+import com.mydreamjob.auth.SmsVerificationService;
+import com.mydreamjob.common.logging.RequestIdAccessor;
+import com.mydreamjob.company.CompanyService;
+import com.mydreamjob.job.JobService;
+import com.mydreamjob.scoringmonitoring.FairnessMonitoringService;
+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.jdbc.core.JdbcTemplate;
+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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+
+/**
+ * R01-A6 独立复验:身份边界、requestId 语义、副作用与夹具反证。
+ *
+ * 使用**真实** {@code CurrentUserAccessor} 与真实 SecurityFilterChain(不 mock Accessor)。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("integration-test")
+class A6IndependentVerificationTest {
+
+ @Autowired private MockMvc mockMvc;
+ @Autowired private RequestIdAccessor requestIdAccessor;
+ @Autowired private JdbcTemplate jdbcTemplate;
+
+ @MockBean private AuthService authService;
+ @MockBean private SmsVerificationService smsVerificationService;
+ @MockBean private JobService jobService;
+ @MockBean private CompanyService companyService;
+ @MockBean private FairnessMonitoringService fairnessMonitoringService;
+ @MockBean private RedisTemplate redisTemplate;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift";
+ private static final List REQUEST_ID_PATHS =
+ List.of("/", "/health", "/auth/me", "/jobs", FAIRNESS_DRIFT);
+
+ private static final List ILLEGAL =
+ List.of("anonymousUser", "not-a-number", "99999999999999999999", "0", "-1");
+
+ private static Authentication principal(String name) {
+ return new UsernamePasswordAuthenticationToken(name, "n/a",
+ AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
+ }
+
+ // ── 1. FairnessMonitoring 身份边界(真实 Accessor)────────
+
+ @Test
+ @DisplayName("独立复验:非法主体访问 FairnessMonitoring 一律 401(不得 400/500)")
+ void fairness_illegalPrincipals_are401_not400or500() throws Exception {
+ for (String p : ILLEGAL) {
+ var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn();
+ int st = r.getResponse().getStatus();
+ assertThat(st).as("principal='%s' 必须 401(实际 %d)", p, st).isEqualTo(401);
+
+ JsonNode json = MAPPER.readTree(r.getResponse().getContentAsString());
+ assertThat(json.path("code").asInt()).isEqualTo(401);
+ String msg = json.path("message").asText();
+ assertThat(msg).isIn("authentication required", "not authenticated");
+ assertThat(msg)
+ .doesNotContain("Exception").doesNotContain("java.")
+ .doesNotContain("principal").doesNotContain("anonymousUser")
+ .doesNotContain("com.mydreamjob").doesNotContain("NumberFormat");
+ assertThat(json.has("stackTrace")).isFalse();
+ assertThat(json.has("trace")).isFalse();
+ // 只检查稳定字段 message,不对整段 JSON 做数字搜索
+ if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) {
+ assertThat(msg).doesNotContain(p);
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("独立复验:非法主体与匿名访问 FairnessMonitoring 时 Service 零调用")
+ void fairness_illegalPrincipals_produceNoServiceCall() throws Exception {
+ Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser",
+ AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
+ for (String p : ILLEGAL) {
+ mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn();
+ }
+ mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(anon))).andReturn();
+ mockMvc.perform(get(FAIRNESS_DRIFT)).andReturn();
+ verify(fairnessMonitoringService, never()).drift(org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ @DisplayName("独立复验:合法数字主体可进入业务层(不被身份层拒绝)")
+ void fairness_legalPrincipal_reachesService() throws Exception {
+ int st = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal("7"))))
+ .andReturn().getResponse().getStatus();
+ assertThat(st).as("合法主体不得被判未登录").isNotEqualTo(401);
+ }
+
+ // ── 2. requestId 语义(5 个 Controller)──────────────────
+
+ @Test
+ @DisplayName("独立复验:5 个 Controller 的 requestId 规范化与头体同源")
+ void requestId_semanticsAcrossFiveControllers() throws Exception {
+ for (String sent : List.of("a6v-legal-1", " ", "q".repeat(90), "x", "a b")) {
+ for (String path : REQUEST_ID_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("独立复验:请求属性优先于原始 Header")
+ void requestAttributeWins() {
+ var req = new org.springframework.mock.web.MockHttpServletRequest();
+ req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "a6v-attr");
+ req.addHeader("X-Request-Id", "a6v-header");
+ assertThat(requestIdAccessor.get(req)).isEqualTo("a6v-attr");
+ }
+
+ @Test
+ @DisplayName("独立复验:CR/LF 不进入规范化结果(直接经 Accessor,不依赖 MockMvc 接受非法 header)")
+ void crlfNeverEntersValue() {
+ for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "k\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");
+ }
+ }
+
+ // ── 3. 副作用快照(行级、显式列、稳定排序)──────────────
+
+ private static final List TABLES = List.of(
+ new String[]{"SCORING_RISK_EVENTS", "ID, SUBJECT_USER_ID, SOURCE_ACTION, SEVERITY, STATUS, VERSION, CREATED_AT"},
+ new String[]{"SCORING_RISK_ACTIONS", "ID, RISK_EVENT_ID, OPERATOR_USER_ID, ACTION, REASON_CODE, CREATED_AT"},
+ new String[]{"JOBS", "ID, COMPANY_ID, TITLE, STATUS, CREATED_AT, UPDATED_AT"},
+ new String[]{"USERS", "ID, PHONE, NICKNAME, ROLE, IS_ACTIVE"},
+ new String[]{"USER_ROLES", "USER_ID, ROLE"},
+ new String[]{"AUDIT_LOGS", "ID, USER_ID, ACTION, ENTITY_TYPE, ENTITY_ID, CREATED_AT"},
+ new String[]{"NOTIFICATIONS", "ID, USER_ID, TYPE, TITLE, IS_READ, CREATED_AT"},
+ new String[]{"MESSAGES", "ID, CONVERSATION_ID, SENDER_USER_ID, CONTENT, CREATED_AT"},
+ new String[]{"CANDIDATE_DIMENSION_SCORES", "ID, STUDENT_USER_ID, DIMENSION, PERIOD, SCORE, STATUS, VERSION"});
+
+ private Map>> snapshot() {
+ Map>> out = new LinkedHashMap<>();
+ for (String[] t : TABLES) {
+ out.put(t[0], jdbcTemplate.queryForList(
+ "SELECT " + t[1] + " FROM " + t[0] + " ORDER BY " + t[1]));
+ }
+ return out;
+ }
+
+ @Test
+ @DisplayName("独立复验:非法身份请求前后九张核心表逐字段零变化")
+ void illegalIdentity_producesNoBusinessWrite() throws Exception {
+ Map>> before = snapshot();
+
+ for (String p : ILLEGAL) {
+ mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn();
+ mockMvc.perform(get(FAIRNESS_DRIFT).header("Authorization", "Bearer " + p)).andReturn();
+ }
+ mockMvc.perform(get(FAIRNESS_DRIFT)).andReturn();
+
+ Map>> after = snapshot();
+ for (String[] t : TABLES) {
+ assertThat(after.get(t[0])).as("%s 必须逐字段零变化", t[0]).isEqualTo(before.get(t[0]));
+ }
+ }
+}
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java
new file mode 100644
index 0000000..6cfbf59
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java
@@ -0,0 +1,167 @@
+package com.mydreamjob.common.r01a6;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.mydreamjob.auth.AuthService;
+import com.mydreamjob.auth.SmsVerificationService;
+import com.mydreamjob.common.logging.RequestIdAccessor;
+import com.mydreamjob.company.CompanyService;
+import com.mydreamjob.job.JobService;
+import com.mydreamjob.scoringmonitoring.FairnessMonitoringService;
+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.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+
+/**
+ * R01-A6 特征测试:FairnessMonitoringController 的身份边界 + 5 个 Controller 的 requestId 规范化。
+ *
+ * 身份断言只针对 {@code /admin/scoring/fairness/*}(其余 4 个 Controller 的代表端点为
+ * 公开端点,无身份解析,仅覆盖 requestId)。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("integration-test")
+class R01A6RequestContextMigrationIntegrationTest {
+
+ @Autowired private MockMvc mockMvc;
+ @Autowired private RequestIdAccessor requestIdAccessor;
+
+ @MockBean private AuthService authService;
+ @MockBean private SmsVerificationService smsVerificationService;
+ @MockBean private JobService jobService;
+ @MockBean private CompanyService companyService;
+ @MockBean private FairnessMonitoringService fairnessMonitoringService;
+ @MockBean private RedisTemplate redisTemplate;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final String API_INDEX = "/";
+ private static final String HEALTH = "/health";
+ private static final String AUTH_ME = "/auth/me";
+ private static final String JOBS = "/jobs";
+ private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift";
+
+ private static final List REQUEST_ID_PATHS =
+ List.of(API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT);
+
+ private static final List ILLEGAL = List.of(
+ "anonymousUser", "not-a-number", "99999999999999999999", "0", "-1");
+
+ private static Authentication principal(String name) {
+ return new UsernamePasswordAuthenticationToken(name, "n/a",
+ AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
+ }
+
+ // ── 身份边界(FairnessMonitoringController)───────────────
+
+ @Test
+ @DisplayName("A6:非法主体访问 FairnessMonitoring 一律 401、无 500、不回显主体")
+ void fairnessMonitoring_illegalPrincipals_are401() throws Exception {
+ for (String p : ILLEGAL) {
+ var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn();
+ int st = r.getResponse().getStatus();
+ assertThat(st).as("principal='%s' 必须 401", p).isEqualTo(401);
+
+ JsonNode json = MAPPER.readTree(r.getResponse().getContentAsString());
+ assertThat(json.path("code").asInt()).isEqualTo(401);
+ String msg = json.path("message").asText();
+ assertThat(msg).isIn("authentication required", "not authenticated");
+ assertThat(msg)
+ .doesNotContain("Exception").doesNotContain("java.")
+ .doesNotContain("principal").doesNotContain("anonymousUser")
+ .doesNotContain("com.mydreamjob").doesNotContain("NumberFormat");
+ 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("A6:非法主体访问 FairnessMonitoring 时业务 Service 零调用")
+ void fairnessMonitoring_illegalPrincipals_produceNoServiceCall() throws Exception {
+ for (String p : ILLEGAL) {
+ mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn();
+ }
+ verify(fairnessMonitoringService, never()).drift(any());
+ }
+
+ @Test
+ @DisplayName("A6:匿名 token 与无 Token 均 401")
+ void fairnessMonitoring_anonymousAndNoToken_are401() throws Exception {
+ Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser",
+ AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
+ assertThat(mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(anon)))
+ .andReturn().getResponse().getStatus()).isEqualTo(401);
+ assertThat(mockMvc.perform(get(FAIRNESS_DRIFT)).andReturn()
+ .getResponse().getStatus()).isEqualTo(401);
+ }
+
+ @Test
+ @DisplayName("A6:合法数字主体仍可进入业务层(不被身份层拒绝)")
+ void fairnessMonitoring_legalPrincipal_reachesService() throws Exception {
+ int st = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal("7"))))
+ .andReturn().getResponse().getStatus();
+ assertThat(st).as("合法主体不得被判未登录").isNotEqualTo(401);
+ }
+
+ // ── requestId 规范化(5 个 Controller)───────────────────
+
+ @Test
+ @DisplayName("A6:5 个 Controller 的 requestId 合法/空白/超长/非法字符 均与响应头同源")
+ void requestId_consistentAcrossFiveControllers() throws Exception {
+ for (String sent : List.of("a6-legal-0001", " ", "v".repeat(80), "", "a b")) {
+ for (String path : REQUEST_ID_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("A6:request attribute 优先于原始 header")
+ void requestAttributeWins() {
+ var req = new org.springframework.mock.web.MockHttpServletRequest();
+ req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "a6-attr-wins");
+ req.addHeader("X-Request-Id", "a6-header-loses");
+ assertThat(requestIdAccessor.get(req)).isEqualTo("a6-attr-wins");
+ }
+
+ @Test
+ @DisplayName("A6:CR/LF 不进入规范化结果(直接经 Accessor)")
+ void crlfNeverEntersValue() {
+ for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "y\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");
+ }
+ }
+}