package com.mydreamjob.profile; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.messaging.ChatResumeLifecycle; import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; import com.mydreamjob.profile.storage.ResumeFileValidator; import com.mydreamjob.profile.storage.ResumeOwnershipVerifier; import com.mydreamjob.profile.storage.ResumeStorageService; import com.mydreamjob.security.UserRoleGuard; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; import java.nio.charset.StandardCharsets; import java.util.HashSet; import java.util.Optional; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 简历归属加固的**服务层**回归测试(不依赖 Controller / MockMvc)。 * *
证明:即使绕过 Controller 直接调用服务层,也无法写入非法简历引用, * 且本人下载 / 删除 / 替换在引用被污染(指向他人存储键)时失败关闭、不触碰存储。
*/ @ExtendWith(MockitoExtension.class) class ResumeOwnershipServiceTest { private static final Long STUDENT = 101L; private static final Long OTHER_USER = 202L; private static final Long COMPANY = 10L; @Mock private StudentProfileRepository profileRepository; @Mock private ResumeStorageService storageService; @Mock private UserRepository userRepository; @Mock private UserRoleGuard userRoleGuard; @Mock private ChatResumeLifecycle chatResumeLifecycle; @Mock private CandidateScoringRefreshRequestService scoringRefreshRequestService; @Spy private ResumeFileValidator resumeFileValidator = new ResumeFileValidator(); @Spy private ResumeOwnershipVerifier resumeOwnershipVerifier = new ResumeOwnershipVerifier(); @InjectMocks private ProfileService profileService; @BeforeEach void setUp() { UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); } private void mockRole(Long userId, User.Role role) { User user = User.builder().id(userId).phone("138" + userId + "0000") .role(role).active(true).verified(true).build(); user.setRoles(new HashSet<>(Set.of(role))); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); } private static StudentProfileUpdateRequest request(String realName) { StudentProfileUpdateRequest req = new StudentProfileUpdateRequest(); req.setRealName(realName); return req; } private static byte[] pdf(String body) { return ("%PDF-1.7\n" + body + "\n%%EOF\n").getBytes(StandardCharsets.US_ASCII); } // ── 普通资料写入:服务层同样拒绝 resumeUrl ──────────────── @Test void updateProfile_withResumeUrl_isRejectedWholeRequestWithoutSaving() { mockRole(STUDENT, User.Role.STUDENT); StudentProfileUpdateRequest req = request("李四"); req.setResumeUrl(OTHER_USER + "_beef0001.pdf"); BusinessException ex = assertThrows(BusinessException.class, () -> profileService.updateProfile(STUDENT, req)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); verify(profileRepository, never()).save(any()); verify(profileRepository, never()).findByUserId(anyLong()); } @Test void updateProfile_withEmptyResumeUrl_isRejected() { mockRole(STUDENT, User.Role.STUDENT); StudentProfileUpdateRequest req = request("李四"); req.setResumeUrl(""); BusinessException ex = assertThrows(BusinessException.class, () -> profileService.updateProfile(STUDENT, req)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); verify(profileRepository, never()).save(any()); } @Test void updateProfile_withoutResumeUrl_preservesExistingReference() { mockRole(STUDENT, User.Role.STUDENT); String existing = STUDENT + "_keep0001.pdf"; StudentProfile profile = StudentProfile.builder() .id(1L).userId(STUDENT).realName("张三").resumeUrl(existing).build(); when(profileRepository.findByUserId(STUDENT)).thenReturn(Optional.of(profile)); when(profileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); StudentProfile saved = profileService.updateProfile(STUDENT, request("李四")); assertEquals("李四", saved.getRealName()); assertEquals(existing, saved.getResumeUrl()); } @Test void updateProfile_asCompany_isForbiddenAndDoesNotCreateProfile() { mockRole(COMPANY, User.Role.COMPANY); BusinessException ex = assertThrows(BusinessException.class, () -> profileService.updateProfile(COMPANY, request("假学生"))); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); verify(profileRepository, never()).save(any()); } // ── 下载:引用被污染为他人文件 → 拒绝且不读取 ───────────── @Test void getResume_pollutedForeignReference_isRejectedWithoutStorageRead() { mockRole(STUDENT, User.Role.STUDENT); StudentProfile profile = StudentProfile.builder() .id(1L).userId(STUDENT).resumeUrl(OTHER_USER + "_beef0001.pdf").build(); when(profileRepository.findByUserId(STUDENT)).thenReturn(Optional.of(profile)); BusinessException ex = assertThrows(BusinessException.class, () -> profileService.getResume(STUDENT)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); verify(storageService, never()).read(anyString()); } @Test void getResume_illegalLegacyReference_isRejectedWithoutStorageRead() { mockRole(STUDENT, User.Role.STUDENT); StudentProfile profile = StudentProfile.builder() .id(1L).userId(STUDENT).resumeUrl("old_resume.pdf").build(); when(profileRepository.findByUserId(STUDENT)).thenReturn(Optional.of(profile)); BusinessException ex = assertThrows(BusinessException.class, () -> profileService.getResume(STUDENT)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); verify(storageService, never()).read(anyString()); } @Test void getResume_ownReference_isRead() { mockRole(STUDENT, User.Role.STUDENT); String own = STUDENT + "_own00001.pdf"; StudentProfile profile = StudentProfile.builder() .id(1L).userId(STUDENT).resumeUrl(own).resumeOriginalName("my.pdf") .resumeContentType("application/pdf").build(); when(profileRepository.findByUserId(STUDENT)).thenReturn(Optional.of(profile)); when(storageService.read(own)).thenReturn(pdf("content")); ProfileService.ResumeFile file = profileService.getResume(STUDENT); assertArrayEquals(pdf("content"), file.getContent()); assertEquals("my.pdf", file.getFilename()); } // ── 删除:引用被污染 → 拒绝,资料 / 快照 / 文件均不变 ───── @Test void deleteResume_pollutedForeignReference_isRejectedAndLeavesEverythingUnchanged() { mockRole(STUDENT, User.Role.STUDENT); String foreign = OTHER_USER + "_beef0001.pdf"; StudentProfile profile = StudentProfile.builder() .id(1L).userId(STUDENT).resumeUrl(foreign).resumeOriginalName("victim.pdf").build(); when(profileRepository.lockByUserId(STUDENT)).thenReturn(Optional.of(profile)); BusinessException ex = assertThrows(BusinessException.class, () -> profileService.deleteResume(STUDENT)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); // 资料未被清空、未落库;聊天快照未撤销;他人文件未被删除 assertNotNull(profile.getResumeUrl()); assertEquals(foreign, profile.getResumeUrl()); assertEquals("victim.pdf", profile.getResumeOriginalName()); verify(profileRepository, never()).save(any()); verify(chatResumeLifecycle, never()).purgeForUser(anyLong()); verify(storageService, never()).delete(anyString()); } // ── 替换上传:非法旧引用 → 拒绝,不留新文件、不删他人文件 ── @Test void uploadResume_pollutedForeignOldReference_isRejectedWithoutStoringOrDeleting() { mockRole(STUDENT, User.Role.STUDENT); StudentProfile profile = StudentProfile.builder() .id(1L).userId(STUDENT).resumeUrl(OTHER_USER + "_beef0001.pdf").build(); when(profileRepository.lockByUserId(STUDENT)).thenReturn(Optional.of(profile)); MockMultipartFile file = new MockMultipartFile("file", "new.pdf", "application/pdf", pdf("new")); BusinessException ex = assertThrows(BusinessException.class, () -> profileService.uploadResume(STUDENT, file)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); verify(storageService, never()).store(anyLong(), any()); verify(storageService, never()).delete(anyString()); verify(profileRepository, never()).save(any()); } @Test void uploadResume_ownOldReference_replacesAndDeletesOldFileAfterSave() { mockRole(STUDENT, User.Role.STUDENT); String oldKey = STUDENT + "_old00001.pdf"; StudentProfile profile = StudentProfile.builder() .id(1L).userId(STUDENT).resumeUrl(oldKey).build(); when(profileRepository.lockByUserId(STUDENT)).thenReturn(Optional.of(profile)); when(storageService.store(anyLong(), any())).thenReturn(STUDENT + "_new00002.pdf"); when(profileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); MockMultipartFile file = new MockMultipartFile("file", "new.pdf", "application/pdf", pdf("new")); var view = profileService.uploadResume(STUDENT, file); assertEquals(STUDENT + "_new00002.pdf", view.get("resumeUrl")); verify(storageService).delete(oldKey); } }