362 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|