gongxiangp2/lib/__tests__/product-package-abort-route-security.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

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)
})
})