Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
271 lines
8.7 KiB
TypeScript
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()
|
|
})
|
|
})
|