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