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"); } } }