216 lines
6.1 KiB
TypeScript
216 lines
6.1 KiB
TypeScript
/** @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<typeof createPhotoshopFile> | null
|
|
materialId?: string
|
|
contentLength?: string | null
|
|
}) {
|
|
const values = new Map<string, unknown>([
|
|
['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()
|
|
})
|
|
})
|