362 lines
12 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(),
},
user: {
findUnique: jest.fn(),
},
department: {
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)
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: '设计部',
managedDepartment: null,
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '设计部-三维组' },
{ name: '运营部' },
])
})
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({ 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)}`)
)
expect(response.status).toBe(200)
expect(prisma.material.findMany.mock.calls[0][0].where.AND).toEqual([
{
OR: [
{ userId: 'user-1' },
{ ownerType: 'USER', ownerId: 'user-1' },
],
},
])
})
it('部门管理员读取素材文件时只放行自己和管理部门范围', async () => {
const fileUrl = 'http://minio:9000/bucket/preview.webp'
getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValueOnce({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: '设计部',
managedDepartment: '设计部',
})
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)}`)
)
expect(response.status).toBe(200)
expect(prisma.material.findMany.mock.calls[0][0].where.AND).toEqual([
{
OR: expect.arrayContaining([
{ userId: 'manager-1' },
{ ownerType: 'USER', ownerId: 'manager-1' },
{ user: { department: { in: expect.arrayContaining(['设计部', '设计部-三维组']) } } },
{ user: { department: { startsWith: '设计部-' } } },
]),
},
])
})
it('禁用用户即使带旧会话也不能读取素材文件', async () => {
const fileUrl = 'http://minio:9000/bucket/preview.webp'
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
isActive: false,
canDownload: true,
department: '设计部',
managedDepartment: null,
})
const response = await GET(
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('账号不可用')
expect(prisma.material.findMany).not.toHaveBeenCalled()
expect(getS3ObjectStream).not.toHaveBeenCalled()
})
it('不允许通过素材代理读取原始素材包,避免绕过下载权限', async () => {
const fileUrl = 'http://minio:9000/bucket/material.zip'
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ zipFilePath: 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(JSON.stringify(prisma.material.findMany.mock.calls[0][0].where.OR)).not.toContain('zipFilePath')
expect(getS3ObjectStream).not.toHaveBeenCalled()
})
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)
})
})