290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
/** @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,
|
||
})
|
||
})
|
||
})
|