/** @jest-environment node */ import { POST } from '@/app/api/upload/psd/route' import { getServerSession } from 'next-auth' import { prisma } from '@/lib/prisma' import { ensureSafeDirectoryWithinRoot, isPathFreeOfSymbolicLinks, } from '@/lib/path-security' import { canManageMaterial } from '@/lib/material-authorization' import { writeFile } from 'fs/promises' jest.mock('next-auth', () => ({ getServerSession: jest.fn(), })) jest.mock('@/lib/auth', () => ({ authOptions: {}, })) jest.mock('@/lib/material-authorization', () => ({ canManageMaterial: jest.fn(), })) jest.mock('@/lib/prisma', () => ({ prisma: { material: { findUnique: jest.fn(), }, }, })) jest.mock('@/lib/upload-paths', () => { const actual = jest.requireActual('@/lib/upload-paths') return { ...actual, getUploadRootDir: jest.fn(() => 'D:\\safe-uploads'), } }) jest.mock('@/lib/path-security', () => { const actual = jest.requireActual('@/lib/path-security') return { ...actual, ensureSafeDirectoryWithinRoot: jest.fn().mockResolvedValue( 'D:\\safe-uploads\\product-package\\material-safe\\psd', ), isPathFreeOfSymbolicLinks: jest.fn().mockResolvedValue(true), } }) jest.mock('fs/promises', () => ({ writeFile: jest.fn().mockResolvedValue(undefined), })) jest.mock('crypto', () => ({ randomBytes: jest.fn(() => ({ toString: () => 'fixed-random', })), })) function createPhotoshopFile( name = 'source.psd', options?: { version?: 1 | 2 type?: string reportedSize?: number signature?: string }, ) { const buffer = Buffer.alloc(16) buffer.write(options?.signature ?? '8BPS', 0, 'ascii') buffer.writeUInt16BE(options?.version ?? 1, 4) return { name, type: options?.type ?? 'application/octet-stream', size: options?.reportedSize ?? buffer.length, arrayBuffer: jest.fn().mockResolvedValue( buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), ), } } function createRequest(input?: { file?: ReturnType | null materialId?: string contentLength?: string | null }) { const values = new Map([ ['file', input?.file === undefined ? createPhotoshopFile() : input.file], ['materialId', input?.materialId ?? 'material-request'], ]) return { headers: { get: jest.fn((name: string) => ( name.toLowerCase() === 'content-length' ? (input?.contentLength === undefined ? '1024' : input.contentLength) : null )), }, formData: jest.fn().mockResolvedValue({ get: (key: string) => values.get(key) ?? null, }), } as any } describe('/api/upload/psd', () => { beforeEach(() => { jest.clearAllMocks() ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' }, }) ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-safe', userId: 'user-1', deletedAt: null, }) ;(canManageMaterial as jest.Mock).mockResolvedValue(true) ;(ensureSafeDirectoryWithinRoot as jest.Mock).mockResolvedValue( 'D:\\safe-uploads\\product-package\\material-safe\\psd', ) ;(isPathFreeOfSymbolicLinks as jest.Mock).mockResolvedValue(true) }) test('缺少 Content-Length 时在解析 multipart 前拒绝请求', async () => { const request = createRequest({ contentLength: null }) const response = await POST(request) expect(response.status).toBe(411) expect(request.formData).not.toHaveBeenCalled() }) test('超大请求在解析 multipart 前返回 413', async () => { const request = createRequest({ contentLength: String(131 * 1024 * 1024) }) const response = await POST(request) expect(response.status).toBe(413) expect(request.formData).not.toHaveBeenCalled() }) test('不得向其他用户的素材目录上传 PSD', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-safe', userId: 'user-2', deletedAt: null, }) ;(canManageMaterial as jest.Mock).mockResolvedValue(false) const response = await POST(createRequest()) expect(response.status).toBe(403) expect(ensureSafeDirectoryWithinRoot).not.toHaveBeenCalled() expect(writeFile).not.toHaveBeenCalled() }) test('使用数据库返回的 material.id 构造受控目录', async () => { const response = await POST(createRequest({ materialId: '../../client-path' })) expect(response.status).toBe(200) expect(prisma.material.findUnique).toHaveBeenCalledWith( expect.objectContaining({ where: { id: '../../client-path' } }), ) expect(ensureSafeDirectoryWithinRoot).toHaveBeenCalledWith( 'D:\\safe-uploads', 'product-package', 'material-safe', 'psd', ) expect(writeFile).toHaveBeenCalledWith( expect.stringContaining('material-safe'), expect.any(Buffer), { flag: 'wx', mode: 0o600 }, ) await expect(response.json()).resolves.toEqual({ path: expect.stringMatching( /^uploads\/product-package\/material-safe\/psd\/psd-\d+-fixed-random\.psd$/, ), }) }) test('拒绝伪造扩展名或无效文件头', async () => { const response = await POST(createRequest({ file: createPhotoshopFile('source.psd', { signature: 'FAKE' }), })) expect(response.status).toBe(400) expect(writeFile).not.toHaveBeenCalled() }) test('PSD 与 PSB 版本必须和扩展名一致', async () => { const response = await POST(createRequest({ file: createPhotoshopFile('source.psd', { version: 2 }), })) expect(response.status).toBe(400) expect(writeFile).not.toHaveBeenCalled() }) test('拒绝不允许的 MIME 类型和超大单文件', async () => { const badMimeResponse = await POST(createRequest({ file: createPhotoshopFile('source.psd', { type: 'text/plain' }), })) expect(badMimeResponse.status).toBe(400) const oversizedResponse = await POST(createRequest({ file: createPhotoshopFile('source.psd', { reportedSize: 129 * 1024 * 1024, }), })) expect(oversizedResponse.status).toBe(413) expect(writeFile).not.toHaveBeenCalled() }) })