/** @jest-environment node */ import { getServerSession } from 'next-auth' import { prisma } from '@/lib/prisma' import { addAsset, getAssetsByMaterialId } from '@/lib/product-package' import { deleteFileIfUnreferenced } from '@/lib/file-cleanup' import { DELETE, GET, POST, } from '@/app/api/materials/[id]/assets/route' jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) jest.mock('@/lib/auth', () => ({ authOptions: {} })) jest.mock('@/lib/department-permission', () => ({ canUserViewMaterial: jest.fn().mockResolvedValue(false), isSuperAdmin: jest.fn().mockResolvedValue(false), isSystemAdmin: jest.fn(() => false), })) jest.mock('@/lib/product-package', () => ({ getAssetsByMaterialId: jest.fn(), addAsset: jest.fn(), })) jest.mock('@/lib/file-cleanup', () => ({ deleteFileIfUnreferenced: jest.fn() })) jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn() })) jest.mock('@/lib/material-cleanup', () => ({ normalizeDisplayImages: jest.fn(() => []) })) jest.mock('@/lib/prisma', () => ({ prisma: { material: { findUnique: jest.fn(), update: jest.fn(), }, materialAsset: { findUnique: jest.fn(), findFirst: jest.fn(), }, $transaction: jest.fn(), }, })) describe('material assets route authorization', () => { beforeEach(() => { jest.clearAllMocks() ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-2', role: 'USER', department: '设计部' }, }) ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', materialType: '任务-详情', previewUrl: '', zipFilePath: '', zipFileName: '', zipFileSize: BigInt(0), displayImages: [], }) ;(deleteFileIfUnreferenced as jest.Mock).mockResolvedValue({ success: true, skipped: true, reason: 'physical_delete_disabled', }) }) test('无查看权限的用户不得读取资产列表', async () => { const response = await GET({} as any, { params: { id: 'material-1' } }) expect(response.status).toBe(403) expect(getAssetsByMaterialId).not.toHaveBeenCalled() }) test('非所有者不得添加资产', async () => { const response = await POST({ json: jest.fn().mockResolvedValue({ assetType: 'preview', fileUrl: 'uploads/a.webp' }), } as any, { params: { id: 'material-1' } }) expect(response.status).toBe(403) expect(addAsset).not.toHaveBeenCalled() }) test('非所有者不得删除资产', async () => { const response = await DELETE({ url: 'http://localhost/api/materials/material-1/assets?assetId=asset-1', } as any, { params: { id: 'material-1' } }) expect(response.status).toBe(403) expect(prisma.materialAsset.findUnique).not.toHaveBeenCalled() }) test('素材所有者也不能通过客户端 fileUrl 直接注册资产', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1', role: 'USER', department: '设计部' }, }) const response = await POST({ json: jest.fn().mockResolvedValue({ assetType: 'preview', fileUrl: 'D:\\app\\sentinel.txt', }), } as any, { params: { id: 'material-1' } }) const body = await response.json() expect(response.status).toBe(403) expect(body.error).toContain('受控上传流程') expect(addAsset).not.toHaveBeenCalled() expect(prisma.material.findUnique).not.toHaveBeenCalled() }) test('删除历史资产记录时不得根据不可信 fileUrl 执行物理删除', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1', role: 'USER', department: '设计部' }, }) // 该接口只服务非打包类素材;任务-详情走单图编辑接口 ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', materialType: '视频', previewUrl: '', zipFilePath: '', zipFileName: '', zipFileSize: BigInt(0), displayImages: [], }) ;(prisma.materialAsset.findUnique as jest.Mock).mockResolvedValue({ id: 'asset-1', materialId: 'material-1', assetType: 'preview', fileUrl: 'D:\\app\\sentinel.txt', fileName: 'sentinel.txt', }) ;(prisma.materialAsset.findFirst as jest.Mock).mockResolvedValue(null) ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) => callback({ materialAsset: { delete: jest.fn().mockResolvedValue({ id: 'asset-1' }), }, material: { update: jest.fn().mockResolvedValue({ id: 'material-1' }), }, }), ) const response = await DELETE({ url: 'http://localhost/api/materials/material-1/assets?assetId=asset-1', } as any, { params: { id: 'material-1' } }) const body = await response.json() expect(response.status).toBe(200) expect(deleteFileIfUnreferenced).toHaveBeenCalledWith( 'D:\\app\\sentinel.txt', { excludeAssetIds: ['asset-1'], allowPhysicalDelete: false, }, ) expect(body.details).toEqual({ fileDeleted: false, fileSkipped: true, fileCleanupError: null, }) }) test('任务-详情素材不得通过该接口直删资产(会导致 ZIP 与预览不一致)', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1', role: 'USER', department: '设计部' }, }) const response = await DELETE({ url: 'http://localhost/api/materials/material-1/assets?assetId=asset-1', } as any, { params: { id: 'material-1' } }) const body = await response.json() expect(response.status).toBe(409) expect(body.error).toContain('单图编辑接口') expect(prisma.materialAsset.findUnique).not.toHaveBeenCalled() }) })