flnxi-Wu/app/api/materials/__tests__/delete-storage-safety.test.ts

389 lines
11 KiB
TypeScript

import { NextRequest } from 'next/server'
import { DELETE } from '../route'
import { POST as cleanupDeletedMaterials } from '../cleanup/route'
import { POST as restoreMaterial } from '../restore/route'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
material: {
findUnique: jest.fn(),
delete: jest.fn(),
update: jest.fn(),
deleteMany: jest.fn(),
count: jest.fn(),
findMany: jest.fn(),
},
uploadField: {
findMany: jest.fn(),
},
fieldDefinition: {
findMany: jest.fn(),
},
$queryRaw: jest.fn(),
},
}))
jest.mock('@/lib/material-log', () => ({
createMaterialLog: jest.fn(),
}))
jest.mock('@/lib/material-storage-cleanup', () => ({
collectMaterialStorageUrls: jest.fn(() => ['s3://bucket/material.zip']),
deleteMaterialStorageFiles: jest.fn(),
}))
jest.mock('@/lib/admin-permissions', () => ({
isCurrentSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/url-rewrite', () => ({
rewriteMaterialStorageUrlsForClient: jest.fn((material) => material),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { createMaterialLog } = require('@/lib/material-log')
const { collectMaterialStorageUrls, deleteMaterialStorageFiles } = require('@/lib/material-storage-cleanup')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
function createMaterial(overrides: Record<string, unknown> = {}) {
return {
id: 'material-1',
userId: 'user-1',
productModel: '测试素材',
categoryName: '分类A',
sceneName: '场景A',
materialType: '3DMAX',
authorName: '作者A',
zipFilePath: 's3://bucket/material.zip',
previewUrl: '',
previewUrls: null,
displayImages: '[]',
videoSourceUrl: null,
videoPreview540Url: null,
videoPreviewClipUrl: null,
videoCoverUrl: null,
user: {
id: 'user-1',
department: '',
},
...overrides,
}
}
describe('DELETE /api/materials 永久删除存储安全', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({
id: 'super-1',
department: '',
managedDepartment: '',
isSuperAdmin: true,
})
prisma.material.findUnique.mockResolvedValue(createMaterial())
isCurrentSuperAdmin.mockResolvedValue(true)
})
it('普通管理员不能永久删除素材', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
department: '设计部',
managedDepartment: '设计部',
isSuperAdmin: false,
})
const response = await DELETE(
new NextRequest('http://localhost/api/materials?id=material-1&permanent=true', {
method: 'DELETE',
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(deleteMaterialStorageFiles).not.toHaveBeenCalled()
expect(prisma.material.delete).not.toHaveBeenCalled()
expect(createMaterialLog).not.toHaveBeenCalled()
})
it('旧会话带最高权限标记但数据库已撤销时不能永久删除素材', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
department: '设计部',
managedDepartment: '设计部',
isSuperAdmin: false,
})
const response = await DELETE(
new NextRequest('http://localhost/api/materials?id=material-1&permanent=true', {
method: 'DELETE',
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(deleteMaterialStorageFiles).not.toHaveBeenCalled()
expect(prisma.material.delete).not.toHaveBeenCalled()
})
it('普通管理员仍可软删除自己上传的素材', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
department: '设计部',
managedDepartment: '设计部',
isSuperAdmin: false,
})
prisma.material.findUnique.mockResolvedValue(createMaterial({
userId: 'admin-1',
user: {
id: 'admin-1',
department: '设计部',
},
}))
prisma.material.update.mockResolvedValue(createMaterial())
const response = await DELETE(
new NextRequest('http://localhost/api/materials?id=material-1', {
method: 'DELETE',
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.message).toBe('删除成功')
expect(deleteMaterialStorageFiles).not.toHaveBeenCalled()
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: { deletedAt: expect.any(Date) },
})
expect(createMaterialLog).toHaveBeenCalled()
})
it('文件删除失败时保留素材数据库记录,不写删除日志', async () => {
deleteMaterialStorageFiles.mockResolvedValue({
deletedFiles: 0,
skippedFiles: 0,
failedFiles: 1,
failures: [{ fileUrl: 's3://bucket/material.zip', reason: 'S3 unavailable' }],
})
const response = await DELETE(
new NextRequest('http://localhost/api/materials?id=material-1&permanent=true', {
method: 'DELETE',
})
)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toContain('素材记录已保留')
expect(prisma.material.delete).not.toHaveBeenCalled()
expect(createMaterialLog).not.toHaveBeenCalled()
})
it('文件全部删除成功后才写审计日志并硬删除数据库记录', async () => {
deleteMaterialStorageFiles.mockResolvedValue({
deletedFiles: 1,
skippedFiles: 0,
failedFiles: 0,
failures: [],
})
prisma.material.delete.mockResolvedValue(createMaterial())
const response = await DELETE(
new NextRequest('http://localhost/api/materials?id=material-1&permanent=true', {
method: 'DELETE',
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.message).toBe('删除成功')
expect(createMaterialLog).toHaveBeenCalledWith(
expect.objectContaining({
materialId: 'material-1',
action: 'DELETE',
materialSnapshot: expect.objectContaining({
productModel: '测试素材',
categoryName: '分类A',
}),
})
)
expect(prisma.material.delete).toHaveBeenCalledWith({
where: { id: 'material-1' },
})
})
})
describe('POST /api/materials/cleanup 过期删除素材清理权限', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.material.findMany.mockResolvedValue([])
prisma.material.deleteMany.mockResolvedValue({ count: 0 })
deleteMaterialStorageFiles.mockResolvedValue({
deletedFiles: 0,
skippedFiles: 0,
failedFiles: 0,
failures: [],
})
})
it('普通管理员不能清理过期删除素材', async () => {
isCurrentSuperAdmin.mockResolvedValue(false)
const response = await cleanupDeletedMaterials(
new NextRequest('http://localhost/api/materials/cleanup', { method: 'POST' })
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(prisma.material.findMany).not.toHaveBeenCalled()
expect(prisma.material.deleteMany).not.toHaveBeenCalled()
})
it('最高权限账号可以清理过期删除素材', async () => {
prisma.material.findMany.mockResolvedValue([
createMaterial({ id: 'material-1', deletedAt: new Date('2026-05-01T00:00:00.000Z') }),
])
const response = await cleanupDeletedMaterials(
new NextRequest('http://localhost/api/materials/cleanup', { method: 'POST' })
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.deletedCount).toBe(1)
expect(deleteMaterialStorageFiles).toHaveBeenCalled()
expect(collectMaterialStorageUrls).toHaveBeenCalled()
expect(prisma.material.deleteMany).toHaveBeenCalledWith({
where: { id: { in: ['material-1'] } },
})
})
})
describe('POST /api/materials/restore 恢复素材权限', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
department: '设计部',
managedDepartment: '设计部',
isSuperAdmin: false,
})
prisma.material.findUnique.mockResolvedValue(createMaterial({
userId: 'user-2',
user: {
id: 'user-2',
department: '财务部',
},
}))
})
it('普通管理员不能恢复管理范围外的素材', async () => {
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '财务部' },
])
const response = await restoreMaterial(
new NextRequest('http://localhost/api/materials/restore', {
method: 'POST',
body: JSON.stringify({ id: 'material-1' }),
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('恢复')
expect(prisma.material.update).not.toHaveBeenCalled()
})
it('普通管理员可以恢复自己上传的素材', async () => {
prisma.material.findUnique.mockResolvedValue(createMaterial({
userId: 'admin-1',
user: {
id: 'admin-1',
department: '设计部',
},
}))
prisma.material.update.mockResolvedValue(createMaterial())
const response = await restoreMaterial(
new NextRequest('http://localhost/api/materials/restore', {
method: 'POST',
body: JSON.stringify({ id: 'material-1' }),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.message).toBe('恢复成功')
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: { deletedAt: null },
})
})
it('最高权限账号可以恢复任意素材', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({
id: 'super-1',
department: '',
managedDepartment: '',
isSuperAdmin: true,
})
prisma.material.update.mockResolvedValue(createMaterial())
const response = await restoreMaterial(
new NextRequest('http://localhost/api/materials/restore', {
method: 'POST',
body: JSON.stringify({ id: 'material-1' }),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.message).toBe('恢复成功')
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: { deletedAt: null },
})
})
})