gongxiangp2/lib/__tests__/materials-share-security.test.ts

290 lines
11 KiB
TypeScript
Raw Permalink 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: {
materialAsset: { findMany: jest.fn() },
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()
;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([])
;(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.each([null, new Date('2029-05-14T00:00:00.000Z')])('报告详情为未填到期日的记录计算一年,保留手动日期 (%s)', async (expiresOn) => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({
materialType: '证书-检测报告',
certificateTestedOn: new Date('2026-05-14T00:00:00.000Z'),
certificateExpiresOn: expiresOn,
}))
const response = await GET(new NextRequest('https://sc.jiansun.vip/api/materials/share?id=material-1'))
expect(response.status).toBe(200)
const body = await response.json()
expect(body.certificateExpiresOn).toBe(expiresOn?.toISOString() || '2027-05-14T00:00:00.000Z')
})
test.each([null, { user: { id: 'admin-1' } }])('公开检测报告返回 PDF 和图片,保留源文件隔离 (%j)', async (session) => {
;(getServerSession as jest.Mock).mockResolvedValue(session)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ role: 'SYSTEM_ADMIN' })
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({
materialType: '证书-检测报告',
certificateTestedOn: new Date('2026-09-23'),
certificateExpiresOn: new Date('2027-09-23'),
reportUsageRules: '唯一的完整规则说明',
}))
const pdfUrl = 'https://s3.jiansun.vip/gongxiangpan/public-display/certificate-report/material-1/pdf/0-report.pdf'
const imageUrl = 'https://s3.jiansun.vip/gongxiangpan/public-display/certificate-report/material-1/image/1-report.jpg'
;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([
{ id: 'pdf', assetType: 'report-pdf', fileUrl: pdfUrl, fileName: 'report.pdf', fileSize: BigInt(10), sortOrder: 0 },
{ id: 'image', assetType: 'report-image', fileUrl: imageUrl, fileName: 'report.jpg', fileSize: BigInt(10), sortOrder: 1 },
{ id: 'source', assetType: 'source', fileUrl: 'uploads/materials/private.zip', fileName: 'private.zip', fileSize: BigInt(10), sortOrder: 2 },
{ id: 'private-pdf', assetType: 'report-pdf', fileUrl: 'https://s3.jiansun.vip/gongxiangpan/private/report.pdf', fileName: 'private.pdf', fileSize: BigInt(10), sortOrder: 3 },
])
const response = await GET(new NextRequest('https://sc.jiansun.vip/api/materials/share?id=material-1'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.assets[0]).toMatchObject({ fileUrl: pdfUrl, fileKind: 'pdf' })
expect(body.assets[1]).toMatchObject({ fileUrl: imageUrl, fileKind: 'image' })
expect(body.assets[2].fileUrl).toBe('')
expect(body.assets[3].fileUrl).toBe('')
expect(body.certificateTestedOn).toBe('2026-09-23T00:00:00.000Z')
expect(body.certificateExpiresOn).toBe('2027-09-23T00:00:00.000Z')
expect(body.reportUsageRules).toBe('唯一的完整规则说明')
expect(body).not.toHaveProperty('certificateUsageInstructions')
expect((prisma.material.findFirst as jest.Mock).mock.calls[0][0].select).not.toHaveProperty('certificateUsageInstructions')
})
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 () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({
materialType: '直播切片',
createdAt: new Date(),
videoSourceUrl: '/uploads/materials/live-source.mp4',
videoPreview540Url: 'https://example.com/live-preview.mp4',
videoPreviewClipUrl: 'https://example.com/live-clip.mp4',
}))
const response = await GET(
new NextRequest('http://localhost/api/materials/share?id=material-live'),
)
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,
})
})
})