/** @jest-environment node */ import { POST } from '@/app/api/materials/upload/init/route' import { getServerSession } from 'next-auth' import { saveUploadSession } from '@/lib/upload-sessions' import { ensureSafeDirectoryWithinRoot } from '@/lib/path-security' import { rm, statfs, writeFile } from 'fs/promises' jest.mock('next-auth', () => ({ getServerSession: jest.fn(), })) jest.mock('@/lib/auth', () => ({ authOptions: {}, })) jest.mock('@/lib/upload-sessions', () => ({ saveUploadSession: jest.fn().mockResolvedValue(undefined), })) jest.mock('@/lib/path-security', () => { const actual = jest.requireActual('@/lib/path-security') return { ...actual, ensureSafeDirectoryWithinRoot: jest.fn().mockResolvedValue('D:\\temp\\uploads\\temp\\upload-fixed'), } }) jest.mock('fs/promises', () => ({ rm: jest.fn().mockResolvedValue(undefined), statfs: jest.fn().mockResolvedValue({ bavail: BigInt(1024) * BigInt(1024), bsize: BigInt(1024) * BigInt(1024), }), writeFile: jest.fn().mockResolvedValue(undefined), })) jest.mock('crypto', () => ({ randomBytes: jest.fn(() => ({ toString: () => 'fixed-upload-id', })), })) function createRequest(body: unknown, options?: { jsonReject?: boolean }) { return { json: jest.fn().mockImplementation(() => { if (options?.jsonReject) { return Promise.reject(new Error('invalid json')) } return Promise.resolve(body) }), } as any } describe('/api/materials/upload/init', () => { beforeEach(() => { jest.clearAllMocks() ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' }, }) process.env.UPLOAD_DIR = 'D:/temp/uploads' }) test('缺少文件名时返回明确错误', async () => { const response = await POST(createRequest({ fileName: ' ', fileSize: 1, totalChunks: 1, })) const body = await response.json() expect(response.status).toBe(400) expect(body).toEqual({ error: '文件名不能为空' }) }) test('空文件时返回明确错误', async () => { const response = await POST(createRequest({ fileName: 'source.psd', fileSize: 0, totalChunks: 1, })) const body = await response.json() expect(response.status).toBe(400) expect(body).toEqual({ error: '上传文件不能为空' }) }) test('无效分片参数时返回明确错误', async () => { const response = await POST(createRequest({ fileName: 'source.psd', fileSize: 1024, totalChunks: 0, })) const body = await response.json() expect(response.status).toBe(400) expect(body).toEqual({ error: '上传分片参数无效' }) }) test('超过10GB的逻辑源文件在创建会话前拒绝', async () => { const response = await POST(createRequest({ fileName: 'huge.psd', fileSize: 10 * 1024 * 1024 * 1024 + 1, totalChunks: 1024, })) const body = await response.json() expect(response.status).toBe(413) expect(body.code).toBe('SOURCE_FILE_TOO_LARGE') expect(ensureSafeDirectoryWithinRoot).not.toHaveBeenCalled() expect(saveUploadSession).not.toHaveBeenCalled() }) test('上传盘无法容纳分片和合并文件时清理空会话目录并拒绝', async () => { ;(statfs as jest.Mock).mockResolvedValueOnce({ bavail: BigInt(1), bsize: BigInt(1) }) const response = await POST(createRequest({ fileName: 'source.psd', fileSize: 20 * 1024 * 1024, totalChunks: 1, })) const body = await response.json() expect(response.status).toBe(507) expect(body.code).toBe('UPLOAD_STORAGE_LOW') expect(rm).toHaveBeenCalledWith( 'D:\\temp\\uploads\\temp\\upload-fixed', { recursive: true, force: true }, ) expect(saveUploadSession).not.toHaveBeenCalled() }) test('有效请求会保存标准化后的上传会话', async () => { const response = await POST(createRequest({ fileName: ' source.psb ', fileSize: '2048', totalChunks: '2', chunkSize: '1024', })) const body = await response.json() expect(response.status).toBe(200) expect(body).toEqual({ uploadId: expect.stringContaining('upload-') }) expect(ensureSafeDirectoryWithinRoot).toHaveBeenCalledWith( expect.any(String), 'temp', expect.stringContaining('upload-'), ) expect(writeFile).toHaveBeenCalled() expect(saveUploadSession).toHaveBeenCalledWith( expect.stringContaining('upload-'), expect.objectContaining({ fileName: 'source.psb', fileSize: 2048, totalChunks: 2, chunkSize: 1024, userId: 'user-1', }), ) }) test('拒绝总大小与分片数量不可能对应的伪造会话', async () => { const response = await POST(createRequest({ fileName: 'forged.psd', fileSize: 1, totalChunks: 1024, })) const body = await response.json() expect(response.status).toBe(400) expect(body.code).toBe('CHUNK_LAYOUT_INVALID') expect(ensureSafeDirectoryWithinRoot).not.toHaveBeenCalled() expect(saveUploadSession).not.toHaveBeenCalled() }) test.each([ '../outside.psd', '..\\outside.psd', '/app/server.js', 'C:\\Windows\\system.ini', '\\\\server\\share\\outside.psd', '%2e%2e%2foutside.psd', '%252e%252e%255coutside.psd', 'bad\u0000name.psd', 'folder/name.psd', ])('拒绝不安全文件名:%s', async (fileName) => { const response = await POST(createRequest({ fileName, fileSize: 1024, totalChunks: 1, })) expect(response.status).toBe(400) await expect(response.json()).resolves.toEqual({ error: '文件名包含不安全的路径或字符', }) expect(ensureSafeDirectoryWithinRoot).not.toHaveBeenCalled() expect(saveUploadSession).not.toHaveBeenCalled() }) test('拒绝超过文件系统叶子名称上限的文件名', async () => { const response = await POST(createRequest({ fileName: `${'文'.repeat(90)}.psd`, fileSize: 1024, totalChunks: 1, })) expect(response.status).toBe(400) expect(saveUploadSession).not.toHaveBeenCalled() }) })