gongxiangp2/lib/__tests__/regenerate-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

303 lines
12 KiB
TypeScript

/** @jest-environment node */
import AdmZip from 'adm-zip'
import { getServerSession } from 'next-auth'
import { POST } from '@/app/api/admin/regenerate-preview/route'
import { readBoundedRegularFile } from '@/lib/bounded-file-reader'
import { sliceImage } from '@/lib/image-processor'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-process-lock'
import { prisma } from '@/lib/prisma'
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
import { deleteFromS3, verifyUploadedS3Object } from '@/lib/s3'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({
isSuperAdmin: jest.fn().mockResolvedValue(false),
hasSystemAdminPermission: jest.fn((role: string, isSuperAdmin: boolean) =>
isSuperAdmin || role === 'ADMIN' || role === 'SYSTEM_ADMIN'),
}))
jest.mock('fs/promises', () => ({
mkdtemp: jest.fn().mockResolvedValue('/uploads/temp/regenerate-material-1-unique'),
rm: jest.fn().mockResolvedValue(undefined),
}))
jest.mock('@/lib/bounded-file-reader', () => ({ readBoundedRegularFile: jest.fn() }))
jest.mock('@/lib/path-security', () => ({
ensureSafeDirectoryWithinRoot: jest.fn().mockResolvedValue('/uploads/temp'),
isPathFreeOfSymbolicLinks: jest.fn().mockResolvedValue(true),
isPathInAllowedDirs: jest.fn().mockReturnValue(true),
}))
jest.mock('@/lib/upload-paths', () => ({
getUploadRootDir: jest.fn().mockReturnValue('/uploads'),
resolveStoredUploadPath: jest.fn().mockReturnValue('/uploads/materials/detail.zip'),
}))
jest.mock('@/lib/material-upload-cross-process-lock', () => ({
acquireTaskMaterialCompletionLock: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
$transaction: jest.fn(),
material: { findUnique: jest.fn(), update: jest.fn() },
materialAsset: {
findMany: jest.fn(),
deleteMany: jest.fn(),
createMany: jest.fn(),
},
},
}))
jest.mock('sharp', () => jest.fn(() => ({
metadata: jest.fn().mockResolvedValue({ width: 1200, height: 2400, pages: 1 }),
})))
jest.mock('@/lib/image-processor', () => ({
assertProcessableMetadata: jest.fn(),
sliceImage: jest.fn(),
}))
jest.mock('@/lib/image-processing-limiter', () => ({
withImageProcessingSlot: jest.fn((task: () => Promise<unknown>) => task()),
}))
jest.mock('@/lib/public-display', () => ({
...jest.requireActual('@/lib/public-display'),
uploadPublicDisplayToS3: jest.fn(),
}))
jest.mock('@/lib/s3', () => ({
deleteFromS3: jest.fn(),
verifyUploadedS3Object: jest.fn(),
}))
describe('admin regenerate-preview route', () => {
const releaseMaterialLock = Object.assign(jest.fn().mockResolvedValue(undefined), {
assertHeld: jest.fn(),
})
const uploadedUrls = [
'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/new-1.webp',
'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/new-2.webp',
]
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN' },
})
;(acquireTaskMaterialCompletionLock as jest.Mock).mockResolvedValue(releaseMaterialLock)
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
previewUrl: 'https://s3.jiansun.vip/old.webp',
})
;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([{
materialId: 'material-1',
assetType: 'detail',
fileUrl: 'uploads/materials/detail.zip',
sortOrder: 0,
}])
const zip = new AdmZip()
zip.addFile('safe/detail.png', Buffer.from('bounded-detail-image'))
;(readBoundedRegularFile as jest.Mock).mockResolvedValue(zip.toBuffer())
;(sliceImage as jest.Mock).mockResolvedValue([
Buffer.from('slice-1'),
Buffer.from('slice-2'),
])
;(uploadPublicDisplayToS3 as jest.Mock)
.mockResolvedValueOnce(uploadedUrls[0])
.mockResolvedValueOnce(uploadedUrls[1])
;(verifyUploadedS3Object as jest.Mock).mockResolvedValue(undefined)
;(deleteFromS3 as jest.Mock).mockResolvedValue(undefined)
;(prisma.$transaction as jest.Mock).mockImplementation(async (action) => action({
material: prisma.material,
materialAsset: prisma.materialAsset,
}))
;(prisma.materialAsset.deleteMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.materialAsset.createMany as jest.Mock).mockResolvedValue({ count: 2 })
;(prisma.material.update as jest.Mock).mockResolvedValue(undefined)
})
function request(materialId = 'material-1') {
const body = JSON.stringify({ materialId })
return new Request('http://localhost/api/admin/regenerate-preview', {
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(body)),
},
body,
}) as any
}
function rawRequest(
body: string,
headers: Record<string, string> = {},
) {
return new Request('http://localhost/api/admin/regenerate-preview', {
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(body)),
...headers,
},
body,
}) as any
}
test('新对象全部验证后,在同一事务替换详情预览资产和卡片封面', async () => {
const response = await POST(request())
const body = await response.json()
expect(response.status).toBe(200)
expect(body).toMatchObject({ success: true, previewCount: 2, previewUrls: uploadedUrls })
expect(acquireTaskMaterialCompletionLock).toHaveBeenCalledWith('material-1')
expect(readBoundedRegularFile).toHaveBeenCalledWith('/uploads/materials/detail.zip', {
maxBytes: 64 * 1024 * 1024,
label: '详情图压缩包',
})
expect(withImageProcessingSlot).toHaveBeenCalledTimes(1)
expect(sliceImage).toHaveBeenCalledWith(
Buffer.from('bounded-detail-image'),
1500,
200,
)
expect(verifyUploadedS3Object).toHaveBeenNthCalledWith(
1,
uploadedUrls[0],
Buffer.byteLength('slice-1'),
'image/webp',
)
expect((verifyUploadedS3Object as jest.Mock).mock.invocationCallOrder[1])
.toBeLessThan((prisma.$transaction as jest.Mock).mock.invocationCallOrder[0])
expect(verifyUploadedS3Object).toHaveBeenNthCalledWith(
2,
uploadedUrls[1],
Buffer.byteLength('slice-2'),
'image/webp',
)
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
expect((prisma.materialAsset.deleteMany as jest.Mock).mock.invocationCallOrder[0])
.toBeLessThan((prisma.materialAsset.createMany as jest.Mock).mock.invocationCallOrder[0])
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: { previewUrl: uploadedUrls[0] },
})
expect(deleteFromS3).not.toHaveBeenCalled()
expect(releaseMaterialLock).toHaveBeenCalledTimes(1)
})
test('ZIP 包含路径穿越条目时拒绝处理且不改旧资产', async () => {
const zip = new AdmZip()
zip.addFile('xx/escape.png', Buffer.from('evil'))
const unsafeZip = zip.toBuffer()
const safeName = Buffer.from('xx/escape.png')
const unsafeName = Buffer.from('../escape.png')
let offset = 0
while ((offset = unsafeZip.indexOf(safeName, offset)) >= 0) {
unsafeName.copy(unsafeZip, offset)
offset += unsafeName.length
}
;(readBoundedRegularFile as jest.Mock).mockResolvedValue(unsafeZip)
const response = await POST(request())
expect(response.status).toBe(400)
expect(sliceImage).not.toHaveBeenCalled()
expect(uploadPublicDisplayToS3).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('ZIP 本体超过上限时直接失败,不打开、不处理也不改数据库', async () => {
;(readBoundedRegularFile as jest.Mock).mockRejectedValueOnce(
new Error('详情图压缩包超过 64MB 限制'),
)
const response = await POST(request())
expect(response.status).toBe(400)
expect(sliceImage).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(deleteFromS3).not.toHaveBeenCalled()
})
test('事务明确失败时只清理本次新对象,不删除旧资产或源 ZIP', async () => {
;(prisma.$transaction as jest.Mock).mockRejectedValueOnce(new Error('db rejected'))
;(prisma.material.findUnique as jest.Mock)
.mockResolvedValueOnce({ id: 'material-1', previewUrl: 'https://s3.jiansun.vip/old.webp' })
.mockResolvedValueOnce({ previewUrl: 'https://s3.jiansun.vip/old.webp' })
;(prisma.materialAsset.findMany as jest.Mock)
.mockResolvedValueOnce([{
materialId: 'material-1',
assetType: 'detail',
fileUrl: 'uploads/materials/detail.zip',
sortOrder: 0,
}])
.mockResolvedValueOnce([{
fileUrl: 'https://s3.jiansun.vip/old-preview.webp',
sortOrder: 0,
}])
const response = await POST(request())
const body = await response.json()
expect(response.status).toBe(500)
expect(body.error).toBe('预览重建失败,请稍后重试')
expect(body.error).not.toContain('db rejected')
expect(deleteFromS3).toHaveBeenCalledTimes(2)
expect(deleteFromS3).toHaveBeenCalledWith(uploadedUrls[0])
expect(deleteFromS3).toHaveBeenCalledWith(uploadedUrls[1])
expect(deleteFromS3).not.toHaveBeenCalledWith('uploads/materials/detail.zip')
})
test('事务响应丢失但数据库已提交时视为成功,绝不删除已引用的新对象', async () => {
;(prisma.$transaction as jest.Mock).mockRejectedValueOnce(new Error('commit response lost'))
;(prisma.material.findUnique as jest.Mock)
.mockResolvedValueOnce({ id: 'material-1', previewUrl: 'https://s3.jiansun.vip/old.webp' })
.mockResolvedValueOnce({ previewUrl: uploadedUrls[0] })
;(prisma.materialAsset.findMany as jest.Mock)
.mockResolvedValueOnce([{
materialId: 'material-1',
assetType: 'detail',
fileUrl: 'uploads/materials/detail.zip',
sortOrder: 0,
}])
.mockResolvedValueOnce(uploadedUrls.map((fileUrl, sortOrder) => ({ fileUrl, sortOrder })))
const response = await POST(request())
expect(response.status).toBe(200)
expect(deleteFromS3).not.toHaveBeenCalled()
})
test('普通登录用户不得重建其他素材的预览', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
const response = await POST(request())
expect(response.status).toBe(403)
expect(prisma.material.findUnique).not.toHaveBeenCalled()
expect(uploadPublicDisplayToS3).not.toHaveBeenCalled()
})
test.each([
['gzip 请求体', rawRequest('{"materialId":"material-1"}', { 'content-encoding': 'gzip' }), 415],
['额外字段', rawRequest('{"materialId":"material-1","admin":true}'), 400],
['声明长度小于实际值', rawRequest('{"materialId":"material-1"}', { 'content-length': '1' }), 413],
])('%s 在数据库和图像处理前被拒绝', async (_label, unsafeRequest, expectedStatus) => {
const response = await POST(unsafeRequest as any)
expect(response.status).toBe(expectedStatus)
expect(prisma.material.findUnique).not.toHaveBeenCalled()
expect(withImageProcessingSlot).not.toHaveBeenCalled()
})
test('缺少 Content-Length 的大流在解析前 fail-closed', async () => {
const largeBody = JSON.stringify({ materialId: 'x'.repeat(3 * 1024) })
const unsafeRequest = rawRequest(largeBody)
unsafeRequest.headers.delete('content-length')
const response = await POST(unsafeRequest as any)
expect(response.status).toBe(411)
expect(prisma.material.findUnique).not.toHaveBeenCalled()
expect(withImageProcessingSlot).not.toHaveBeenCalled()
})
})