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.
255 lines
11 KiB
255 lines
11 KiB
package com.mydreamjob.profile;
|
|
|
|
import com.mydreamjob.auth.User;
|
|
import com.mydreamjob.auth.UserRepository;
|
|
import com.mydreamjob.common.exception.BusinessException;
|
|
import com.mydreamjob.common.exception.ErrorCode;
|
|
import com.mydreamjob.common.logging.RequestIdAccessor;
|
|
import com.mydreamjob.profile.storage.ResumeStorageService;
|
|
import com.mydreamjob.security.CurrentUserAccessor;
|
|
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.http.HttpHeaders;
|
|
import org.springframework.http.HttpStatus;
|
|
import org.springframework.http.MediaType;
|
|
import org.springframework.mock.web.MockHttpServletRequest;
|
|
import org.springframework.mock.web.MockMultipartFile;
|
|
import org.springframework.http.ResponseEntity;
|
|
import org.springframework.security.authentication.InsufficientAuthenticationException;
|
|
import org.springframework.security.core.Authentication;
|
|
import org.springframework.security.core.context.SecurityContext;
|
|
import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
|
import java.util.*;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.Mockito.*;
|
|
|
|
@ExtendWith(MockitoExtension.class)
|
|
class ResumeControllerTest {
|
|
|
|
@Mock
|
|
private ProfileService profileService;
|
|
|
|
@Mock
|
|
private Authentication authentication;
|
|
|
|
/** R01-A:Controller 身份解析已收束到唯一的 CurrentUserAccessor。 */
|
|
@Spy
|
|
private CurrentUserAccessor currentUserAccessor = new CurrentUserAccessor();
|
|
|
|
/** R01-A:requestId 已收束到 RequestIdAccessor。 */
|
|
@Spy
|
|
private RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
|
|
|
|
@InjectMocks
|
|
private ProfileController controller;
|
|
|
|
private static final Long STUDENT_ID = 100L;
|
|
private static final Long COMPANY_ID = 10L;
|
|
|
|
private MockHttpServletRequest request;
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
request = new MockHttpServletRequest();
|
|
request.setRequestURI("/api/v1/profile/me/resume");
|
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
|
}
|
|
|
|
private void mockAuth(Long userId) {
|
|
when(authentication.isAuthenticated()).thenReturn(true);
|
|
when(authentication.getName()).thenReturn(String.valueOf(userId));
|
|
}
|
|
|
|
// ── 未登录 401 ───────────────────────────────────────────
|
|
|
|
@Test
|
|
void uploadResume_unauthenticated_shouldReturn401() {
|
|
when(authentication.isAuthenticated()).thenReturn(false);
|
|
MockMultipartFile file = new MockMultipartFile("file", "r.pdf",
|
|
"application/pdf", "data".getBytes());
|
|
|
|
// R01-A:未登录语义由 CurrentUserAccessor 统一产出
|
|
// (InsufficientAuthenticationException 由 GlobalExceptionHandler 映射为 401),
|
|
// 不再由 Controller 自己抛 BusinessException(2001)。
|
|
assertThrows(InsufficientAuthenticationException.class,
|
|
() -> controller.uploadResume(file, request));
|
|
}
|
|
|
|
// ── STUDENT 上传成功 ──────────────────────────────────────
|
|
|
|
@Test
|
|
void uploadResume_asStudent_shouldReturn200() {
|
|
mockAuth(STUDENT_ID);
|
|
MockMultipartFile file = new MockMultipartFile("file", "r.pdf",
|
|
"application/pdf", "data".getBytes());
|
|
Map<String, Object> view = Map.of("resumeUrl", "path.pdf", "fileName", "r.pdf");
|
|
when(profileService.uploadResume(eq(STUDENT_ID), any())).thenReturn(view);
|
|
|
|
var resp = controller.uploadResume(file, request);
|
|
|
|
assertEquals(200, resp.getStatusCode().value());
|
|
assertNotNull(resp.getBody());
|
|
assertEquals(200, resp.getBody().getCode());
|
|
}
|
|
|
|
// ── COMPANY 上传 403 ──────────────────────────────────────
|
|
|
|
@Test
|
|
void uploadResume_asCompany_shouldReturn403() {
|
|
mockAuth(COMPANY_ID);
|
|
MockMultipartFile file = new MockMultipartFile("file", "r.pdf",
|
|
"application/pdf", "data".getBytes());
|
|
when(profileService.uploadResume(eq(COMPANY_ID), any()))
|
|
.thenThrow(new BusinessException(ErrorCode.FORBIDDEN));
|
|
|
|
BusinessException ex = assertThrows(BusinessException.class,
|
|
() -> controller.uploadResume(file, request));
|
|
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt());
|
|
}
|
|
|
|
// ── ADMIN 上传 403 ────────────────────────────────────────
|
|
|
|
@Test
|
|
void uploadResume_asAdmin_shouldReturn403() {
|
|
mockAuth(999L);
|
|
MockMultipartFile file = new MockMultipartFile("file", "r.pdf",
|
|
"application/pdf", "data".getBytes());
|
|
when(profileService.uploadResume(eq(999L), any()))
|
|
.thenThrow(new BusinessException(ErrorCode.FORBIDDEN));
|
|
|
|
BusinessException ex = assertThrows(BusinessException.class,
|
|
() -> controller.uploadResume(file, request));
|
|
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt());
|
|
}
|
|
|
|
// ── 学生下载成功 ──────────────────────────────────────────
|
|
|
|
@Test
|
|
void downloadResume_asStudent_shouldReturn200() {
|
|
mockAuth(STUDENT_ID);
|
|
ProfileService.ResumeFile rf = new ProfileService.ResumeFile(
|
|
"my_resume.pdf", "application/pdf", "content".getBytes());
|
|
when(profileService.getResume(STUDENT_ID)).thenReturn(rf);
|
|
|
|
var resp = controller.downloadResume(request);
|
|
|
|
assertEquals(200, resp.getStatusCode().value());
|
|
assertEquals(MediaType.APPLICATION_PDF, resp.getHeaders().getContentType());
|
|
}
|
|
|
|
// ── 学生删除成功 ──────────────────────────────────────────
|
|
|
|
@Test
|
|
void deleteResume_asStudent_shouldReturn200() {
|
|
mockAuth(STUDENT_ID);
|
|
doNothing().when(profileService).deleteResume(STUDENT_ID);
|
|
|
|
var resp = controller.deleteResume(request);
|
|
|
|
assertEquals(200, resp.getStatusCode().value());
|
|
assertEquals(200, resp.getBody().getCode());
|
|
}
|
|
|
|
// ── 非法扩展名 400 ────────────────────────────────────────
|
|
|
|
@Test
|
|
void uploadResume_invalidExtension_shouldReturn400() {
|
|
mockAuth(STUDENT_ID);
|
|
MockMultipartFile file = new MockMultipartFile("file", "r.exe",
|
|
"application/x-msdownload", "data".getBytes());
|
|
when(profileService.uploadResume(eq(STUDENT_ID), any()))
|
|
.thenThrow(new BusinessException(ErrorCode.BAD_REQUEST));
|
|
|
|
assertThrows(BusinessException.class,
|
|
() -> controller.uploadResume(file, request));
|
|
}
|
|
|
|
// ── MIME 不匹配 400 ───────────────────────────────────────
|
|
|
|
@Test
|
|
void uploadResume_mimeTypeMismatch_shouldReturn400() {
|
|
mockAuth(STUDENT_ID);
|
|
MockMultipartFile file = new MockMultipartFile("file", "r.pdf",
|
|
"text/html", "data".getBytes());
|
|
when(profileService.uploadResume(eq(STUDENT_ID), any()))
|
|
.thenThrow(new BusinessException(ErrorCode.BAD_REQUEST));
|
|
|
|
assertThrows(BusinessException.class,
|
|
() -> controller.uploadResume(file, request));
|
|
}
|
|
|
|
// ── Content-Disposition 文件名 ─────────────────────────
|
|
|
|
@Test
|
|
void downloadResume_englishFilename_shouldReturnContentDisposition() {
|
|
mockAuth(STUDENT_ID);
|
|
ProfileService.ResumeFile rf = new ProfileService.ResumeFile(
|
|
"my_resume.pdf", "application/pdf", "data".getBytes());
|
|
when(profileService.getResume(STUDENT_ID)).thenReturn(rf);
|
|
|
|
var resp = controller.downloadResume(request);
|
|
|
|
String cd = resp.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
|
assertNotNull(cd);
|
|
assertTrue(cd.contains("my_resume.pdf"),
|
|
"CD should contain original filename, got: " + cd);
|
|
assertEquals(MediaType.APPLICATION_PDF, resp.getHeaders().getContentType());
|
|
}
|
|
|
|
@Test
|
|
void downloadResume_chineseFilename_shouldReturnUtf8EncodedFilename() {
|
|
mockAuth(STUDENT_ID);
|
|
ProfileService.ResumeFile rf = new ProfileService.ResumeFile(
|
|
"张三-简历.pdf", "application/pdf", "data".getBytes());
|
|
when(profileService.getResume(STUDENT_ID)).thenReturn(rf);
|
|
|
|
var resp = controller.downloadResume(request);
|
|
|
|
String cd = resp.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
|
assertNotNull(cd);
|
|
assertTrue(cd.contains("filename*=UTF-8''"));
|
|
assertTrue(cd.contains("%E5%BC%A0%E4%B8%89")
|
|
|| cd.contains("张三"));
|
|
}
|
|
|
|
@Test
|
|
void downloadResume_contentTypeShouldMatchStoredType() {
|
|
mockAuth(STUDENT_ID);
|
|
ProfileService.ResumeFile rf = new ProfileService.ResumeFile(
|
|
"cv.docx",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"data".getBytes());
|
|
when(profileService.getResume(STUDENT_ID)).thenReturn(rf);
|
|
|
|
var resp = controller.downloadResume(request);
|
|
|
|
assertEquals(MediaType.valueOf(
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
|
|
resp.getHeaders().getContentType());
|
|
}
|
|
|
|
@Test
|
|
void downloadResume_legacyUnsafeFilenameCannotInjectResponseHeader() {
|
|
mockAuth(STUDENT_ID);
|
|
ProfileService.ResumeFile rf = new ProfileService.ResumeFile(
|
|
"..\\resume\r\nX-Injected: secret.pdf", "application/pdf", "data".getBytes());
|
|
when(profileService.getResume(STUDENT_ID)).thenReturn(rf);
|
|
|
|
var resp = controller.downloadResume(request);
|
|
|
|
String contentDisposition = resp.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
|
assertNotNull(contentDisposition);
|
|
assertFalse(contentDisposition.contains("\r"));
|
|
assertFalse(contentDisposition.contains("\n"));
|
|
assertFalse(contentDisposition.contains("X-Injected:"));
|
|
}
|
|
}
|
|
|