389 lines
11 KiB
TypeScript
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 },
|
|
})
|
|
})
|
|
})
|