forked from baskbuddy/zhangshun
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
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);
|
|
}
|
|
}
|
|
|