gongxiangp2/lib/__tests__/regenerate-pdf-preview-route.test.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

271 lines
8.7 KiB
TypeScript

/** @jest-environment node */
import path from 'path'
import { lstat, readFile, realpath } from 'fs/promises'
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { POST } from '@/app/api/admin/regenerate-pdf-preview/route'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import { processPdfUpload } from '@/lib/pdf-upload-handler'
import { prisma } from '@/lib/prisma'
import { resolveStoredUploadPath } from '@/lib/upload-paths'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({
isSuperAdmin: jest.fn(),
hasSystemAdminPermission: jest.fn(),
}))
jest.mock('@/lib/image-processing-limiter', () => ({
withImageProcessingSlot: jest.fn(),
}))
jest.mock('@/lib/pdf-upload-handler', () => ({
processPdfUpload: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
findUnique: jest.fn(),
update: jest.fn(),
},
},
}))
const uploadRoot = path.resolve('D:\\trusted-uploads')
const sourcePath = path.join(uploadRoot, 'materials', 'brand-vi.pdf')
jest.mock('@/lib/upload-paths', () => ({
getUploadRootDir: jest.fn(() => path.resolve('D:\\trusted-uploads')),
resolveStoredUploadPath: jest.fn(() => path.join(
path.resolve('D:\\trusted-uploads'),
'materials',
'brand-vi.pdf',
)),
}))
jest.mock('fs/promises', () => ({
...jest.requireActual('fs/promises'),
lstat: jest.fn(),
realpath: jest.fn(),
readFile: jest.fn(),
}))
type MockStatsOptions = {
directory?: boolean
symlink?: boolean
size?: number
dev?: number
ino?: number
mtimeMs?: number
}
function mockStats(options: MockStatsOptions = {}) {
return {
isDirectory: () => options.directory === true,
isSymbolicLink: () => options.symlink === true,
isFile: () => options.directory !== true && options.symlink !== true,
size: options.size ?? 1024,
dev: options.dev ?? 1,
ino: options.ino ?? 2,
mtimeMs: options.mtimeMs ?? 3,
}
}
function createRequest(
body: unknown = { materialId: 'material-1' },
headers: Record<string, string> = {},
) {
return new NextRequest('http://localhost/api/admin/regenerate-pdf-preview', {
method: 'POST',
headers: {
'content-type': 'application/json',
...headers,
},
body: JSON.stringify(body),
})
}
describe('admin regenerate-pdf-preview route', () => {
const originalAdminApiKey = process.env.ADMIN_API_KEY
let imageLockHeld = false
let consoleErrorSpy: jest.SpyInstance
beforeAll(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
})
beforeEach(() => {
jest.clearAllMocks()
imageLockHeld = false
process.env.ADMIN_API_KEY = 'configured-admin-key'
;(resolveStoredUploadPath as jest.Mock).mockReturnValue(sourcePath)
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN' },
})
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true)
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
materialType: '品牌VI灵感',
zipFilePath: 'uploads/materials/brand-vi.pdf',
})
;(prisma.material.update as jest.Mock).mockResolvedValue({ id: 'material-1' })
;(processPdfUpload as jest.Mock).mockImplementation(async () => {
if (!imageLockHeld) throw new Error('PDF content was read outside image lock')
return { success: true, previewUrl: 'https://preview.example/new.jpg' }
})
;(withImageProcessingSlot as jest.Mock).mockImplementation(
async (task: () => Promise<unknown>) => {
imageLockHeld = true
try {
return await task()
} finally {
imageLockHeld = false
}
},
)
;(lstat as jest.Mock).mockImplementation(async (filePath: string) => (
path.resolve(filePath) === uploadRoot
? mockStats({ directory: true, dev: 1, ino: 1 })
: mockStats()
))
;(realpath as jest.Mock).mockImplementation(async (filePath: string) => path.resolve(filePath))
})
afterAll(() => {
consoleErrorSpy.mockRestore()
if (originalAdminApiKey === undefined) {
delete process.env.ADMIN_API_KEY
} else {
process.env.ADMIN_API_KEY = originalAdminApiKey
}
})
test('普通登录用户不能重建 PDF 预览', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(false)
const response = await POST(createRequest())
expect(response.status).toBe(403)
expect(isSuperAdmin).toHaveBeenCalledWith('user-1')
expect(prisma.material.findUnique).not.toHaveBeenCalled()
expect(processPdfUpload).not.toHaveBeenCalled()
})
test('服务器未配置 API key 时,任意请求头都不能绕过登录', async () => {
delete process.env.ADMIN_API_KEY
;(getServerSession as jest.Mock).mockResolvedValue(null)
const response = await POST(createRequest(
{ materialId: 'material-1' },
{ 'x-api-key': 'attacker-controlled-value' },
))
expect(response.status).toBe(401)
expect(getServerSession).toHaveBeenCalledTimes(1)
expect(prisma.material.findUnique).not.toHaveBeenCalled()
})
test('已配置且匹配的 API key 可以调用,并且全部 PDF 内容处理都在全局锁内', async () => {
;(getServerSession as jest.Mock).mockRejectedValue(new Error('valid API key should skip session'))
const response = await POST(createRequest(
{ materialId: 'material-1' },
{ 'x-api-key': 'configured-admin-key' },
))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.previewUrl).toBe('https://preview.example/new.jpg')
expect(getServerSession).not.toHaveBeenCalled()
expect(withImageProcessingSlot).toHaveBeenCalledTimes(1)
expect(processPdfUpload).toHaveBeenCalledWith(sourcePath, '')
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: { previewUrl: 'https://preview.example/new.jpg' },
})
})
test('超大 PDF 只检查元数据,不读取文件内容或进入处理锁', async () => {
;(lstat as jest.Mock).mockImplementation(async (filePath: string) => (
path.resolve(filePath) === uploadRoot
? mockStats({ directory: true, dev: 1, ino: 1 })
: mockStats({ size: 128 * 1024 * 1024 + 1 })
))
const response = await POST(createRequest())
expect(response.status).toBe(413)
expect(lstat).toHaveBeenCalledWith(sourcePath)
expect(realpath).not.toHaveBeenCalled()
expect(readFile).not.toHaveBeenCalled()
expect(withImageProcessingSlot).not.toHaveBeenCalled()
expect(processPdfUpload).not.toHaveBeenCalled()
})
test('UPLOAD_DIR 之外的绝对路径会在访问文件前被拒绝', async () => {
;(resolveStoredUploadPath as jest.Mock).mockReturnValue(path.resolve('D:\\outside', 'secret.pdf'))
const response = await POST(createRequest())
expect(response.status).toBe(400)
expect(lstat).not.toHaveBeenCalled()
expect(realpath).not.toHaveBeenCalled()
expect(processPdfUpload).not.toHaveBeenCalled()
})
test('位于 UPLOAD_DIR 内的符号链接 PDF 会被拒绝', async () => {
;(lstat as jest.Mock).mockImplementation(async (filePath: string) => (
path.resolve(filePath) === uploadRoot
? mockStats({ directory: true, dev: 1, ino: 1 })
: mockStats({ symlink: true })
))
const response = await POST(createRequest())
expect(response.status).toBe(400)
expect(realpath).not.toHaveBeenCalled()
expect(withImageProcessingSlot).not.toHaveBeenCalled()
expect(processPdfUpload).not.toHaveBeenCalled()
})
test('父目录符号链接解析到 UPLOAD_DIR 外时会被拒绝', async () => {
;(realpath as jest.Mock).mockImplementation(async (filePath: string) => (
path.resolve(filePath) === uploadRoot
? uploadRoot
: path.resolve('D:\\outside', 'brand-vi.pdf')
))
const response = await POST(createRequest())
expect(response.status).toBe(400)
expect(withImageProcessingSlot).not.toHaveBeenCalled()
expect(processPdfUpload).not.toHaveBeenCalled()
})
test('请求体超过 2 KiB 时在数据库查询前拒绝', async () => {
const response = await POST(createRequest({
materialId: 'material-1',
padding: 'x'.repeat(3 * 1024),
}))
expect(response.status).toBe(413)
expect(prisma.material.findUnique).not.toHaveBeenCalled()
expect(lstat).not.toHaveBeenCalled()
})
})