fix: 统一文档解析文件格式校验

- 统一知识库和工作流支持格式并增加前后端上传拦截

- 拒绝 XLS 与伪装 XLSX,避免空内容解析成功
This commit is contained in:
2026-09-02 19:15:01 +08:00
parent 36acf37976
commit c3673ece46
24 changed files with 658 additions and 101 deletions

View File

@@ -0,0 +1,86 @@
package tech.easyflow.ai.document.support;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 知识库导入与工作流文档解析共用的文件格式策略。
*
* @author Codex
* @since 2026-09-02
*/
public final class DocumentParseFilePolicy {
private static final List<String> SUPPORTED_EXTENSION_ORDER =
List.of("txt", "pdf", "docx", "md", "pptx", "xlsx", "csv");
private static final Set<String> SUPPORTED_EXTENSIONS =
Set.copyOf(SUPPORTED_EXTENSION_ORDER);
private static final String SUPPORTED_TYPE_LABEL =
SUPPORTED_EXTENSION_ORDER.stream()
.map(extension -> extension.toUpperCase(Locale.ROOT))
.collect(Collectors.joining(""));
/**
* 禁止实例化格式策略工具类。
*/
private DocumentParseFilePolicy() {
}
/**
* 返回只读的支持格式集合。
*
* @return 支持的文件扩展名
*/
public static Set<String> supportedExtensions() {
return SUPPORTED_EXTENSIONS;
}
/**
* 返回面向用户展示的支持格式列表。
*
* @return 大写扩展名列表
*/
public static String supportedTypeLabel() {
return SUPPORTED_TYPE_LABEL;
}
/**
* 判断扩展名是否属于文档解析支持范围。
*
* @param extension 文件扩展名
* @return 支持时返回 {@code true}
*/
public static boolean isSupportedExtension(String extension) {
return SUPPORTED_EXTENSIONS.contains(normalizeExtension(extension));
}
/**
* 判断文件名是否属于文档解析支持范围。
*
* @param fileName 文件名
* @return 支持时返回 {@code true}
*/
public static boolean isSupportedFileName(String fileName) {
return isSupportedExtension(extensionOf(fileName));
}
/**
* 提取文件名中的小写扩展名。
*
* @param fileName 文件名
* @return 小写扩展名;无扩展名时返回空字符串
*/
public static String extensionOf(String fileName) {
String normalizedName = fileName == null ? "" : fileName.trim();
int dotIndex = normalizedName.lastIndexOf('.');
return dotIndex < 0 || dotIndex == normalizedName.length() - 1
? ""
: normalizeExtension(normalizedName.substring(dotIndex + 1));
}
private static String normalizeExtension(String extension) {
return extension == null ? "" : extension.trim().toLowerCase(Locale.ROOT);
}
}

View File

@@ -10,6 +10,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext; import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos; import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult; import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
@@ -63,7 +64,7 @@ public class DocumentImportBatchAppService {
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportBatchAppService.class); private static final Logger LOG = LoggerFactory.getLogger(DocumentImportBatchAppService.class);
private static final Set<String> SUPPORTED_EXTENSIONS = private static final Set<String> SUPPORTED_EXTENSIONS =
DocumentImportFormatPolicy.supportedExtensions(); DocumentParseFilePolicy.supportedExtensions();
private static final Duration BATCH_MUTATION_LOCK_LEASE = Duration.ofMinutes(30); private static final Duration BATCH_MUTATION_LOCK_LEASE = Duration.ofMinutes(30);
private static final Duration RECOVERY_DISPATCH_LEASE = Duration.ofMinutes(2); private static final Duration RECOVERY_DISPATCH_LEASE = Duration.ofMinutes(2);
private static final Duration RECOVERY_LEASE_RENEW_INTERVAL = private static final Duration RECOVERY_LEASE_RENEW_INTERVAL =
@@ -1456,7 +1457,11 @@ public class DocumentImportBatchAppService {
int dotIndex = fileName == null ? -1 : fileName.lastIndexOf('.'); int dotIndex = fileName == null ? -1 : fileName.lastIndexOf('.');
String extension = dotIndex < 0 ? "" : fileName.substring(dotIndex + 1).toLowerCase(Locale.ROOT); String extension = dotIndex < 0 ? "" : fileName.substring(dotIndex + 1).toLowerCase(Locale.ROOT);
if (!SUPPORTED_EXTENSIONS.contains(extension)) { if (!SUPPORTED_EXTENSIONS.contains(extension)) {
throw new BusinessException("暂不支持该文件格式"); throw new BusinessException(
"暂不支持该文件格式,仅支持 "
+ DocumentParseFilePolicy.supportedTypeLabel()
+ " 文件"
);
} }
} }

View File

@@ -1,40 +0,0 @@
package tech.easyflow.ai.documentimport.task;
import java.util.Set;
/**
* 知识库文档导入格式统一策略。
*
* @author Codex
* @since 2026-08-04
*/
public final class DocumentImportFormatPolicy {
private static final Set<String> SUPPORTED_EXTENSIONS =
Set.of("txt", "pdf", "docx", "md", "pptx", "xlsx", "csv");
/**
* 禁止实例化格式策略工具类。
*/
private DocumentImportFormatPolicy() {
}
/**
* 返回只读的支持格式集合。
*
* @return 支持的文件扩展名
*/
public static Set<String> supportedExtensions() {
return SUPPORTED_EXTENSIONS;
}
/**
* 判断扩展名是否属于知识库导入支持范围。
*
* @param extension 已转换为小写的文件扩展名
* @return 支持时返回 {@code true}
*/
public static boolean isSupported(String extension) {
return SUPPORTED_EXTENSIONS.contains(extension);
}
}

View File

@@ -42,6 +42,7 @@ import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException; import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.service.DocumentParseBridgeService; import tech.easyflow.ai.document.service.DocumentParseBridgeService;
import tech.easyflow.ai.document.support.DocumentInputStreamSupport; import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
import tech.easyflow.ai.documentimport.DocumentImportDtos; import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.DocumentImportKeys; import tech.easyflow.ai.documentimport.DocumentImportKeys;
import tech.easyflow.ai.documentimport.DocumentImportPreviewService; import tech.easyflow.ai.documentimport.DocumentImportPreviewService;
@@ -4600,8 +4601,12 @@ public class KnowledgeDocumentImportTaskAppService {
} }
private void assertSupportedImportFile(String fileExt) { private void assertSupportedImportFile(String fileExt) {
if (!DocumentImportFormatPolicy.isSupported(fileExt)) { if (!DocumentParseFilePolicy.isSupportedExtension(fileExt)) {
throw new BusinessException("当前仅支持 pdf/docx/txt/md/pptx/xlsx/csv 文档导入"); throw new BusinessException(
"当前仅支持 "
+ DocumentParseFilePolicy.supportedTypeLabel()
+ " 文档导入"
);
} }
} }

View File

@@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext; import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos; import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult; import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
@@ -60,7 +61,7 @@ public class KnowledgeImportBatchFacade {
private static final Duration INCOMPLETE_SUBMISSION_TIMEOUT = private static final Duration INCOMPLETE_SUBMISSION_TIMEOUT =
Duration.ofMinutes(30); Duration.ofMinutes(30);
private static final Set<String> SUPPORTED_EXTENSIONS = private static final Set<String> SUPPORTED_EXTENSIONS =
DocumentImportFormatPolicy.supportedExtensions(); DocumentParseFilePolicy.supportedExtensions();
private final DocumentImportBatchAppService batchAppService; private final DocumentImportBatchAppService batchAppService;
private final DocumentImportBatchTracker batchTracker; private final DocumentImportBatchTracker batchTracker;
@@ -889,7 +890,13 @@ public class KnowledgeImportBatchFacade {
*/ */
private void assertSupportedExtension(String fileName) { private void assertSupportedExtension(String fileName) {
if (!SUPPORTED_EXTENSIONS.contains(extension(fileName))) { if (!SUPPORTED_EXTENSIONS.contains(extension(fileName))) {
throw new BusinessException(415, 41502, "暂不支持该文件格式"); throw new BusinessException(
415,
41502,
"暂不支持该文件格式,仅支持 "
+ DocumentParseFilePolicy.supportedTypeLabel()
+ " 文件"
);
} }
} }

View File

@@ -1,5 +1,6 @@
package tech.easyflow.ai.easyagentsflow.service; package tech.easyflow.ai.easyagentsflow.service;
import com.easyagents.document.core.exception.DocumentParseException;
import com.easyagents.flow.core.chain.ChainState; import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.ExceptionSummary; import com.easyagents.flow.core.chain.ExceptionSummary;
import com.easyagents.flow.core.chain.NodeState; import com.easyagents.flow.core.chain.NodeState;
@@ -165,6 +166,10 @@ public class TinyFlowService {
String rootMessage = StringUtil.hasText(error.getRootCauseMessage()) String rootMessage = StringUtil.hasText(error.getRootCauseMessage())
? error.getRootCauseMessage() ? error.getRootCauseMessage()
: error.getMessage(); : error.getMessage();
if (DocumentParseException.class.getName().equals(rootClass)
&& StringUtil.hasText(rootMessage)) {
return rootMessage;
}
if (StringUtil.noText(rootClass)) { if (StringUtil.noText(rootClass)) {
return rootMessage; return rootMessage;
} }

View File

@@ -26,10 +26,6 @@ public class DefaultReadService implements ReadDocService {
@Override @Override
public String read(String fileName, InputStream is) { public String read(String fileName, InputStream is) {
String suffix = DocUtil.getSuffix(fileName); String suffix = DocUtil.getSuffix(fileName);
if ("pdf".equals(suffix)) { return DocUtil.readPreviewContent(suffix, is);
return DocUtil.readPdfFile(is);
} else {
return DocUtil.readWordFile(suffix, is);
}
} }
} }

View File

@@ -12,6 +12,7 @@ import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException; import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.service.DocumentParseBridgeService; import tech.easyflow.ai.document.service.DocumentParseBridgeService;
import tech.easyflow.ai.document.support.DocumentInputStreamSupport; import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
import tech.easyflow.ai.document.support.DocumentParseSourceType; import tech.easyflow.ai.document.support.DocumentParseSourceType;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader; import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
import tech.easyflow.common.filestorage.FileStorageService; import tech.easyflow.common.filestorage.FileStorageService;
@@ -206,6 +207,16 @@ public class DocNodeFileContentExtractor {
if (!StringUtil.hasText(sourceRef.getFilePath())) { if (!StringUtil.hasText(sourceRef.getFilePath())) {
throw new BusinessException("文件输入缺少 filePath"); throw new BusinessException("文件输入缺少 filePath");
} }
if (!DocumentParseFilePolicy.isSupportedFileName(
sourceRef.getFileName())) {
throw new BusinessException(
"文件“"
+ sourceRef.getFileName()
+ "”格式不支持,文档解析仅支持 "
+ DocumentParseFilePolicy.supportedTypeLabel()
+ " 文件"
);
}
} }
private void collectFileValues(Object value, List<Object> result) { private void collectFileValues(Object value, List<Object> result) {
@@ -285,8 +296,12 @@ public class DocNodeFileContentExtractor {
"document:default-reader"); "document:default-reader");
InputStream inputStream = InputStream inputStream =
Files.newInputStream(temporaryFile)) { Files.newInputStream(temporaryFile)) {
return readerManager.getReader().read( String content = readerManager.getReader().read(
sourceRef.getFileName(), inputStream); sourceRef.getFileName(), inputStream);
if (!StringUtil.hasText(content)) {
throw new BusinessException("文档解析结果为空");
}
return content;
} }
} catch (IOException e) { } catch (IOException e) {
DocumentInputStreamSupport.SizeLimitExceededException sizeError = DocumentInputStreamSupport.SizeLimitExceededException sizeError =

View File

@@ -2,6 +2,7 @@ package tech.easyflow.ai.documentimport.task;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@@ -20,6 +21,13 @@ public class CsvImportSupportPolicyTest {
@Test @Test
public void shouldAllowCsvAcrossAllKnowledgeImportEntrypoints() public void shouldAllowCsvAcrossAllKnowledgeImportEntrypoints()
throws Exception { throws Exception {
Assert.assertFalse(
DocumentParseFilePolicy.isSupportedFileName("legacy.xls"));
Assert.assertTrue(
DocumentParseFilePolicy.isSupportedFileName("REPORT.XLSX"));
Assert.assertEquals(
"TXT、PDF、DOCX、MD、PPTX、XLSX、CSV",
DocumentParseFilePolicy.supportedTypeLabel());
KnowledgeDocumentImportTaskAppService taskService = KnowledgeDocumentImportTaskAppService taskService =
new KnowledgeDocumentImportTaskAppService(); new KnowledgeDocumentImportTaskAppService();
Method assertSupported = KnowledgeDocumentImportTaskAppService.class Method assertSupported = KnowledgeDocumentImportTaskAppService.class
@@ -43,10 +51,10 @@ public class CsvImportSupportPolicyTest {
Assert.assertTrue(readSupportedExtensions( Assert.assertTrue(readSupportedExtensions(
KnowledgeImportBatchFacade.class).contains("csv")); KnowledgeImportBatchFacade.class).contains("csv"));
Assert.assertSame( Assert.assertSame(
DocumentImportFormatPolicy.supportedExtensions(), DocumentParseFilePolicy.supportedExtensions(),
readSupportedExtensions(DocumentImportBatchAppService.class)); readSupportedExtensions(DocumentImportBatchAppService.class));
Assert.assertSame( Assert.assertSame(
DocumentImportFormatPolicy.supportedExtensions(), DocumentParseFilePolicy.supportedExtensions(),
readSupportedExtensions(KnowledgeImportBatchFacade.class)); readSupportedExtensions(KnowledgeImportBatchFacade.class));
} }

View File

@@ -1,5 +1,6 @@
package tech.easyflow.ai.easyagentsflow.service; package tech.easyflow.ai.easyagentsflow.service;
import com.easyagents.document.core.exception.DocumentParseException;
import com.easyagents.flow.core.chain.ChainState; import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.ChainStatus; import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.ExceptionSummary; import com.easyagents.flow.core.chain.ExceptionSummary;
@@ -191,6 +192,40 @@ public class TinyFlowServiceTest {
result.getNodes().get(NODE_ID).getMessage()); result.getNodes().get(NODE_ID).getMessage());
} }
/**
* 验证文档格式错误只展示可操作提示,不暴露底层异常类名。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldExposeDocumentParseMessageWithoutExceptionClass()
throws Exception {
ChainExecutor chainExecutor = mock(ChainExecutor.class);
ChainStateRepository chainStateRepository =
mock(ChainStateRepository.class);
NodeStateRepository nodeStateRepository =
mock(NodeStateRepository.class);
String message = "文件不是标准 XLSX请另存为 XLSX 后重试";
ExceptionSummary error = new ExceptionSummary(
new RuntimeException(
"文档解析失败",
new DocumentParseException(message)));
ChainState chainState = new ChainState();
chainState.setStatus(ChainStatus.FAILED);
chainState.setError(error);
when(chainExecutor.getChainStateRepository())
.thenReturn(chainStateRepository);
when(chainExecutor.getNodeStateRepository())
.thenReturn(nodeStateRepository);
when(chainStateRepository.load(EXECUTE_ID))
.thenReturn(chainState);
TinyFlowService service = service(chainExecutor);
ChainInfo result = service.getChainStatus(EXECUTE_ID, null);
Assert.assertEquals(message, result.getMessage());
}
/** /**
* 创建带指定初始状态的设计器节点。 * 创建带指定初始状态的设计器节点。
* *

View File

@@ -0,0 +1,32 @@
package tech.easyflow.ai.node;
import org.junit.Assert;
import org.junit.Test;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
/**
* {@link DefaultReadService} 测试。
*/
public class DefaultReadServiceTest {
/**
* 验证知识库与工作流共同允许的纯文本格式可以被读取。
*/
@Test
public void shouldReadSupportedPlainTextFormats() {
DefaultReadService service = new DefaultReadService();
for (String fileName : new String[] {"notes.txt", "README.md", "table.csv"}) {
String content = service.read(
fileName,
new ByteArrayInputStream(
"标题,内容".getBytes(StandardCharsets.UTF_8)
)
);
Assert.assertEquals("标题,内容", content);
}
}
}

View File

@@ -117,10 +117,10 @@ public class DocNodeFileContentExtractorTest {
} }
/** /**
* 验证非桥接类型文件会继续走默认读取器。 * 验证支持的纯文本文件会继续走默认读取器。
*/ */
@Test @Test
public void shouldUseDefaultReaderForUnsupportedType() { public void shouldUseDefaultReaderForPlainText() {
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService(); RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor( DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
bridgeService, bridgeService,
@@ -134,6 +134,36 @@ public class DocNodeFileContentExtractorTest {
Assert.assertNull(bridgeService.lastSource); Assert.assertNull(bridgeService.lastSource);
} }
/**
* 验证旧版 XLS 在进入默认读取器前被明确拒绝。
*/
@Test
public void shouldRejectLegacyXlsBeforeDefaultReader() {
RecordingDocumentParseBridgeService bridgeService =
new RecordingDocumentParseBridgeService();
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
bridgeService,
new FakeFileStorageService(),
new FakeReaderManager("should not be used")
);
BusinessException error = Assert.assertThrows(
BusinessException.class,
() -> extractor.extract(buildFileValue(
"test.xls",
"/files/test.xls",
"application/vnd.ms-excel"
))
);
Assert.assertEquals(
"文件“test.xls”格式不支持文档解析仅支持 "
+ "TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件",
error.getMessage()
);
Assert.assertNull(bridgeService.lastSource);
}
/** /**
* 验证缺少 filePath 时会抛出明确异常。 * 验证缺少 filePath 时会抛出明确异常。
*/ */
@@ -176,6 +206,29 @@ public class DocNodeFileContentExtractorTest {
} }
} }
/**
* 验证默认读取器返回空内容时节点不会伪装成功。
*/
@Test
public void shouldFailWhenDefaultReaderResultIsEmpty() {
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
new RecordingDocumentParseBridgeService(),
new FakeFileStorageService(),
new FakeReaderManager(" ")
);
BusinessException error = Assert.assertThrows(
BusinessException.class,
() -> extractor.extract(buildFileValue(
"empty.txt",
"/files/empty.txt",
"text/plain"
))
);
Assert.assertEquals("文档解析结果为空", error.getMessage());
}
/** /**
* 验证普通远端素材 URL 的非桥接文件仍拒绝访问回环地址。 * 验证普通远端素材 URL 的非桥接文件仍拒绝访问回环地址。
*/ */

View File

@@ -0,0 +1,40 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import DragFileUpload from './DragFileUpload.vue';
vi.mock('@easyflow/hooks', () => ({
useAppConfig: () => ({ apiURL: '' }),
}));
vi.mock('@easyflow/locales', () => ({
$t: (key: string) => key,
}));
vi.mock('@easyflow/stores', () => ({
useAccessStore: () => ({ accessToken: 'test-token' }),
}));
vi.mock('#/locales', () => ({
$t: (key: string, params?: Record<string, string>) =>
key === 'message.upload.unsupportedFileType'
? `${params?.fileName}”格式不支持,仅支持 ${params?.types} 文件。`
: key,
}));
describe('drag file upload', () => {
it('uses the shared document formats and rejects XLS drops', async () => {
const wrapper = mount(DragFileUpload);
const upload = wrapper.getComponent({ name: 'ElUpload' });
expect(upload.props('accept')).toBe('.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv');
const beforeUpload = upload.props('beforeUpload') as (
file: File,
) => Promise<boolean>;
await expect(beforeUpload(new File(['legacy'], 'test.xls'))).resolves.toBe(
false,
);
});
});

View File

@@ -10,6 +10,7 @@ import { useAccessStore } from '@easyflow/stores';
import { UploadFilled } from '@element-plus/icons-vue'; import { UploadFilled } from '@element-plus/icons-vue';
import { ElIcon, ElMessage, ElUpload } from 'element-plus'; import { ElIcon, ElMessage, ElUpload } from 'element-plus';
import { DocumentParseFilePolicy } from '#/utils/document-parse-file-policy';
import { import {
normalizeUploadError, normalizeUploadError,
resolveUploadPath, resolveUploadPath,
@@ -28,7 +29,6 @@ const props = defineProps({
const emit = defineEmits(['success', 'error', 'onChange']); const emit = defineEmits(['success', 'error', 'onChange']);
const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
const ACCEPTED_FILE_TYPES = '.txt,.pdf,.docx,.md,.pptx,.xlsx';
const accessStore = useAccessStore(); const accessStore = useAccessStore();
const headers = ref({ const headers = ref({
'easyflow-token': accessStore.accessToken, 'easyflow-token': accessStore.accessToken,
@@ -54,12 +54,18 @@ const handleError: UploadProps['onError'] = (error) => {
emit('error', normalizedError); emit('error', normalizedError);
}; };
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => { const beforeUpload: UploadProps['beforeUpload'] = async (rawFile) => {
if (rawFile.size <= MAX_FILE_SIZE_BYTES) { try {
return true; await DocumentParseFilePolicy.validateFiles([rawFile]);
} catch (error: any) {
ElMessage.warning(error?.message || $t('message.notSupported'));
return false;
} }
ElMessage.warning($t('message.upload.fileTooLarge')); if (rawFile.size > MAX_FILE_SIZE_BYTES) {
return false; ElMessage.warning($t('message.upload.fileTooLarge'));
return false;
}
return true;
}; };
// 文件状态变化回调 // 文件状态变化回调
@@ -97,7 +103,7 @@ defineExpose({
drag drag
:headers="headers" :headers="headers"
:action="`${apiURL}${props.action}`" :action="`${apiURL}${props.action}`"
:accept="ACCEPTED_FILE_TYPES" :accept="DocumentParseFilePolicy.accept"
:before-upload="beforeUpload" :before-upload="beforeUpload"
:on-success="handleSuccess" :on-success="handleSuccess"
:on-error="handleError" :on-error="handleError"
@@ -111,7 +117,9 @@ defineExpose({
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-base">{{ $t('message.upload.title') }}</span> <span class="text-base">{{ $t('message.upload.title') }}</span>
<span class="text-muted-foreground text-sm">{{ <span class="text-muted-foreground text-sm">{{
$t('message.upload.description') $t('message.upload.description', {
types: DocumentParseFilePolicy.supportedTypeLabel,
})
}}</span> }}</span>
</div> </div>
</ElUpload> </ElUpload>

View File

@@ -79,7 +79,7 @@
"progressUpload": "Progress of file upload", "progressUpload": "Progress of file upload",
"fileSize": "File size", "fileSize": "File size",
"batchUploadTitle": "Select files or drop them here", "batchUploadTitle": "Select files or drop them here",
"batchUploadDescription": "TXT, PDF, DOCX, MD, PPTX and XLSX. Up to 100MB per file and 1GB per folder.", "batchUploadDescription": "Supported formats: {types}. Up to 100MB per file and 1GB per folder.",
"batchUploadTip": "When upload completes, choose manual or automatic import.", "batchUploadTip": "When upload completes, choose manual or automatic import.",
"selectFolder": "Select Folder", "selectFolder": "Select Folder",
"manualImport": "Manual Import", "manualImport": "Manual Import",
@@ -95,8 +95,8 @@
"singleFileLimit": "Each file must be no larger than 100MB", "singleFileLimit": "Each file must be no larger than 100MB",
"folderSizeLimit": "The folder must be no larger than 1GB", "folderSizeLimit": "The folder must be no larger than 1GB",
"fileCountLimit": "A batch can contain up to 2000 files", "fileCountLimit": "A batch can contain up to 2000 files",
"noSupportedFiles": "No supported documents found", "noSupportedFiles": "No supported documents found. Supported formats: {types}.",
"unsupportedSkipped": "Unsupported files were skipped", "unsupportedSkipped": "Unsupported files were skipped. Supported formats: {types}.",
"createBatchFailed": "Failed to create upload batch", "createBatchFailed": "Failed to create upload batch",
"cancelBatchFailed": "Failed to cancel the upload batch. Please retry.", "cancelBatchFailed": "Failed to cancel the upload batch. Please retry.",
"uploadFailed": "Upload failed. Please retry.", "uploadFailed": "Upload failed. Please retry.",

View File

@@ -27,8 +27,13 @@
"copyFail": "Copy fail", "copyFail": "Copy fail",
"upload": { "upload": {
"title": "Click or drag and drop files here to upload", "title": "Click or drag and drop files here to upload",
"description": "TXT, PDF, DOCX, MD, PPTX, and XLSX files are supported, up to 100 MB each.", "description": "Supported formats: {types}. Up to 100 MB per file.",
"fileTooLarge": "Each file must not exceed 100 MB" "supportedTypes": "Supported formats: {types}.",
"singleFileLimit": "Up to 100 MB per file.",
"fileTooLarge": "Each file must not exceed 100 MB",
"unsupportedFileType": "“{fileName}” is not supported. Supported formats: {types}.",
"legacyExcelAsXlsx": "“{fileName}” is not a standard XLSX file. It may be a legacy XLS or an encrypted file. Remove protection and save it as XLSX in Excel/WPS; renaming the extension does not work.",
"invalidXlsx": "“{fileName}” is not a standard XLSX file. Its content does not match the extension or the file is damaged. Save it as XLSX in Excel/WPS and try again."
}, },
"uploadFileFirst": "Please upload the file first", "uploadFileFirst": "Please upload the file first",
"deleteModelAlert": "This operation will delete the large model. Are you sure to delete it?", "deleteModelAlert": "This operation will delete the large model. Are you sure to delete it?",

View File

@@ -79,7 +79,7 @@
"progressUpload": "文件上传进度", "progressUpload": "文件上传进度",
"fileSize": "文件大小", "fileSize": "文件大小",
"batchUploadTitle": "点击选择文件,或将文件拖到这里上传", "batchUploadTitle": "点击选择文件,或将文件拖到这里上传",
"batchUploadDescription": "支持 TXT、PDF、DOCX、MD、PPTX、XLSX;单个文件不超过 100MB文件夹总大小不超过 1GB。", "batchUploadDescription": "支持 {types} 文件;单个文件不超过 100MB文件夹总大小不超过 1GB。",
"batchUploadTip": "上传完成后,可选择手动导入或自动导入。", "batchUploadTip": "上传完成后,可选择手动导入或自动导入。",
"selectFolder": "选择文件夹", "selectFolder": "选择文件夹",
"manualImport": "手动导入", "manualImport": "手动导入",
@@ -95,8 +95,8 @@
"singleFileLimit": "单个文件不能超过 100MB", "singleFileLimit": "单个文件不能超过 100MB",
"folderSizeLimit": "文件夹总大小不能超过 1GB", "folderSizeLimit": "文件夹总大小不能超过 1GB",
"fileCountLimit": "单批次文件数不能超过 2000", "fileCountLimit": "单批次文件数不能超过 2000",
"noSupportedFiles": "未找到支持的文档", "noSupportedFiles": "未找到支持的文档,仅支持 {types} 文件",
"unsupportedSkipped": "已跳过不支持的文件", "unsupportedSkipped": "已跳过不支持的文件;仅支持 {types} 文件",
"createBatchFailed": "创建上传批次失败", "createBatchFailed": "创建上传批次失败",
"cancelBatchFailed": "取消上传批次失败,请重试", "cancelBatchFailed": "取消上传批次失败,请重试",
"uploadFailed": "文件上传失败,请重试", "uploadFailed": "文件上传失败,请重试",

View File

@@ -27,8 +27,13 @@
"copyFail": "复制失败", "copyFail": "复制失败",
"upload": { "upload": {
"title": "点击或将文件拖拽到这里上传", "title": "点击或将文件拖拽到这里上传",
"description": "支持 TXT、PDF、DOCX、MD、PPTX、XLSX,单个文件不超过 100 MB。", "description": "支持 {types} 文件,单个文件不超过 100 MB。",
"fileTooLarge": "单个文件大小不能超过 100 MB" "supportedTypes": "支持 {types} 文件",
"singleFileLimit": "单个文件不超过 100 MB。",
"fileTooLarge": "单个文件大小不能超过 100 MB",
"unsupportedFileType": "“{fileName}”格式不支持,仅支持 {types} 文件。",
"legacyExcelAsXlsx": "“{fileName}”不是标准 XLSX可能是旧版 XLS 或已加密文件。请解除保护后用 Excel/WPS 另存为 XLSX修改文件后缀无效",
"invalidXlsx": "“{fileName}”不是标准 XLSX文件内容与扩展名不一致或文件已损坏。请用 Excel/WPS 另存为 XLSX 后重试"
}, },
"uploadFileFirst": "请先上传文件", "uploadFileFirst": "请先上传文件",
"deleteModelAlert": "该操作会删除大模型,确定删除吗?", "deleteModelAlert": "该操作会删除大模型,确定删除吗?",

View File

@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest';
import { DocumentParseFilePolicy } from './document-parse-file-policy';
vi.mock('#/locales', () => ({
$t: (key: string, params?: Record<string, string>) => {
if (key === 'message.upload.unsupportedFileType') {
return `${params?.fileName}”格式不支持,仅支持 ${params?.types} 文件。`;
}
if (key === 'message.upload.legacyExcelAsXlsx') {
return `${params?.fileName}”不是标准 XLSX可能是旧版 XLS 或已加密文件。请解除保护后用 Excel/WPS 另存为 XLSX修改文件后缀无效`;
}
return key;
},
}));
describe('document parse file policy', () => {
it('provides one accept list for knowledge and workflow uploads', () => {
expect(DocumentParseFilePolicy.accept).toBe(
'.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv',
);
expect(DocumentParseFilePolicy.supports('REPORT.XLSX')).toBe(true);
expect(DocumentParseFilePolicy.supports('legacy.xls')).toBe(false);
});
it('accepts a standard XLSX container signature', async () => {
const file = new File([new Uint8Array([80, 75, 3, 4, 0])], '清单.XLSX');
await expect(
DocumentParseFilePolicy.validateFiles([file]),
).resolves.toBeUndefined();
});
it('rejects unsupported extensions with the supported type list', async () => {
await expect(
DocumentParseFilePolicy.validateFiles([new File(['xls'], 'test.xls')]),
).rejects.toThrow(
'“test.xls”格式不支持仅支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件。',
);
});
it('rejects legacy XLS content disguised as XLSX', async () => {
const file = new File(
[new Uint8Array([208, 207, 17, 224, 161, 177, 26, 225])],
'整理发布清单.xlsx',
);
await expect(DocumentParseFilePolicy.validateFiles([file])).rejects.toThrow(
'“整理发布清单.xlsx”不是标准 XLSX可能是旧版 XLS 或已加密文件。请解除保护后用 Excel/WPS 另存为 XLSX修改文件后缀无效',
);
});
});

View File

@@ -0,0 +1,83 @@
import { $t } from '#/locales';
const OLE2_SIGNATURE = [208, 207, 17, 224, 161, 177, 26, 225];
const supportedExtensions = Object.freeze([
'txt',
'pdf',
'docx',
'md',
'pptx',
'xlsx',
'csv',
]);
const supportedExtensionSet = new Set(supportedExtensions);
const supportedTypeLabel = supportedExtensions
.map((extension) => extension.toUpperCase())
.join('、');
function extensionOf(fileName: string) {
const normalizedName = String(fileName || '')
.trim()
.toLowerCase();
const dotIndex = normalizedName.lastIndexOf('.');
return dotIndex === -1 ? '' : normalizedName.slice(dotIndex + 1);
}
function supports(fileName: string) {
return supportedExtensionSet.has(extensionOf(fileName));
}
async function validateFiles(files: File[]) {
for (const file of files) {
const extension = extensionOf(file.name);
if (!supportedExtensionSet.has(extension)) {
throw new Error(
$t('message.upload.unsupportedFileType', {
fileName: file.name,
types: supportedTypeLabel,
}),
);
}
if (extension !== 'xlsx') {
continue;
}
const prefix = new Uint8Array(await file.slice(0, 8).arrayBuffer());
if (hasZipSignature(prefix)) {
continue;
}
const messageKey = startsWith(prefix, OLE2_SIGNATURE)
? 'message.upload.legacyExcelAsXlsx'
: 'message.upload.invalidXlsx';
throw new Error($t(messageKey, { fileName: file.name }));
}
}
/**
* 知识库导入与工作流文档解析共用的文件格式策略。
*/
export const DocumentParseFilePolicy = Object.freeze({
accept: supportedExtensions.map((extension) => `.${extension}`).join(','),
supportedExtensions,
supportedTypeLabel,
supports,
validateFiles,
});
function hasZipSignature(prefix: Uint8Array) {
return (
prefix.length >= 4 &&
prefix[0] === 80 &&
prefix[1] === 75 &&
((prefix[2] === 3 && prefix[3] === 4) ||
(prefix[2] === 5 && prefix[3] === 6) ||
(prefix[2] === 7 && prefix[3] === 8))
);
}
function startsWith(prefix: Uint8Array, signature: number[]) {
return (
prefix.length >= signature.length &&
signature.every((value, index) => prefix[index] === value)
);
}

View File

@@ -0,0 +1,52 @@
import { shallowMount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import ImportKnowledgeFileContainer from './ImportKnowledgeFileContainer.vue';
vi.mock('@easyflow/hooks', () => ({
useAppConfig: () => ({ apiURL: '' }),
}));
vi.mock('@easyflow/locales', () => ({
$t: (key: string, params?: Record<string, string>) =>
key === 'documentCollection.importDoc.batchUploadDescription'
? `支持 ${params?.types} 文件`
: key,
}));
vi.mock('@easyflow/stores', () => ({
useAccessStore: () => ({ accessToken: 'test-token' }),
}));
vi.mock('#/api/request', () => ({ api: {} }));
vi.mock('#/locales', () => ({
$t: (key: string) => key,
}));
vi.mock('element-plus/es/components/table-v2/index.mjs', () => ({
ElAutoResizer: { template: '<div><slot :height="300" :width="600" /></div>' },
ElTableV2: { template: '<div />' },
}));
vi.mock('element-plus/es/components/table-v2/style/css.mjs', () => ({}));
describe('import knowledge file container', () => {
it('uses the shared document formats for file and folder selection', () => {
const wrapper = shallowMount(ImportKnowledgeFileContainer, {
props: { batchMode: true },
});
const inputs = wrapper.findAll('input[type="file"]');
expect(inputs).toHaveLength(2);
for (const input of inputs) {
expect(input.attributes('accept')).toBe(
'.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv',
);
}
expect(wrapper.text()).toContain(
'支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件',
);
});
});

View File

@@ -22,6 +22,7 @@ import {
import { formatFileSize } from '#/api/common/file'; import { formatFileSize } from '#/api/common/file';
import { api } from '#/api/request'; import { api } from '#/api/request';
import DragFileUpload from '#/components/upload/DragFileUpload.vue'; import DragFileUpload from '#/components/upload/DragFileUpload.vue';
import { DocumentParseFilePolicy } from '#/utils/document-parse-file-policy';
import { resolveDocumentUploadResponse } from './document-import-upload-response'; import { resolveDocumentUploadResponse } from './document-import-upload-response';
@@ -80,16 +81,6 @@ const emit = defineEmits<{
const MAX_FILE_COUNT = 2000; const MAX_FILE_COUNT = 2000;
const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
const MAX_TOTAL_SIZE_BYTES = 1024 * 1024 * 1024; const MAX_TOTAL_SIZE_BYTES = 1024 * 1024 * 1024;
const SUPPORTED_EXTENSIONS = new Set([
'csv',
'docx',
'md',
'pdf',
'pptx',
'txt',
'xlsx',
]);
const fileData = ref<LegacyFileInfo[]>([]); const fileData = ref<LegacyFileInfo[]>([]);
const filesPath = ref<any[]>([]); const filesPath = ref<any[]>([]);
const dragUploadRef = ref<InstanceType<typeof DragFileUpload>>(); const dragUploadRef = ref<InstanceType<typeof DragFileUpload>>();
@@ -331,8 +322,7 @@ async function prepareBatch(files: File[]) {
ignoredCount++; ignoredCount++;
continue; continue;
} }
const extension = file.name.split('.').pop()?.toLowerCase() || ''; if (!DocumentParseFilePolicy.supports(file.name)) {
if (!SUPPORTED_EXTENSIONS.has(extension)) {
ignoredCount++; ignoredCount++;
continue; continue;
} }
@@ -344,7 +334,11 @@ async function prepareBatch(files: File[]) {
accepted.push(file); accepted.push(file);
} }
if (accepted.length === 0) { if (accepted.length === 0) {
ElMessage.warning($t('documentCollection.importDoc.noSupportedFiles')); ElMessage.warning(
$t('documentCollection.importDoc.noSupportedFiles', {
types: DocumentParseFilePolicy.supportedTypeLabel,
}),
);
return; return;
} }
if (accepted.length > MAX_FILE_COUNT) { if (accepted.length > MAX_FILE_COUNT) {
@@ -356,7 +350,17 @@ async function prepareBatch(files: File[]) {
return; return;
} }
if (ignoredCount > 0) { if (ignoredCount > 0) {
ElMessage.info($t('documentCollection.importDoc.unsupportedSkipped')); ElMessage.info(
$t('documentCollection.importDoc.unsupportedSkipped', {
types: DocumentParseFilePolicy.supportedTypeLabel,
}),
);
}
try {
await DocumentParseFilePolicy.validateFiles(accepted);
} catch (error: any) {
ElMessage.warning(error?.message || $t('message.notSupported'));
return;
} }
batchFiles.value = await Promise.all( batchFiles.value = await Promise.all(
@@ -538,7 +542,7 @@ async function createClientFileKey(relativePath: string) {
class="native-file-input" class="native-file-input"
type="file" type="file"
multiple multiple
accept=".txt,.pdf,.docx,.md,.pptx,.xlsx,.csv" :accept="DocumentParseFilePolicy.accept"
@change="handleNativeSelection" @change="handleNativeSelection"
/> />
<input <input
@@ -546,6 +550,7 @@ async function createClientFileKey(relativePath: string) {
class="native-file-input" class="native-file-input"
type="file" type="file"
multiple multiple
:accept="DocumentParseFilePolicy.accept"
webkitdirectory webkitdirectory
@change="handleNativeSelection" @change="handleNativeSelection"
/> />
@@ -564,7 +569,11 @@ async function createClientFileKey(relativePath: string) {
{{ $t('documentCollection.importDoc.batchUploadTitle') }} {{ $t('documentCollection.importDoc.batchUploadTitle') }}
</div> </div>
<div class="batch-drop-zone__description"> <div class="batch-drop-zone__description">
{{ $t('documentCollection.importDoc.batchUploadDescription') }} {{
$t('documentCollection.importDoc.batchUploadDescription', {
types: DocumentParseFilePolicy.supportedTypeLabel,
})
}}
</div> </div>
<ElButton <ElButton
:icon="FolderOpened" :icon="FolderOpened"

View File

@@ -11,6 +11,7 @@ import { ElButton, ElIcon, ElLink, ElMessage } from 'element-plus';
import { api } from '#/api/request'; import { api } from '#/api/request';
import { $t } from '#/locales'; import { $t } from '#/locales';
import { DocumentParseFilePolicy } from '#/utils/document-parse-file-policy';
import { import {
appendWorkflowFileValues, appendWorkflowFileValues,
@@ -19,7 +20,6 @@ import {
normalizeWorkflowFileValues, normalizeWorkflowFileValues,
validateWorkflowFileSelection, validateWorkflowFileSelection,
validateWorkflowFileValues, validateWorkflowFileValues,
WORKFLOW_FILE_LIMITS,
} from './workflowFileValue'; } from './workflowFileValue';
const props = defineProps({ const props = defineProps({
@@ -50,9 +50,6 @@ const fileInputRef = ref<HTMLInputElement | null>(null);
const currentFiles = computed(() => const currentFiles = computed(() =>
normalizeWorkflowFileValues(props.modelValue), normalizeWorkflowFileValues(props.modelValue),
); );
const maxSingleFileSizeText = formatWorkflowFileSize(
WORKFLOW_FILE_LIMITS.maxSingleSize,
).replace('.0 ', ' ');
function triggerSelectFile() { function triggerSelectFile() {
if (props.disabled || uploadLoading.value) { if (props.disabled || uploadLoading.value) {
@@ -69,6 +66,7 @@ async function uploadFiles(files: File[]) {
uploadLoading.value = true; uploadLoading.value = true;
try { try {
validateWorkflowFileSelection(currentFiles.value, files); validateWorkflowFileSelection(currentFiles.value, files);
await DocumentParseFilePolicy.validateFiles(files);
const uploadedFiles = []; const uploadedFiles = [];
for (const file of files) { for (const file of files) {
const res = await api.upload( const res = await api.upload(
@@ -134,6 +132,7 @@ function removeFile(filePath: string) {
ref="fileInputRef" ref="fileInputRef"
class="workflow-file-input__native" class="workflow-file-input__native"
type="file" type="file"
:accept="DocumentParseFilePolicy.accept"
:disabled="disabled" :disabled="disabled"
multiple multiple
@change="handleNativeFileChange" @change="handleNativeFileChange"
@@ -167,7 +166,16 @@ function removeFile(filePath: string) {
: '拖入文件或点击上传' : '拖入文件或点击上传'
}} }}
</span> </span>
<small>单个文件不超过 {{ maxSingleFileSizeText }}</small> <small class="workflow-file-input__hint">
<span>
{{
$t('message.upload.supportedTypes', {
types: DocumentParseFilePolicy.supportedTypeLabel,
})
}}
</span>
<span>{{ $t('message.upload.singleFileLimit') }}</span>
</small>
</span> </span>
</button> </button>
</div> </div>
@@ -287,13 +295,15 @@ function removeFile(filePath: string) {
.workflow-file-input__dropzone-copy { .workflow-file-input__dropzone-copy {
display: flex; display: flex;
flex-flow: row wrap; flex-direction: column;
gap: var(--space-1) var(--space-2); gap: var(--space-1);
align-items: center; align-items: flex-start;
line-height: 1.4; line-height: 1.4;
} }
.workflow-file-input__dropzone-copy small { .workflow-file-input__hint {
display: flex;
flex-direction: column;
font-size: 11px; font-size: 11px;
color: var(--el-text-color-placeholder); color: var(--el-text-color-placeholder);
} }

View File

@@ -1,11 +1,62 @@
import { mount } from '@vue/test-utils'; import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, nextTick, ref } from 'vue'; import { defineComponent, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import WorkflowFileInput from '../WorkflowFileInput.vue'; import WorkflowFileInput from '../WorkflowFileInput.vue';
const requestMocks = vi.hoisted(() => ({
upload: vi.fn(),
}));
vi.mock('#/api/request', () => ({
api: {
upload: requestMocks.upload,
},
}));
vi.mock('#/locales', () => ({
$t: (key: string, params?: Record<string, string>) => {
if (key === 'message.upload.supportedTypes') {
return `支持 ${params?.types} 文件`;
}
if (key === 'message.upload.singleFileLimit') {
return '单个文件不超过 100 MB。';
}
if (key === 'message.upload.unsupportedFileType') {
return `${params?.fileName}”格式不支持,仅支持 ${params?.types} 文件。`;
}
if (key === 'message.upload.legacyExcelAsXlsx') {
return `${params?.fileName}”不是标准 XLSX`;
}
return key;
},
}));
describe('workflow file input', () => { describe('workflow file input', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('limits the file chooser and shows the supported formats', () => {
const wrapper = mount(WorkflowFileInput);
expect(wrapper.get('input[type="file"]').attributes('accept')).toBe(
'.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv',
);
expect(wrapper.text()).toContain(
'支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件',
);
expect(
wrapper
.findAll('.workflow-file-input__hint > span')
.map((item) => item.text()),
).toEqual([
'支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件',
'单个文件不超过 100 MB。',
]);
});
it('shows the upload area again after the uploaded file is deleted', async () => { it('shows the upload area again after the uploaded file is deleted', async () => {
const Host = defineComponent({ const Host = defineComponent({
components: { WorkflowFileInput }, components: { WorkflowFileInput },
@@ -55,4 +106,39 @@ describe('workflow file input', () => {
wrapper.get('.workflow-file-input__upload-trigger').attributes(), wrapper.get('.workflow-file-input__upload-trigger').attributes(),
).toHaveProperty('disabled'); ).toHaveProperty('disabled');
}); });
it('rejects legacy XLS content before starting upload', async () => {
requestMocks.upload.mockReset();
vi.spyOn(console, 'error').mockImplementation(() => {});
const wrapper = mount(WorkflowFileInput);
const input = wrapper.get('input[type="file"]');
const file = new File(
[new Uint8Array([208, 207, 17, 224, 161, 177, 26, 225])],
'legacy.xlsx',
);
Object.defineProperty(input.element, 'files', {
configurable: true,
value: [file],
});
await input.trigger('change');
await flushPromises();
expect(requestMocks.upload).not.toHaveBeenCalled();
});
it('rejects a dragged legacy XLS file before starting upload', async () => {
requestMocks.upload.mockReset();
vi.spyOn(console, 'error').mockImplementation(() => {});
const wrapper = mount(WorkflowFileInput);
await wrapper.get('.workflow-file-input__dropzone').trigger('drop', {
dataTransfer: {
files: [new File(['legacy'], 'test.xls')],
},
});
await flushPromises();
expect(requestMocks.upload).not.toHaveBeenCalled();
});
}); });