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