2026-09-16 15:28:42 +08:00

249 lines
7.2 KiB
TypeScript

/** @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 {
headers: { get: jest.fn(() => null) },
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: 'brand-guide.pdf',
fileSize: 2048,
totalChunks: 2,
chunkSize: 1024,
uploadPurpose: 'brand-knowledge',
}))
expect(response.status).toBe(200)
expect(writeFile).toHaveBeenCalledWith(
expect.stringMatching(/meta\.json$/),
expect.stringContaining('"uploadPurpose":"brand-knowledge"'),
'utf-8',
)
expect(saveUploadSession).toHaveBeenCalledWith(
expect.stringContaining('upload-'),
expect.objectContaining({ uploadPurpose: 'brand-knowledge' }),
)
})
test('拒绝未登记的上传用途', async () => {
const response = await POST(createRequest({
fileName: 'brand-guide.pdf',
fileSize: 2048,
totalChunks: 2,
chunkSize: 1024,
uploadPurpose: 'another-workflow',
}))
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: '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()
})
})