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