/** @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 = {}) { 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, }) }) })