import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; import path from 'path'; import { writeFile, mkdir, unlink, stat } from 'fs/promises'; import { existsSync } from 'fs'; jest.mock('fs/promises'); jest.mock('fs'); jest.mock('nanoid', () => ({ nanoid: jest.fn(() => 'test-id-12345'), })); const mockedWriteFile = writeFile as jest.MockedFunction; const mockedMkdir = mkdir as jest.MockedFunction; const mockedUnlink = unlink as jest.MockedFunction; const mockedStat = stat as jest.MockedFunction; const mockedExistsSync = existsSync as jest.MockedFunction; describe('Upload Module', () => { beforeEach(() => { jest.clearAllMocks(); process.env.UPLOAD_DIR = './test-uploads'; }); afterEach(() => { delete process.env.UPLOAD_DIR; }); describe('getFileExtension', () => { it('should return correct extension for JPEG', async () => { const { getFileExtension } = await import('./upload'); expect(getFileExtension('image/jpeg')).toBe('.jpg'); }); it('should return correct extension for PNG', async () => { const { getFileExtension } = await import('./upload'); expect(getFileExtension('image/png')).toBe('.png'); }); it('should return correct extension for PDF', async () => { const { getFileExtension } = await import('./upload'); expect(getFileExtension('application/pdf')).toBe('.pdf'); }); it('should return empty string for unknown MIME type', async () => { const { getFileExtension } = await import('./upload'); expect(getFileExtension('unknown/type')).toBe(''); }); }); describe('isAllowedType', () => { it('should return true for allowed image types', async () => { const { isAllowedType } = await import('./upload'); expect(isAllowedType('image/jpeg', 'image')).toBe(true); expect(isAllowedType('image/png', 'image')).toBe(true); expect(isAllowedType('image/gif', 'image')).toBe(true); }); it('should return true for allowed document types', async () => { const { isAllowedType } = await import('./upload'); expect(isAllowedType('application/pdf', 'document')).toBe(true); expect(isAllowedType('application/msword', 'document')).toBe(true); }); it('should return false for disallowed types', async () => { const { isAllowedType } = await import('./upload'); expect(isAllowedType('image/jpeg', 'document')).toBe(false); expect(isAllowedType('application/pdf', 'image')).toBe(false); }); it('should return false for unknown MIME types', async () => { const { isAllowedType } = await import('./upload'); expect(isAllowedType('unknown/type', 'image')).toBe(false); }); it('should handle null type', async () => { const { isAllowedType } = await import('./upload'); expect(isAllowedType(null as any, 'image')).toBe(false); }); it('should handle undefined type', async () => { const { isAllowedType } = await import('./upload'); expect(isAllowedType(undefined as any, 'image')).toBe(false); }); it('should handle empty string type', async () => { const { isAllowedType } = await import('./upload'); expect(isAllowedType('', 'image')).toBe(false); }); it('should handle invalid category', async () => { const { isAllowedType } = await import('./upload'); expect(isAllowedType('image/jpeg', 'invalid' as any)).toBe(false); }); }); describe('validateFileSignature', () => { it('should validate JPEG signature correctly', async () => { const { validateFileSignature } = await import('./upload'); const validJpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0x00, 0x00]); expect(validateFileSignature(validJpegBuffer, 'image/jpeg')).toBe(true); }); it('should validate PNG signature correctly', async () => { const { validateFileSignature } = await import('./upload'); const validPngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]); expect(validateFileSignature(validPngBuffer, 'image/png')).toBe(true); }); it('should validate PDF signature correctly', async () => { const { validateFileSignature } = await import('./upload'); const validPdfBuffer = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00]); expect(validateFileSignature(validPdfBuffer, 'application/pdf')).toBe(true); }); it('should return false for invalid JPEG signature', async () => { const { validateFileSignature } = await import('./upload'); const invalidBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); expect(validateFileSignature(invalidBuffer, 'image/jpeg')).toBe(false); }); it('should return true for SVG files', async () => { const { validateFileSignature } = await import('./upload'); const svgBuffer = Buffer.from(''); expect(validateFileSignature(svgBuffer, 'image/svg+xml')).toBe(true); }); it('should return true for unknown MIME types', async () => { const { validateFileSignature } = await import('./upload'); const buffer = Buffer.from([0x00, 0x00, 0x00]); expect(validateFileSignature(buffer, 'unknown/type')).toBe(true); }); }); describe('sanitizeFileName', () => { it('should remove special characters', async () => { const { sanitizeFileName } = await import('./upload'); expect(sanitizeFileName('test<>:"/\\|?*.jpg')).toBe('test_________.jpg'); }); it('should convert to lowercase', async () => { const { sanitizeFileName } = await import('./upload'); expect(sanitizeFileName('TestFile.JPG')).toBe('testfile.jpg'); }); it('should replace multiple dots', async () => { const { sanitizeFileName } = await import('./upload'); expect(sanitizeFileName('test..file...jpg')).toBe('test.file.jpg'); }); it('should preserve Chinese characters', async () => { const { sanitizeFileName } = await import('./upload'); expect(sanitizeFileName('测试文件.jpg')).toBe('测试文件.jpg'); }); it('should preserve underscores and hyphens', async () => { const { sanitizeFileName } = await import('./upload'); expect(sanitizeFileName('test_file-name.jpg')).toBe('test_file-name.jpg'); }); }); describe('isDangerousFile', () => { it('should detect .exe files as dangerous', async () => { const { isDangerousFile } = await import('./upload'); expect(isDangerousFile('malware.exe')).toBe(true); }); it('should detect .bat files as dangerous', async () => { const { isDangerousFile } = await import('./upload'); expect(isDangerousFile('script.bat')).toBe(true); }); it('should detect .php files as dangerous', async () => { const { isDangerousFile } = await import('./upload'); expect(isDangerousFile('webshell.php')).toBe(true); }); it('should detect .js files as dangerous', async () => { const { isDangerousFile } = await import('./upload'); expect(isDangerousFile('script.js')).toBe(true); }); it('should not flag safe files as dangerous', async () => { const { isDangerousFile } = await import('./upload'); expect(isDangerousFile('image.jpg')).toBe(false); expect(isDangerousFile('document.pdf')).toBe(false); }); it('should be case insensitive', async () => { const { isDangerousFile } = await import('./upload'); expect(isDangerousFile('MALWARE.EXE')).toBe(true); expect(isDangerousFile('Script.BAT')).toBe(true); }); }); describe('getDatePath', () => { it('should return correct date path format', async () => { const { getDatePath } = await import('./upload'); const datePath = getDatePath(); const regex = /^\d{4}\/\d{2}\/\d{2}$/; expect(regex.test(datePath)).toBe(true); }); it('should pad month and day with zeros', async () => { const { getDatePath } = await import('./upload'); const datePath = getDatePath(); const parts = datePath.split('/'); expect(parts[1].length).toBe(2); expect(parts[2].length).toBe(2); }); }); describe('uploadFile', () => { const createMockFile = (overrides: Partial = {}): File => { return { name: 'test.jpg', size: 1024, type: 'image/jpeg', arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(1024)), ...overrides, } as any; }; it('should upload a valid image file successfully', async () => { const { uploadFile } = await import('./upload'); const validJpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0x00, 0x00]); const mockFile = createMockFile({ arrayBuffer: jest.fn().mockResolvedValue(validJpegBuffer), }); mockedExistsSync.mockReturnValue(true); mockedWriteFile.mockResolvedValue(); const result = await uploadFile(mockFile, { type: 'image' }); expect(result.id).toBe('test-id-12345'); expect(result.name).toBe('test.jpg'); expect(result.type).toBe('image/jpeg'); expect(result.url).toContain('/uploads/image/'); expect(result.url).toContain('.jpg'); }); it('should reject files exceeding size limit', async () => { const { uploadFile } = await import('./upload'); const largeFile = createMockFile({ size: 10 * 1024 * 1024, }); await expect(uploadFile(largeFile, { type: 'image', maxSize: 5 * 1024 * 1024 })) .rejects.toThrow('文件大小超过限制'); }); it('should reject disallowed file types', async () => { const { uploadFile } = await import('./upload'); const invalidFile = createMockFile({ type: 'application/exe', }); await expect(uploadFile(invalidFile, { type: 'image' })) .rejects.toThrow('不支持的文件类型'); }); it('should reject dangerous file extensions', async () => { const { uploadFile } = await import('./upload'); const dangerousFile = createMockFile({ name: 'malware.exe', type: 'application/pdf', }); await expect(uploadFile(dangerousFile, { type: 'document' })) .rejects.toThrow('不允许上传此类型的文件'); }); it('should reject files with mismatched signatures', async () => { const { uploadFile } = await import('./upload'); const fakeJpegBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); const fakeFile = createMockFile({ type: 'image/jpeg', arrayBuffer: jest.fn().mockResolvedValue(fakeJpegBuffer), }); await expect(uploadFile(fakeFile, { type: 'image' })) .rejects.toThrow('文件内容与声明类型不匹配'); }); it('should create upload directory if it does not exist', async () => { const { uploadFile } = await import('./upload'); const validJpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0x00, 0x00]); const mockFile = createMockFile({ arrayBuffer: jest.fn().mockResolvedValue(validJpegBuffer), }); mockedExistsSync.mockReturnValue(false); mockedMkdir.mockResolvedValue(undefined as any); mockedWriteFile.mockResolvedValue(); await uploadFile(mockFile, { type: 'image' }); expect(mockedMkdir).toHaveBeenCalled(); }); it('should include userId in upload result', async () => { const { uploadFile } = await import('./upload'); const validJpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0x00, 0x00]); const mockFile = createMockFile({ arrayBuffer: jest.fn().mockResolvedValue(validJpegBuffer), }); mockedExistsSync.mockReturnValue(true); mockedWriteFile.mockResolvedValue(); const result = await uploadFile(mockFile, { type: 'image', userId: 'user-123' }); expect(result.uploadedBy).toBe('user-123'); }); it('should sanitize file name', async () => { const { uploadFile } = await import('./upload'); const validJpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0x00, 0x00]); const mockFile = createMockFile({ name: 'Test<>File.JPG', arrayBuffer: jest.fn().mockResolvedValue(validJpegBuffer), }); mockedExistsSync.mockReturnValue(true); mockedWriteFile.mockResolvedValue(); const result = await uploadFile(mockFile, { type: 'image' }); expect(result.name).toBe('test__file.jpg'); }); }); describe('deleteFile', () => { it('should delete existing file successfully', async () => { const { deleteFile } = await import('./upload'); mockedExistsSync.mockReturnValue(true); mockedUnlink.mockResolvedValue(); const result = await deleteFile('/uploads/image/test.jpg'); expect(result).toBe(true); expect(mockedUnlink).toHaveBeenCalled(); }); it('should return false for non-existent file', async () => { const { deleteFile } = await import('./upload'); mockedExistsSync.mockReturnValue(false); const result = await deleteFile('/uploads/image/nonexistent.jpg'); expect(result).toBe(false); expect(mockedUnlink).not.toHaveBeenCalled(); }); it('should return false on deletion error', async () => { const { deleteFile } = await import('./upload'); mockedExistsSync.mockReturnValue(true); mockedUnlink.mockRejectedValue(new Error('Permission denied')); const result = await deleteFile('/uploads/image/test.jpg'); expect(result).toBe(false); }); }); describe('getFileInfo', () => { it('should return file information for existing file', async () => { const { getFileInfo } = await import('./upload'); const mockStats = { size: 1024, birthtime: new Date('2024-01-01'), mtime: new Date('2024-01-02'), }; mockedStat.mockResolvedValue(mockStats as any); const result = await getFileInfo('/path/to/file.jpg'); expect(result).toEqual({ size: 1024, createdAt: mockStats.birthtime, modifiedAt: mockStats.mtime, }); }); it('should return null for non-existent file', async () => { const { getFileInfo } = await import('./upload'); mockedStat.mockRejectedValue(new Error('File not found')); const result = await getFileInfo('/path/to/nonexistent.jpg'); expect(result).toBeNull(); }); }); describe('Security Tests', () => { it('should prevent path traversal attacks', async () => { const { sanitizeFileName } = await import('./upload'); const maliciousName = '../../../etc/passwd'; const sanitized = sanitizeFileName(maliciousName); expect(sanitized).not.toContain('/'); expect(sanitized).not.toContain('..'); }); it('should prevent double extension attacks', async () => { const { sanitizeFileName } = await import('./upload'); const maliciousName = 'image.jpg.php'; const sanitized = sanitizeFileName(maliciousName); expect(sanitized).toBe('image.jpg.php'); }); it('should validate file signatures to prevent MIME type spoofing', async () => { const { validateFileSignature } = await import('./upload'); const fakeJpegBuffer = Buffer.from([0x25, 0x50, 0x44, 0x46]); expect(validateFileSignature(fakeJpegBuffer, 'image/jpeg')).toBe(false); const realJpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF]); expect(validateFileSignature(realJpegBuffer, 'image/jpeg')).toBe(true); }); it('should reject all dangerous file extensions', async () => { const { isDangerousFile } = await import('./upload'); const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.php', '.jsp', '.asp', '.aspx', '.js']; dangerousExtensions.forEach(ext => { expect(isDangerousFile(`malware${ext}`)).toBe(true); }); }); }); });