gongxiangp2/lib/__tests__/materials-share-security.test.ts
2026-08-13 09:21:02 +08:00

225 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @jest-environment node */
import fs from 'fs'
import path from 'path'
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { GET } from '@/app/api/materials/share/route'
import { DownloadErrorCode } from '@/lib/download-errors'
import { prisma } from '@/lib/prisma'
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
findFirst: jest.fn(),
},
materialLike: {
findFirst: jest.fn(),
},
materialFavorite: {
findFirst: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
function buildMaterial(overrides: Record<string, unknown> = {}) {
return {
id: 'material-1',
previewUrl: '',
productModel: 'SPU-001',
reportTitle: null,
categoryName: '索菲亚',
sceneName: '客厅',
platformName: null,
productCategoryName: null,
materialType: '图片',
isAiGenerated: false,
taskDetailSourceType: null,
authorName: '测试人员',
zipFileName: '',
zipFilePath: '',
zipFileSize: BigInt(0),
downloadCount: 0,
likeCount: 0,
displayImages: null,
createdAt: new Date('2026-07-30T00:00:00.000Z'),
userId: 'owner-1',
videoSourceUrl: null,
videoPreview540Url: null,
videoPreviewClipUrl: null,
videoCoverUrl: null,
videoDuration: null,
videoProcessingStatus: null,
taskNo: null,
taskStatus: null,
taskFetchedAt: null,
isFullSet: false,
...overrides,
}
}
describe('materials share security', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue(null)
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(null)
;(prisma.materialLike.findFirst as jest.Mock).mockResolvedValue(null)
;(prisma.materialFavorite.findFirst as jest.Mock).mockResolvedValue(null)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
})
test('公开分享只查询未删除素材', async () => {
const response = await GET(
new NextRequest('http://localhost/api/materials/share?id=material-deleted'),
)
const body = await response.json()
expect(response.status).toBe(404)
expect(body.error).toBe('素材不存在')
expect(prisma.material.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'material-deleted',
deletedAt: null,
},
}),
)
})
test('鉴权下载响应禁止共享缓存', () => {
const routeSource = fs.readFileSync(
path.join(process.cwd(), 'app/api/materials/download/route.ts'),
'utf8',
)
expect(routeSource).toContain("'Cache-Control': 'private, no-store, max-age=0'")
expect(routeSource).not.toContain("'Cache-Control': 'public, max-age=86400, must-revalidate'")
})
test('匿名访问只能使用公开分享能力', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial())
const response = await GET(
new NextRequest('http://localhost/api/materials/share?id=material-1'),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.capabilities).toEqual({
canFavorite: false,
canEdit: false,
canDelete: false,
})
})
test('未满15天的视频分享链接保留有效,但不返回任何视频内容', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({
materialType: '淘系-逛逛视频',
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
videoSourceUrl: '/uploads/materials/source.mp4',
videoPreview540Url: 'https://example.com/preview.mp4',
videoPreviewClipUrl: 'https://example.com/clip.mp4',
}))
const response = await GET(
new NextRequest('http://localhost/api/materials/share?id=material-video'),
)
const body = await response.json()
expect(response.status).toBe(403)
expect(response.headers.get('Cache-Control')).toBe('private, no-store, max-age=0')
expect(body).toMatchObject({
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
remainingDays: expect.any(Number),
availableAt: expect.any(String),
})
expect(body.details).toContain('满 15 天')
expect(body).not.toHaveProperty('videoSourceUrl')
expect(body).not.toHaveProperty('videoPreview540Url')
expect(body).not.toHaveProperty('videoPreviewClipUrl')
})
test('视频满15天后同一分享链接正常返回预览', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({
materialType: '淘系-逛逛视频',
createdAt: new Date(Date.now() - 16 * 24 * 60 * 60 * 1000),
videoSourceUrl: '/uploads/materials/source.mp4',
videoPreview540Url: 'https://example.com/preview.mp4',
videoPreviewClipUrl: 'https://example.com/clip.mp4',
}))
const response = await GET(
new NextRequest('http://localhost/api/materials/share?id=material-video'),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.videoPreview540Url).toBeTruthy()
expect(body.videoPreviewClipUrl).toBeTruthy()
expect(body.videoSourceUrl).toBeNull()
})
test('素材所有者可收藏和编辑,但不能获得管理员删除权限', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'owner-1' },
})
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial())
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
role: 'USER',
department: null,
managedDepartment: null,
canDownload: false,
isSuperAdmin: false,
})
const response = await GET(
new NextRequest('http://localhost/api/materials/share?id=material-1'),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.capabilities).toEqual({
canFavorite: true,
canEdit: true,
canDelete: false,
})
})
test('公开详情由服务端返回与写接口一致的操作能力', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'admin-1' },
})
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial())
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
role: 'SYSTEM_ADMIN',
department: null,
managedDepartment: null,
canDownload: false,
isSuperAdmin: false,
})
const response = await GET(
new NextRequest('http://localhost/api/materials/share?id=material-1'),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.capabilities).toEqual({
canFavorite: true,
canEdit: true,
canDelete: true,
})
})
})