gongxiangp2/lib/__tests__/transcode-route-authorization.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

155 lines
4.8 KiB
TypeScript

/** @jest-environment node */
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { POST } from '@/app/api/materials/transcode/route'
import { prisma } from '@/lib/prisma'
import { hasSystemAdminPermission } from '@/lib/department-permission'
import { transcodeQueue } from '@/lib/video-transcode-queue'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
count: jest.fn(),
findFirst: jest.fn(),
},
},
}))
jest.mock('@/lib/department-permission', () => ({
canUserViewMaterial: jest.fn(),
hasSystemAdminPermission: jest.fn(),
}))
jest.mock('@/lib/video-transcode-task', () => ({
executeTranscodeTask: jest.fn(),
}))
jest.mock('@/lib/video-transcode-queue', () => ({
VIDEO_MATERIAL_TYPES: ['淘系-主图视频'],
transcodeQueue: {
add: jest.fn(),
getStatus: jest.fn(() => ({
waiting: [],
processingIds: ['material-1'],
progress: new Map(),
stages: new Map(),
maxConcurrent: 1,
})),
recoverOnStartup: jest.fn().mockResolvedValue(1),
recoverInterruptedTasks: jest.fn(),
},
}))
function createRequest() {
return new NextRequest('http://localhost/api/materials/transcode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ materialId: 'material-1', forceRetry: true }),
})
}
describe('transcode route authorization', () => {
beforeEach(() => {
jest.clearAllMocks()
;(transcodeQueue.add as jest.Mock).mockResolvedValue(undefined)
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'owner-1', role: 'USER', isSuperAdmin: false },
})
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(false)
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'owner-1',
videoSourceUrl: 'uploads/materials/video.mp4',
videoCoverUrl: null,
videoProcessingStatus: 'failed',
materialType: '淘系-主图视频',
})
})
test('素材所有者可以重试自己的转码任务', async () => {
const response = await POST(createRequest())
expect(response.status).toBe(200)
expect(transcodeQueue.add).toHaveBeenCalledWith(
'material-1',
'uploads/materials/video.mp4',
true,
expect.any(Function),
)
expect(transcodeQueue.recoverOnStartup).not.toHaveBeenCalled()
})
test('普通用户不能启动其他用户素材的转码任务', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'other-owner',
videoSourceUrl: 'uploads/materials/video.mp4',
videoCoverUrl: null,
videoProcessingStatus: 'failed',
materialType: '淘系-主图视频',
})
const response = await POST(createRequest())
const body = await response.json()
expect(response.status).toBe(403)
expect(body.error).toContain('只能启动自己素材')
expect(transcodeQueue.add).not.toHaveBeenCalled()
})
test('系统管理员可以启动任意素材的转码任务,恢复由 instrumentation 周期负责', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'admin-1', role: 'SYSTEM_ADMIN', isSuperAdmin: false },
})
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true)
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'other-owner',
videoSourceUrl: 'uploads/materials/video.mp4',
videoCoverUrl: null,
videoProcessingStatus: 'failed',
materialType: '淘系-主图视频',
})
const response = await POST(createRequest())
expect(response.status).toBe(200)
expect(transcodeQueue.recoverOnStartup).not.toHaveBeenCalled()
expect(transcodeQueue.add).toHaveBeenCalledTimes(1)
})
test('POST 只等待持久化入队,不等待后台转码任务完成', async () => {
const responsePromise = POST(createRequest())
await expect(responsePromise).resolves.toMatchObject({ status: 200 })
expect(transcodeQueue.add).toHaveBeenCalledTimes(1)
})
test('processing 状态即使 forceRetry 也不能绕过后台租约恢复并启动第二个任务', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'owner-1',
videoSourceUrl: 'uploads/materials/video.mp4',
videoCoverUrl: null,
videoProcessingStatus: 'processing',
transcodeLeaseToken: null,
transcodeLeaseExpiresAt: null,
materialType: '淘系-主图视频',
})
const response = await POST(createRequest())
expect(response.status).toBe(409)
expect(transcodeQueue.add).not.toHaveBeenCalled()
})
})