|
|
@@ -0,0 +1,254 @@
|
|
|
+package com.etotem.cfc.controller;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.HealthReportDraft;
|
|
|
+import com.etotem.cfc.service.DanReportUploadService;
|
|
|
+import com.etotem.cfc.service.HealthReportDraftService;
|
|
|
+import com.etotem.cfc.service.ReportCollectService;
|
|
|
+import com.etotem.cfc.service.StorageService;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
+import org.springframework.mock.web.MockMultipartFile;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyLong;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
+
|
|
|
+/**
|
|
|
+ * TDD 契约单测(B-3 / R6 · 报告上传文件类型/内容安全校验)。
|
|
|
+ *
|
|
|
+ * <p>覆盖端点:
|
|
|
+ * <ul>
|
|
|
+ * <li>POST /api/dan-report/upload (DanReportController.upload)</li>
|
|
|
+ * <li>POST /api/report-parser/upload(ReportParserController.uploadAndParse)</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p>契约(三条验收,两条端点均覆盖第 1/3 条):
|
|
|
+ * <ol>
|
|
|
+ * <li>上传非 PDF(.exe/.txt/伪造扩展名与 MIME 的非 PDF 内容)→ Result.code=400(非 500、非成功),
|
|
|
+ * 且不进入存储/解析/采集链路(下游 service 不被调用)</li>
|
|
|
+ * <li>上传空文件 → Result.code=400</li>
|
|
|
+ * <li>上传合法 PDF(文件头含 %PDF- 魔数)→ 走原成功路径返回 Result.code=200</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p>校验语义沿用项目惯例:Result 业务码(400)+ HTTP 200 信封。
|
|
|
+ * 测试不依赖数据库/外部服务:下游 service 全 mock,不启 Spring 上下文。
|
|
|
+ */
|
|
|
+@DisplayName("B-3 报告上传文件类型校验契约(dan-report / report-parser)")
|
|
|
+public class ReportUploadValidationContractTest {
|
|
|
+
|
|
|
+ /** 合法 PDF 文件头(含 %PDF- 魔数) */
|
|
|
+ private static final byte[] PDF_BYTES = ("%PDF-1.7\n%\u00e2\u00e3\u00cf\u00d3\n"
|
|
|
+ + "1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF\n").getBytes(StandardCharsets.UTF_8);
|
|
|
+
|
|
|
+ /** EXE 文件头(MZ 魔数),用于伪造场景 */
|
|
|
+ private static final byte[] EXE_BYTES = "MZ\u0090\u0000evil_executable_content".getBytes(StandardCharsets.UTF_8);
|
|
|
+
|
|
|
+ private MockMvc danMvc;
|
|
|
+ private MockMvc parserMvc;
|
|
|
+
|
|
|
+ @Mock private DanReportUploadService danUploadService;
|
|
|
+ @Mock private StorageService storageService;
|
|
|
+ @Mock private HealthReportDraftService healthReportDraftService;
|
|
|
+ @Mock private ReportCollectService reportCollectService;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ public void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+
|
|
|
+ DanReportController danController = new DanReportController();
|
|
|
+ ReflectionTestUtils.setField(danController, "uploadService", danUploadService);
|
|
|
+ danMvc = MockMvcBuilders.standaloneSetup(danController).build();
|
|
|
+
|
|
|
+ ReportParserController parserController = new ReportParserController();
|
|
|
+ ReflectionTestUtils.setField(parserController, "storageService", storageService);
|
|
|
+ ReflectionTestUtils.setField(parserController, "healthReportDraftService", healthReportDraftService);
|
|
|
+ ReflectionTestUtils.setField(parserController, "reportCollectService", reportCollectService);
|
|
|
+ parserMvc = MockMvcBuilders.standaloneSetup(parserController).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────── dan-report/upload ──────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("dan-report/upload:上传 .exe 非 PDF → 400 拒绝且不触达解析服务")
|
|
|
+ public void danReportUpload_rejectsExecutable_returns400() throws Exception {
|
|
|
+ MockMultipartFile exe = new MockMultipartFile(
|
|
|
+ "file", "malware.exe", "application/octet-stream", EXE_BYTES);
|
|
|
+
|
|
|
+ danMvc.perform(multipart("/api/dan-report/upload")
|
|
|
+ .file(exe)
|
|
|
+ .param("dimension", "mind")
|
|
|
+ .param("memberId", "7")
|
|
|
+ .requestAttr("userId", 1L))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.message").value("仅支持上传 PDF 格式报告文件"));
|
|
|
+
|
|
|
+ verify(danUploadService, never()).uploadAndParse(anyLong(), anyLong(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("dan-report/upload:上传 .txt(text/plain MIME)→ 400 拒绝")
|
|
|
+ public void danReportUpload_rejectsTextFile_returns400() throws Exception {
|
|
|
+ MockMultipartFile txt = new MockMultipartFile(
|
|
|
+ "file", "notes.txt", "text/plain", "hello world".getBytes(StandardCharsets.UTF_8));
|
|
|
+
|
|
|
+ danMvc.perform(multipart("/api/dan-report/upload")
|
|
|
+ .file(txt)
|
|
|
+ .param("dimension", "wisdom")
|
|
|
+ .param("memberId", "7")
|
|
|
+ .requestAttr("userId", 1L))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400));
|
|
|
+
|
|
|
+ verify(danUploadService, never()).uploadAndParse(anyLong(), anyLong(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("dan-report/upload:伪造 .pdf 扩展名 + application/pdf MIME 但内容非 PDF → 魔数兜底 400 拒绝")
|
|
|
+ public void danReportUpload_rejectsFakePdfByMagicBytes_returns400() throws Exception {
|
|
|
+ MockMultipartFile fakePdf = new MockMultipartFile(
|
|
|
+ "file", "report.pdf", "application/pdf", "definitely not a pdf".getBytes(StandardCharsets.UTF_8));
|
|
|
+
|
|
|
+ danMvc.perform(multipart("/api/dan-report/upload")
|
|
|
+ .file(fakePdf)
|
|
|
+ .param("dimension", "mind")
|
|
|
+ .param("memberId", "7")
|
|
|
+ .requestAttr("userId", 1L))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400));
|
|
|
+
|
|
|
+ verify(danUploadService, never()).uploadAndParse(anyLong(), anyLong(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("dan-report/upload:空文件 → 400 拒绝")
|
|
|
+ public void danReportUpload_rejectsEmptyFile_returns400() throws Exception {
|
|
|
+ MockMultipartFile empty = new MockMultipartFile(
|
|
|
+ "file", "empty.pdf", "application/pdf", new byte[0]);
|
|
|
+
|
|
|
+ danMvc.perform(multipart("/api/dan-report/upload")
|
|
|
+ .file(empty)
|
|
|
+ .param("dimension", "mind")
|
|
|
+ .param("memberId", "7")
|
|
|
+ .requestAttr("userId", 1L))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.message").value("文件不能为空"));
|
|
|
+
|
|
|
+ verify(danUploadService, never()).uploadAndParse(anyLong(), anyLong(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("dan-report/upload:合法 PDF(文件头含 %PDF- 魔数)→ 走原成功路径 code=200")
|
|
|
+ public void danReportUpload_validPdf_returnsSuccess() throws Exception {
|
|
|
+ MockMultipartFile pdf = new MockMultipartFile(
|
|
|
+ "file", "dan-report.pdf", "application/pdf", PDF_BYTES);
|
|
|
+
|
|
|
+ Map<String, Object> data = new HashMap<>();
|
|
|
+ data.put("draftId", 11L);
|
|
|
+ when(danUploadService.uploadAndParse(eq(1L), eq(7L), eq("mind"), any(MultipartFile.class)))
|
|
|
+ .thenReturn(Result.success(data));
|
|
|
+
|
|
|
+ danMvc.perform(multipart("/api/dan-report/upload")
|
|
|
+ .file(pdf)
|
|
|
+ .param("dimension", "mind")
|
|
|
+ .param("memberId", "7")
|
|
|
+ .requestAttr("userId", 1L))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
+ .andExpect(jsonPath("$.data.draftId").value(11));
|
|
|
+
|
|
|
+ verify(danUploadService).uploadAndParse(eq(1L), eq(7L), eq("mind"), any(MultipartFile.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────── report-parser/upload ──────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("report-parser/upload:上传非 PDF → 400 拒绝且不进入存储链路")
|
|
|
+ public void reportParserUpload_rejectsNonPdf_returns400() throws Exception {
|
|
|
+ MockMultipartFile exe = new MockMultipartFile(
|
|
|
+ "file", "malware.exe", "application/octet-stream", EXE_BYTES);
|
|
|
+
|
|
|
+ parserMvc.perform(multipart("/api/report-parser/upload")
|
|
|
+ .file(exe)
|
|
|
+ .requestAttr("userId", 1L))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.message").value("仅支持上传 PDF 格式报告文件"));
|
|
|
+
|
|
|
+ verify(storageService, never()).storeFile(any(MultipartFile.class), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("report-parser/upload:伪造 .pdf 扩展名但内容非 PDF → 魔数兜底 400 拒绝")
|
|
|
+ public void reportParserUpload_rejectsFakePdfByMagicBytes_returns400() throws Exception {
|
|
|
+ MockMultipartFile fakePdf = new MockMultipartFile(
|
|
|
+ "file", "report.pdf", "application/pdf", "MZ fake pdf binary".getBytes(StandardCharsets.UTF_8));
|
|
|
+
|
|
|
+ parserMvc.perform(multipart("/api/report-parser/upload")
|
|
|
+ .file(fakePdf)
|
|
|
+ .requestAttr("userId", 1L))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400));
|
|
|
+
|
|
|
+ verify(storageService, never()).storeFile(any(MultipartFile.class), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("report-parser/upload:空文件 → 400 拒绝")
|
|
|
+ public void reportParserUpload_rejectsEmptyFile_returns400() throws Exception {
|
|
|
+ MockMultipartFile empty = new MockMultipartFile(
|
|
|
+ "file", "empty.pdf", "application/pdf", new byte[0]);
|
|
|
+
|
|
|
+ parserMvc.perform(multipart("/api/report-parser/upload")
|
|
|
+ .file(empty)
|
|
|
+ .requestAttr("userId", 1L))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.message").value("文件不能为空"));
|
|
|
+
|
|
|
+ verify(storageService, never()).storeFile(any(MultipartFile.class), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("report-parser/upload:合法 PDF(%PDF- 魔数)→ 走原成功路径(存储+建草稿+异步采集)")
|
|
|
+ public void reportParserUpload_validPdf_returnsSuccess() throws Exception {
|
|
|
+ MockMultipartFile pdf = new MockMultipartFile(
|
|
|
+ "file", "legit.pdf", "application/pdf", PDF_BYTES);
|
|
|
+
|
|
|
+ when(storageService.storeFile(any(MultipartFile.class), eq("health-reports")))
|
|
|
+ .thenReturn("/uploads/health-reports/legit.pdf");
|
|
|
+ HealthReportDraft draft = new HealthReportDraft();
|
|
|
+ draft.setId(55L);
|
|
|
+ when(healthReportDraftService.createDraft(any(), any(), any(), any(), any(), any()))
|
|
|
+ .thenReturn(draft);
|
|
|
+
|
|
|
+ parserMvc.perform(multipart("/api/report-parser/upload")
|
|
|
+ .file(pdf)
|
|
|
+ .requestAttr("userId", 1L))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
+ .andExpect(jsonPath("$.data.draftId").value(55));
|
|
|
+
|
|
|
+ verify(storageService).storeFile(any(MultipartFile.class), eq("health-reports"));
|
|
|
+ verify(reportCollectService).submitAsyncCollect(55L);
|
|
|
+ }
|
|
|
+}
|