fix: 兼容客户端文档 MIME 差异

- 接受 WPS及系统 MIME 别名并统一保存服务端标准类型

- 保留扩展名、大小和文件头校验以拒绝伪装文件
This commit is contained in:
2026-07-30 15:06:08 +08:00
parent ff5f90121b
commit 864cea6135
2 changed files with 166 additions and 19 deletions

View File

@@ -6,6 +6,7 @@ import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import tech.easyflow.agent.config.AgentDocumentProperties;
import tech.easyflow.agent.entity.AgentDocumentAttachment;
import tech.easyflow.agent.mapper.AgentDocumentAttachmentMapper;
@@ -14,13 +15,22 @@ import tech.easyflow.agent.runtime.media.AgentMediaObjectStorage;
import tech.easyflow.common.entity.LoginAccount;
import java.io.ByteArrayInputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* {@link AgentDocumentService} 上传幂等性测试。
*/
public class AgentDocumentServiceTest {
private static final byte[] OLE_SIGNATURE = {
(byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0, (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
private static final byte[] ZIP_SIGNATURE = {'P', 'K', 3, 4, 0, 0, 0, 0};
private AgentDocumentAttachmentMapper attachmentMapper;
private AgentMediaObjectStorage objectStorage;
private AgentDocumentReadTaskProducer taskProducer;
@@ -82,6 +92,61 @@ public class AgentDocumentServiceTest {
Mockito.verify(taskProducer, Mockito.never()).send(Mockito.anyString());
}
/**
* 验证 WPS、系统注册表和旧式浏览器 MIME 别名在内容有效时均可上传。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void validateFileShouldAcceptClientMimeAliasesAndNormalizeCanonicalType() throws Exception {
List<MimeCase> cases = List.of(
new MimeCase("sample.doc", "application/wps-office.doc",
OLE_SIGNATURE, "application/msword"),
new MimeCase("sample.docx", "application/wps-office.docx",
ZIP_SIGNATURE, "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
new MimeCase("sample.xls", "application/wps-office.xls",
OLE_SIGNATURE, "application/vnd.ms-excel"),
new MimeCase("sample.xlsx", "application/x-zip-compressed",
ZIP_SIGNATURE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
new MimeCase("sample.ppt", "application/x-ole-storage",
OLE_SIGNATURE, "application/vnd.ms-powerpoint"),
new MimeCase("sample.pptx", "application/zip",
ZIP_SIGNATURE, "application/vnd.openxmlformats-officedocument.presentationml.presentation"),
new MimeCase("sample.pdf", "application/x-pdf",
"%PDF-1.7".getBytes(StandardCharsets.US_ASCII), "application/pdf"),
new MimeCase("sample.txt", "text/x-log",
"plain text".getBytes(StandardCharsets.UTF_8), "text/plain"),
new MimeCase("sample.md", "text/x-markdown",
"# title".getBytes(StandardCharsets.UTF_8), "text/markdown"));
for (MimeCase mimeCase : cases) {
Object document = invokeValidateFile(file(mimeCase));
Assert.assertEquals(mimeCase.canonicalMime(), mimeType(document));
}
}
/**
* 验证客户端 MIME 即使伪装成 WPS 文档,文件头不匹配时仍会被拒绝。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void validateFileShouldRejectInvalidContentDespiteCompatibleMimeAlias() throws Exception {
MimeCase mimeCase = new MimeCase(
"sample.doc",
"application/wps-office.doc",
"%PDF-1.7".getBytes(StandardCharsets.US_ASCII),
"application/msword");
try {
invokeValidateFile(file(mimeCase));
Assert.fail("expected ResponseStatusException");
} catch (ResponseStatusException error) {
Assert.assertEquals(415, error.getStatusCode().value());
Assert.assertEquals("文件内容与扩展名不一致", error.getReason());
}
}
/**
* 构造一条已完成读取的正式聊天附件。
*
@@ -104,4 +169,69 @@ public class AgentDocumentServiceTest {
attachment.setStatus(AgentDocumentStatus.READY.name());
return attachment;
}
/**
* 创建指定名称、MIME 和内容的上传文件。
*
* @param mimeCase MIME 测试用例
* @return 上传文件
* @throws Exception 模拟输入流失败时抛出
*/
private MultipartFile file(MimeCase mimeCase) throws Exception {
MultipartFile file = Mockito.mock(MultipartFile.class);
Mockito.when(file.isEmpty()).thenReturn(false);
Mockito.when(file.getSize()).thenReturn((long) mimeCase.bytes().length);
Mockito.when(file.getOriginalFilename()).thenReturn(mimeCase.fileName());
Mockito.when(file.getContentType()).thenReturn(mimeCase.declaredMime());
Mockito.when(file.getInputStream())
.thenAnswer(invocation -> new ByteArrayInputStream(mimeCase.bytes()));
return file;
}
/**
* 调用文档文件校验。
*
* @param file 上传文件
* @return 内部文档描述
* @throws Exception 校验失败或反射调用失败时抛出
*/
private Object invokeValidateFile(MultipartFile file) throws Exception {
Method method = AgentDocumentService.class.getDeclaredMethod("validateFile", MultipartFile.class);
method.setAccessible(true);
try {
return method.invoke(service, file);
} catch (InvocationTargetException error) {
if (error.getCause() instanceof Exception cause) {
throw cause;
}
throw error;
}
}
/**
* 读取内部文档描述的规范 MIME。
*
* @param document 内部文档描述
* @return 规范 MIME
* @throws Exception 反射调用失败时抛出
*/
private String mimeType(Object document) throws Exception {
Method method = document.getClass().getDeclaredMethod("mimeType");
method.setAccessible(true);
return (String) method.invoke(document);
}
/**
* MIME 兼容性测试用例。
*
* @param fileName 文件名
* @param declaredMime 客户端声明 MIME
* @param bytes 文件内容
* @param canonicalMime 服务端规范 MIME
*/
private record MimeCase(String fileName,
String declaredMime,
byte[] bytes,
String canonicalMime) {
}
}