轻松聘AI开发
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.
 
 
 

245 lines
11 KiB

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)。
*
* <p>证明:即使绕过 Controller 直接调用服务层,也无法写入非法简历引用,
* 且本人下载 / 删除 / 替换在引用被污染(指向他人存储键)时失败关闭、不触碰存储。</p>
*/
@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);
}
}