Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
257 lines
8.0 KiB
TypeScript
257 lines
8.0 KiB
TypeScript
/** @jest-environment node */
|
|
|
|
import { POST } from '@/app/api/materials/upload/product-package/abort/route'
|
|
import { getServerSession } from 'next-auth'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { getOwnedUploadSession } from '@/lib/upload-sessions'
|
|
import { safelyCleanupMaterialUploadSession } from '@/lib/temp-cleanup'
|
|
|
|
jest.mock('next-auth', () => ({
|
|
getServerSession: jest.fn(),
|
|
}))
|
|
|
|
jest.mock('@/lib/auth', () => ({
|
|
authOptions: {},
|
|
}))
|
|
|
|
jest.mock('@/lib/prisma', () => ({
|
|
prisma: {
|
|
$transaction: jest.fn(),
|
|
material: {
|
|
findUnique: jest.fn(),
|
|
delete: jest.fn(),
|
|
},
|
|
materialAsset: {
|
|
count: jest.fn(),
|
|
},
|
|
productPackageUploadJob: {
|
|
findFirst: jest.fn(),
|
|
},
|
|
},
|
|
}))
|
|
|
|
jest.mock('@/lib/upload-sessions', () => ({
|
|
getOwnedUploadSession: jest.fn(),
|
|
isSafeUploadId: jest.fn((uploadId: string) =>
|
|
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(String(uploadId || '').trim()),
|
|
),
|
|
}))
|
|
|
|
jest.mock('@/lib/temp-cleanup', () => ({
|
|
safelyCleanupMaterialUploadSession: jest.fn(),
|
|
}))
|
|
|
|
describe('product-package abort route security', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
;(getServerSession as jest.Mock).mockResolvedValue({
|
|
user: { id: 'user-1' },
|
|
})
|
|
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
|
id: 'material-1',
|
|
userId: 'user-1',
|
|
materialType: '任务-详情',
|
|
previewUrl: '',
|
|
zipFilePath: '',
|
|
})
|
|
;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0)
|
|
;(prisma.material.delete as jest.Mock).mockResolvedValue({ id: 'material-1' })
|
|
;(prisma.productPackageUploadJob.findFirst as jest.Mock).mockResolvedValue(null)
|
|
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({
|
|
$queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
|
|
material: prisma.material,
|
|
materialAsset: prisma.materialAsset,
|
|
productPackageUploadJob: prisma.productPackageUploadJob,
|
|
}))
|
|
;(getOwnedUploadSession as jest.Mock).mockResolvedValue({
|
|
fileName: 'detail.zip',
|
|
fileSize: 1024,
|
|
totalChunks: 1,
|
|
chunks: new Map(),
|
|
createdAt: Date.now(),
|
|
userId: 'user-1',
|
|
})
|
|
;(safelyCleanupMaterialUploadSession as jest.Mock).mockResolvedValue({
|
|
status: 'deleted',
|
|
success: true,
|
|
deletedFiles: 2,
|
|
freedSpace: 1024,
|
|
})
|
|
})
|
|
|
|
function createRequest(body: unknown) {
|
|
return {
|
|
json: jest.fn().mockResolvedValue(body),
|
|
} as any
|
|
}
|
|
|
|
test('非法 uploadId 应在查询会话和执行删除前整体拒绝', async () => {
|
|
const response = await POST(createRequest({
|
|
materialId: 'material-1',
|
|
uploadIds: ['../escape'],
|
|
}))
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(getOwnedUploadSession).not.toHaveBeenCalled()
|
|
expect(safelyCleanupMaterialUploadSession).not.toHaveBeenCalled()
|
|
expect(prisma.material.delete).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('任一会话不存在或不属于当前用户时不得部分清理', async () => {
|
|
;(getOwnedUploadSession as jest.Mock)
|
|
.mockResolvedValueOnce({
|
|
fileName: 'main.zip',
|
|
fileSize: 1024,
|
|
totalChunks: 1,
|
|
chunks: new Map(),
|
|
createdAt: Date.now(),
|
|
userId: 'user-1',
|
|
})
|
|
.mockResolvedValueOnce(null)
|
|
|
|
const response = await POST(createRequest({
|
|
materialId: 'material-1',
|
|
uploadIds: ['upload-1', 'upload-2'],
|
|
}))
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(getOwnedUploadSession).toHaveBeenCalledTimes(2)
|
|
expect(safelyCleanupMaterialUploadSession).not.toHaveBeenCalled()
|
|
expect(prisma.material.delete).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('单次上传会话数量超过合法最大提交上限时不得进入清理', async () => {
|
|
const response = await POST(createRequest({
|
|
materialId: 'material-1',
|
|
uploadIds: Array.from({ length: 1141 }, (_, index) => `upload-${index}`),
|
|
}))
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(getOwnedUploadSession).not.toHaveBeenCalled()
|
|
expect(prisma.material.delete).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('详情长图产生超过 200 个合法预览会话时仍可完整回滚', async () => {
|
|
const uploadIds = Array.from({ length: 244 }, (_, index) => `upload-${index}`)
|
|
let activeSessionChecks = 0
|
|
let maxActiveSessionChecks = 0
|
|
;(getOwnedUploadSession as jest.Mock).mockImplementation(async () => {
|
|
activeSessionChecks += 1
|
|
maxActiveSessionChecks = Math.max(maxActiveSessionChecks, activeSessionChecks)
|
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
|
activeSessionChecks -= 1
|
|
return {
|
|
fileName: 'detail.zip',
|
|
fileSize: 1024,
|
|
totalChunks: 1,
|
|
chunks: new Map(),
|
|
createdAt: Date.now(),
|
|
userId: 'user-1',
|
|
}
|
|
})
|
|
const response = await POST(createRequest({
|
|
materialId: 'material-1',
|
|
uploadIds,
|
|
}))
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body).toEqual(expect.objectContaining({
|
|
success: true,
|
|
deleted: true,
|
|
cleanedUploadIds: 244,
|
|
}))
|
|
expect(getOwnedUploadSession).toHaveBeenCalledTimes(244)
|
|
expect(maxActiveSessionChecks).toBeGreaterThan(1)
|
|
expect(maxActiveSessionChecks).toBeLessThanOrEqual(12)
|
|
expect(safelyCleanupMaterialUploadSession).toHaveBeenCalledTimes(244)
|
|
})
|
|
|
|
test('存在活动后台任务时不得删除素材或清理上传会话', async () => {
|
|
;(prisma.productPackageUploadJob.findFirst as jest.Mock).mockResolvedValueOnce({
|
|
id: 'job-active',
|
|
status: 'processing',
|
|
})
|
|
|
|
const response = await POST(createRequest({
|
|
materialId: 'material-1',
|
|
uploadIds: ['upload-1'],
|
|
}))
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body).toEqual(expect.objectContaining({
|
|
success: true,
|
|
deleted: false,
|
|
reason: 'job_processing',
|
|
jobId: 'job-active',
|
|
}))
|
|
expect(prisma.material.delete).not.toHaveBeenCalled()
|
|
expect(safelyCleanupMaterialUploadSession).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('全部会话预校验通过后才通过统一取消协议删除素材会话', async () => {
|
|
const response = await POST(createRequest({
|
|
materialId: 'material-1',
|
|
uploadIds: ['upload-1', 'upload-2'],
|
|
}))
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body).toEqual({
|
|
success: true,
|
|
deleted: true,
|
|
materialId: 'material-1',
|
|
cleanedUploadIds: 2,
|
|
})
|
|
expect(getOwnedUploadSession).toHaveBeenNthCalledWith(1, 'upload-1', 'user-1')
|
|
expect(getOwnedUploadSession).toHaveBeenNthCalledWith(2, 'upload-2', 'user-1')
|
|
expect(prisma.material.delete).toHaveBeenCalledWith({ where: { id: 'material-1' } })
|
|
expect(safelyCleanupMaterialUploadSession).toHaveBeenNthCalledWith(1, {
|
|
uploadId: 'upload-1',
|
|
intent: 'cancel',
|
|
expectedOwnerId: 'user-1',
|
|
})
|
|
expect(safelyCleanupMaterialUploadSession).toHaveBeenNthCalledWith(2, {
|
|
uploadId: 'upload-2',
|
|
intent: 'cancel',
|
|
expectedOwnerId: 'user-1',
|
|
})
|
|
})
|
|
|
|
test('Redis或锁状态不可用时返回503并保留该会话证据', async () => {
|
|
;(safelyCleanupMaterialUploadSession as jest.Mock)
|
|
.mockResolvedValueOnce({
|
|
status: 'deleted',
|
|
success: true,
|
|
deletedFiles: 2,
|
|
freedSpace: 1024,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
status: 'unavailable',
|
|
success: false,
|
|
deletedFiles: 0,
|
|
freedSpace: 0,
|
|
code: 'UPLOAD_CLEANUP_STATE_UNAVAILABLE',
|
|
httpStatus: 503,
|
|
error: 'Redis不可用',
|
|
})
|
|
|
|
const response = await POST(createRequest({
|
|
materialId: 'material-1',
|
|
uploadIds: ['upload-1', 'upload-2'],
|
|
}))
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(503)
|
|
expect(body).toEqual(expect.objectContaining({
|
|
success: false,
|
|
deleted: true,
|
|
cleanedUploadIds: 1,
|
|
pendingUploadIds: ['upload-2'],
|
|
code: 'UPLOAD_CANCEL_CLEANUP_INCOMPLETE',
|
|
}))
|
|
expect(prisma.material.delete).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|