239 lines
8.1 KiB
TypeScript

import { NextRequest } from 'next/server'
import { Readable } from 'stream'
import { GET } from '../route'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
findMany: jest.fn(),
},
},
}))
jest.mock('@/lib/s3', () => ({
getS3ObjectStream: jest.fn(),
isS3Url: jest.fn((value?: string | null) => /^https?:\/\//.test(value || '')),
}))
jest.mock('@/lib/material-share-token', () => ({
findValidMaterialShareToken: jest.fn(),
}))
jest.mock('@/lib/public-share-settings', () => ({
isPublicShareEnabled: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { getS3ObjectStream, isS3Url } = require('@/lib/s3')
const { findValidMaterialShareToken } = require('@/lib/material-share-token')
const { isPublicShareEnabled } = require('@/lib/public-share-settings')
const buildMaterialRecord = (overrides: Record<string, unknown> = {}) => ({
id: 'material-1',
previewUrl: 'http://minio:9000/bucket/preview.webp',
zipFilePath: 'http://minio:9000/bucket/material.zip',
videoSourceUrl: null,
videoCoverUrl: null,
videoPreview540Url: null,
videoPreviewClipUrl: null,
audioUrl: null,
displayImages: '[]',
previewUrls: null,
metadata: null,
...overrides,
})
describe('GET /api/materials/asset', () => {
beforeEach(() => {
jest.resetAllMocks()
isS3Url.mockImplementation((value?: string | null) => /^https?:\/\//.test(value || ''))
isPublicShareEnabled.mockResolvedValue(true)
})
it('未登录时拒绝读取素材文件', async () => {
getServerSession.mockResolvedValue(null)
const response = await GET(
new NextRequest('http://localhost/api/materials/asset?url=http%3A%2F%2Fminio%3A9000%2Fbucket%2Fpreview.webp')
)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录或使用有效分享链接')
expect(prisma.material.findMany).not.toHaveBeenCalled()
expect(getS3ObjectStream).not.toHaveBeenCalled()
})
it('文件地址不属于素材记录时拒绝读取', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.material.findMany.mockResolvedValue([])
const response = await GET(
new NextRequest('http://localhost/api/materials/asset?url=http%3A%2F%2Fminio%3A9000%2Fbucket%2Funknown.webp')
)
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('文件不属于当前素材库记录')
expect(getS3ObjectStream).not.toHaveBeenCalled()
})
it('已登录且文件属于素材记录时代理返回 S3 文件流', async () => {
const fileUrl = 'http://minio:9000/bucket/preview.webp'
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ previewUrl: fileUrl })])
getS3ObjectStream.mockResolvedValue({
body: Readable.from([Buffer.from('asset-body')]),
contentLength: 10,
contentType: 'image/webp',
acceptRanges: 'bytes',
})
const response = await GET(
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
)
const body = await response.text()
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('image/webp')
expect(body).toBe('asset-body')
expect(getS3ObjectStream).toHaveBeenCalledWith(fileUrl, null)
})
it('未登录但分享票据有效时允许读取素材文件', async () => {
const fileUrl = 'http://minio:9000/bucket/preview.webp'
getServerSession.mockResolvedValue(null)
prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ previewUrl: fileUrl })])
findValidMaterialShareToken.mockResolvedValue({ id: 'token-row', materialId: 'material-1' })
getS3ObjectStream.mockResolvedValue({
body: Readable.from([Buffer.from('asset-body')]),
contentLength: 10,
contentType: 'image/webp',
acceptRanges: 'bytes',
})
const response = await GET(
new NextRequest(
`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}&shareToken=share-token`
)
)
const body = await response.text()
expect(response.status).toBe(200)
expect(body).toBe('asset-body')
expect(findValidMaterialShareToken).toHaveBeenCalledWith('share-token', 'material-1')
})
it('公开分享开关关闭时拒绝通过分享票据读取素材文件', async () => {
const fileUrl = 'http://minio:9000/bucket/preview.webp'
getServerSession.mockResolvedValue(null)
prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ previewUrl: fileUrl })])
isPublicShareEnabled.mockResolvedValue(false)
const response = await GET(
new NextRequest(
`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}&shareToken=share-token`
)
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('公开分享功能已关闭')
expect(findValidMaterialShareToken).not.toHaveBeenCalled()
expect(getS3ObjectStream).not.toHaveBeenCalled()
})
it('允许读取 metadata.previewUrls 中登记过的预览图', async () => {
const fileUrl = 'http://minio:9000/bucket/metadata-preview.webp'
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.material.findMany.mockResolvedValue([
buildMaterialRecord({
metadata: JSON.stringify({
previewUrls: [fileUrl],
templateFields: {
externalRef: 'http://minio:9000/bucket/not-this.webp',
},
}),
})
])
getS3ObjectStream.mockResolvedValue({
body: Readable.from([Buffer.from('metadata-preview')]),
contentLength: 16,
contentType: 'image/webp',
acceptRanges: 'bytes',
})
const response = await GET(
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
)
await expect(response.text()).resolves.toBe('metadata-preview')
expect(response.status).toBe(200)
expect(getS3ObjectStream).toHaveBeenCalledWith(fileUrl, null)
})
it('不会把 metadata 普通自定义字段里的 URL 当成可代理文件', async () => {
const fileUrl = 'http://minio:9000/bucket/custom-field.webp'
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.material.findMany.mockResolvedValue([
buildMaterialRecord({
metadata: JSON.stringify({
templateFields: {
externalRef: fileUrl,
},
}),
})
])
const response = await GET(
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
)
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('文件不属于当前素材库记录')
expect(getS3ObjectStream).not.toHaveBeenCalled()
})
it('第一条候选只是 metadata 普通字段命中时,会继续寻找真实文件归属素材', async () => {
const fileUrl = 'http://minio:9000/bucket/shared.webp'
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.material.findMany.mockResolvedValue([
buildMaterialRecord({
id: 'material-false-positive',
previewUrl: 'http://minio:9000/bucket/other.webp',
metadata: JSON.stringify({
templateFields: { externalRef: fileUrl },
}),
}),
buildMaterialRecord({
id: 'material-real-owner',
previewUrl: fileUrl,
}),
])
getS3ObjectStream.mockResolvedValue({
body: Readable.from([Buffer.from('real-owner')]),
contentLength: 10,
contentType: 'image/webp',
acceptRanges: 'bytes',
})
const response = await GET(
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
)
await expect(response.text()).resolves.toBe('real-owner')
expect(response.status).toBe(200)
expect(getS3ObjectStream).toHaveBeenCalledWith(fileUrl, null)
})
})