gongxiangp2/lib/__tests__/transcode-route-authorization.test.ts

192 lines
6.9 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'
import { canAccessOperationsGMaterials } from '@/lib/material-organization-access'
jest.mock('@/lib/material-organization-access', () => ({ canAccessOperationsGMaterials: jest.fn() }))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
count: jest.fn(),
findFirst: jest.fn(),
findMany: 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()
;(canAccessOperationsGMaterials as jest.Mock).mockResolvedValue(true)
;(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()
})
test('调出运营中心 G 的上传者不能重试专属视频,管理员也不能越过组织范围', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'moved-owner', role: 'ADMIN' } })
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true)
;(canAccessOperationsGMaterials as jest.Mock).mockResolvedValue(false)
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1', userId: 'moved-owner', accessScope: 'operations_g',
materialType: '淘系-主图视频', videoSourceUrl: 'uploads/video.mp4', videoProcessingStatus: 'failed',
})
expect((await POST(createRequest())).status).toBe(403)
expect(transcodeQueue.add).not.toHaveBeenCalled()
})
test('中心外管理员的转码队列只返回通用素材', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'outside-admin', role: 'ADMIN' } })
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true)
;(canAccessOperationsGMaterials as jest.Mock).mockResolvedValue(false)
;(prisma.material.findMany as jest.Mock).mockResolvedValue([{ id: 'material-1' }])
;(transcodeQueue.getStatus as jest.Mock).mockReturnValueOnce({
waiting: ['g-video'], processingIds: ['material-1'], maxConcurrent: 1,
progress: new Map([['g-video', 20]]), stages: new Map([['g-video', 'processing']]),
})
const response = await POST(createRequest())
expect(response.status).toBe(200)
const body = await response.json()
expect(body.queueStatus.waiting).toEqual([])
expect(body.queueStatus.processingIds).toEqual(['material-1'])
expect(body.queueStatus.queueLength).toBe(0)
expect(prisma.material.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { id: { in: ['g-video', 'material-1'] }, accessScope: 'general' },
}))
})
})