You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
220 lines
11 KiB
220 lines
11 KiB
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 独立复验(不复用实施者的测试类与断言)。
|
|
*
|
|
* <p>超长/溢出主体不做整响应数字搜索,解析 JSON 后仅检查业务字段。</p>
|
|
*
|
|
* <p>说明:无 Token 请求由 Security 链提前返回 401,本批不要求其携带 requestId,
|
|
* 因此对该类请求**只断言 401**。</p>
|
|
*/
|
|
@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<String, Object> redisTemplate;
|
|
|
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
|
|
private static final List<String> 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<String> 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), "<b>x</b>", "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");
|
|
}
|
|
}
|
|
}
|
|
|