package com.mydreamjob.deletion; import com.mydreamjob.auth.JwtTokenProvider; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; 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.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDateTime; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; 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.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * 注销守卫拦截器集成测试。 * *

覆盖:PENDING 阻断 POST/PUT/PATCH/DELETE(9302),GET/白名单放行, * NONE 放行,COMPLETED 普通业务接口返回 9303,注销接口白名单放行。 */ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") class DeletionGuardInterceptorTest { @Autowired private MockMvc mockMvc; @Autowired private JwtTokenProvider jwtTokenProvider; @Autowired private UserRepository userRepository; @Autowired private AccountDeletionRepository deletionRepository; @MockBean private org.springframework.data.redis.core.RedisTemplate redisTemplate; private static final AtomicLong PHONE = new AtomicLong(932_000_000L); @BeforeEach @SuppressWarnings("unchecked") void setUp() { org.springframework.data.redis.core.ValueOperations ops = mock(org.springframework.data.redis.core.ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(anyString())).thenReturn(false); } @AfterEach void tearDown() { // 清理共享 H2 库(integrationdb)中的注销记录,避免残留 PENDING/COMPLETED // 污染后续其他集成测试类(其使用固定 userId 触发注销守卫)。 deletionRepository.deleteAll(); userRepository.deleteAll(); } private long createStudent() { User u = userRepository.save(User.builder() .phone(String.valueOf(PHONE.getAndIncrement())) .roles(Set.of(User.Role.STUDENT)) .role(User.Role.STUDENT) .active(true) .build()); return u.getId(); } private String token(long uid) { return com.mydreamjob.auth.support.TestAuthTokens.bearer(uid, "STUDENT"); } private void setStatus(long uid, DeletionStatus status, LocalDateTime effectiveAt) { deletionRepository.save(AccountDeletion.builder() .userId(uid).status(status).effectiveAt(effectiveAt).version(0L).build()); } @Test void pending_blocksWrites_returns9302() throws Exception { long uid = createStudent(); setStatus(uid, DeletionStatus.PENDING, LocalDateTime.now().plusDays(30)); mockMvc.perform(put("/profile/me/privacy") .header("Authorization", token(uid)) .contentType(MediaType.APPLICATION_JSON) .content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(9302)); mockMvc.perform(put("/profile/me") .header("Authorization", token(uid)) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(9302)); mockMvc.perform(post("/applications") .header("Authorization", token(uid))) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(9302)); } @Test void pending_allowsGet_andDeletionWhitelist() throws Exception { long uid = createStudent(); setStatus(uid, DeletionStatus.PENDING, LocalDateTime.now().plusDays(30)); mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(uid))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("PENDING")); // GET 默认放行 mockMvc.perform(get("/profile/me/privacy").header("Authorization", token(uid))) .andExpect(status().isOk()); } @Test void noneUser_isNotBlocked() throws Exception { long uid = createStudent(); mockMvc.perform(put("/profile/me/privacy") .header("Authorization", token(uid)) .contentType(MediaType.APPLICATION_JSON) .content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}")) .andExpect(status().isOk()); mockMvc.perform(get("/profile/me/privacy").header("Authorization", token(uid))) .andExpect(status().isOk()); } @Test void completed_blocksOrdinaryBusiness_with9303() throws Exception { long uid = createStudent(); setStatus(uid, DeletionStatus.COMPLETED, null); // 普通业务 GET 同样被 9303 拒绝(旧 JWT 即便未过期也失效) mockMvc.perform(get("/profile/me/privacy").header("Authorization", token(uid))) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(9303)); mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(uid))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("COMPLETED")); mockMvc.perform(post("/profile/me/deletion").header("Authorization", token(uid))) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(9301)); } @Test void unauthenticated_isNotBlockedByInterceptor() throws Exception { mockMvc.perform(get("/profile/me/privacy")) .andExpect(status().isUnauthorized()); } /** * R01-A 回归:本拦截器注册在 {@code /**} 上,先于所有 Controller 执行, * 因此非法主体绝不能在拦截器层逃逸为 500。 * *

修复前 {@code Long.valueOf(auth.getName())} 对 Long 溢出主体( * {@code "99999999999999999999"})抛出 {@code NumberFormatException}, * 在到达任何 Controller 之前就返回全站 HTTP 500。

*/ @Test void illegalOrOverflowingPrincipal_returns401InsteadOf500() throws Exception { for (String principal : new String[]{ "99999999999999999999", "٤٢", "+42", "-1", "0", " ", "not-a-number"}) { mockMvc.perform(get("/profile/me/privacy") .with(authentication(UsernamePasswordAuthenticationToken.authenticated( principal, null, java.util.List.of())))) .andExpect(status().isUnauthorized()); } } @Test void options_isAllowed() throws Exception { long uid = createStudent(); setStatus(uid, DeletionStatus.PENDING, LocalDateTime.now().plusDays(30)); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders .options("/profile/me/privacy").header("Authorization", token(uid))) .andExpect(status().isOk()); } }