247 lines
14 KiB
TypeScript
247 lines
14 KiB
TypeScript
/** @jest-environment node */
|
||
jest.mock('@/lib/material-organization-access', () => ({ canAccessOperationsGMaterials: jest.fn().mockResolvedValue(false) }))
|
||
|
||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||
import os from 'os'
|
||
import path from 'path'
|
||
import { NextRequest } from 'next/server'
|
||
import { getServerSession } from 'next-auth'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { isSuperAdmin } from '@/lib/department-permission'
|
||
import { canAccessOperationsGMaterials } from '@/lib/material-organization-access'
|
||
import { canUserAccessPrivateTaskDetail } from '@/lib/user-permission-inherit'
|
||
import { canAccessMaterialPrivacy, isPrivateTaskDetail } from '@/lib/task-detail-privacy'
|
||
import { buildMaterialWhereFromSearchParams } from '@/lib/material-query'
|
||
import { rewriteMediaUrlForClient } from '@/lib/url-rewrite'
|
||
import { GET, HEAD } from '@/app/api/uploads/[...path]/route'
|
||
import { GET as getDetail } from '@/app/api/materials/[id]/route'
|
||
import { GET as getSharedDetail } from '@/app/api/materials/share/route'
|
||
import { GET as getDownload } from '@/app/api/materials/download/route'
|
||
import { GET as getExternalDownload } from '@/app/api/external/download/route'
|
||
|
||
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
|
||
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
|
||
jest.mock('@/lib/path-security', () => ({
|
||
...jest.requireActual('@/lib/path-security'),
|
||
getAllowedFileDirectories: () => [process.env.UPLOAD_DIR],
|
||
}))
|
||
jest.mock('@/lib/prisma', () => ({ prisma: {
|
||
material: { findMany: jest.fn(), findFirst: jest.fn(), findUnique: jest.fn() },
|
||
materialAsset: { findMany: jest.fn() },
|
||
materialSku: { findMany: jest.fn().mockResolvedValue([]) },
|
||
materialLike: { findFirst: jest.fn() },
|
||
materialFavorite: { findFirst: jest.fn() },
|
||
user: { findUnique: jest.fn() },
|
||
} }))
|
||
jest.mock('@/lib/user-permission-inherit', () => ({ canUserAccessPrivateTaskDetail: jest.fn(), canUserEditTaskDetailAssets: jest.fn().mockResolvedValue(false) }))
|
||
jest.mock('@/lib/department-permission', () => ({
|
||
isSuperAdmin: jest.fn().mockResolvedValue(false), isSystemAdmin: jest.fn().mockReturnValue(false),
|
||
hasSystemAdminPermission: jest.fn().mockReturnValue(false), canUserViewMaterial: jest.fn().mockResolvedValue(false),
|
||
canUserDownloadMaterial: jest.fn().mockResolvedValue(false), canUserDownloadSource: jest.fn().mockResolvedValue(false),
|
||
}))
|
||
jest.mock('@/lib/download-token', () => ({ isTokenExpired: jest.fn(() => false), verifyDownloadToken: jest.fn(() => ({ materialId: 'private-one', downloadType: 'all' })) }))
|
||
|
||
const material = {
|
||
id: 'private-one',
|
||
materialType: '任务-详情',
|
||
taskDetailPrivate: true,
|
||
taskDetailRequesterUserId: 'requester',
|
||
userId: 'uploader',
|
||
}
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||
;(canAccessOperationsGMaterials as jest.Mock).mockResolvedValue(false)
|
||
;(canUserAccessPrivateTaskDetail as jest.Mock).mockResolvedValue(false)
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, zipFileSize: BigInt(0), assets: [] })
|
||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue(material)
|
||
;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([])
|
||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: true })
|
||
})
|
||
|
||
test('私密详情/编辑详情/分享/下载都先拒绝无特权的上传者管理员', async () => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'uploader', role: 'ADMIN', isSuperAdmin: true } })
|
||
for (const suffix of ['', '?intent=edit']) {
|
||
const response = await getDetail(new NextRequest(`https://example.com/api/materials/private-one${suffix}`), { params: { id: 'private-one' } })
|
||
expect(response.status).toBe(403)
|
||
}
|
||
expect((await getSharedDetail(new NextRequest('https://example.com/api/materials/share?id=private-one'))).status).toBe(403)
|
||
expect((await getDownload(new NextRequest('https://example.com/api/materials/download?id=private-one'))).status).toBe(403)
|
||
})
|
||
|
||
test.each([
|
||
material,
|
||
{ ...material, taskDetailPrivate: false, accessScope: 'operations_g' },
|
||
])('超级管理员可查看受限素材的详情和分享页', async (restrictedMaterial) => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'super-admin', role: 'ADMIN' } })
|
||
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...restrictedMaterial, zipFileSize: BigInt(0), assets: [] })
|
||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ role: 'ADMIN', isSuperAdmin: true, canDownload: true })
|
||
|
||
const url = 'https://example.com/api/materials'
|
||
const params = { id: restrictedMaterial.id }
|
||
const detail = await getDetail(new NextRequest(`${url}/${params.id}`), { params })
|
||
const shared = await getSharedDetail(new NextRequest(`${url}/share?id=${params.id}`))
|
||
for (const response of [detail, shared]) {
|
||
expect(response.status).toBe(200)
|
||
expect(response.headers.get('cache-control')).toContain('no-store')
|
||
expect(await response.json()).toMatchObject({ id: params.id, canDownloadSource: false })
|
||
}
|
||
expect((await getDetail(new NextRequest(`${url}/${params.id}?intent=edit`), { params })).status).toBe(403)
|
||
expect((await getDownload(new NextRequest(`${url}/download?id=${params.id}`))).status).toBe(403)
|
||
})
|
||
|
||
test('所选需求运营可看详情,即使没有公开任务详情类型查看权限', async () => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'requester', role: 'USER' } })
|
||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: false })
|
||
const response = await getDetail(new NextRequest('https://example.com/api/materials/private-one'), { params: { id: 'private-one' } })
|
||
expect(response.status).toBe(200)
|
||
expect((await response.json()).taskDetailPrivate).toBe(true)
|
||
expect(response.headers.get('cache-control')).toContain('no-store')
|
||
})
|
||
|
||
test('匿名分享及有效外部下载token也不能暴露私密素材', async () => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue(null)
|
||
expect((await getSharedDetail(new NextRequest('https://example.com/api/materials/share?id=private-one'))).status).toBe(401)
|
||
expect((await getExternalDownload(new NextRequest('https://example.com/api/external/download?token=valid-old-token'))).status).toBe(404)
|
||
})
|
||
|
||
test('公开/旧素材维持原链路;需求运营可访问私密且无需权限组', async () => {
|
||
expect(await canAccessMaterialPrivacy({}, null)).toBe(true)
|
||
expect(await canAccessMaterialPrivacy(material, 'requester')).toBe(true)
|
||
expect(canUserAccessPrivateTaskDetail).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test.each([null, 'uploader', 'super-admin', 'other'])('私密不因匿名/上传者/管理员身份隐式放行:%s', async (userId) => {
|
||
expect(await canAccessMaterialPrivacy(material, userId)).toBe(false)
|
||
})
|
||
|
||
test('授权组可访问;撤销后立即拒绝,不缓存决定', async () => {
|
||
;(canUserAccessPrivateTaskDetail as jest.Mock).mockResolvedValueOnce(true).mockResolvedValueOnce(false)
|
||
expect(await canAccessMaterialPrivacy(material, 'department-user')).toBe(true)
|
||
expect(await canAccessMaterialPrivacy(material, 'department-user')).toBe(false)
|
||
})
|
||
|
||
test('损坏或未完整读取需求类型不能让私密标记变公开', () => {
|
||
expect(isPrivateTaskDetail({ taskDetailPrivate: true })).toBe(true)
|
||
})
|
||
|
||
test.each([false, true])('列表保留搜索和类型筛选,并按超管资格应用素材范围:%s', async (isSuperAdmin) => {
|
||
const result = await buildMaterialWhereFromSearchParams(
|
||
new URLSearchParams('materialType=任务-详情&search=SPU'),
|
||
{ id: 'requester' },
|
||
{ context: { canAccessDeleted: false, expandedViewableTypes: ['任务-详情'], isSuperAdmin,
|
||
canAccessPrivateTaskDetails: false, searchOrConditions: [{ productModel: { contains: 'SPU' } }] } },
|
||
)
|
||
expect(result.where.materialType).toEqual({ in: ['任务-详情', '产品全案'] })
|
||
expect(result.where.OR).toEqual([{ productModel: { contains: 'SPU' } }])
|
||
if (isSuperAdmin) {
|
||
expect(result.where.AND).toBeUndefined()
|
||
} else {
|
||
expect(result.where.AND[0].OR[0].taskDetailPrivate).toBe(false)
|
||
expect(result.where.AND[0].OR[1]).toEqual({ materialType: '任务-详情', taskDetailPrivate: true, accessScope: 'general', taskDetailRequesterUserId: 'requester' })
|
||
}
|
||
})
|
||
|
||
test('私密权限组无需按需求运营过滤,公开类型权限仍在', async () => {
|
||
const result = await buildMaterialWhereFromSearchParams(new URLSearchParams(), { id: 'member' }, {
|
||
context: { canAccessDeleted: false, expandedViewableTypes: ['视频'], isSuperAdmin: false, canAccessPrivateTaskDetails: true },
|
||
})
|
||
expect(result.where.AND[0].OR).toEqual([
|
||
{ taskDetailPrivate: false, accessScope: 'general', materialType: { in: ['视频'] } },
|
||
{ materialType: '任务-详情', taskDetailPrivate: true, accessScope: 'general' },
|
||
])
|
||
})
|
||
|
||
describe('私密预览真实文件入口', () => {
|
||
let root: string
|
||
const previousRoot = process.env.UPLOAD_DIR
|
||
const segments = ['materials', 'private-one', 'private-previews', 'detail', 'slice.webp']
|
||
beforeAll(async () => {
|
||
root = await mkdtemp(path.join(os.tmpdir(), 'private-preview-test-'))
|
||
process.env.UPLOAD_DIR = root
|
||
await mkdir(path.join(root, ...segments.slice(0, -1)), { recursive: true })
|
||
await writeFile(path.join(root, ...segments), 'private-image-bytes')
|
||
})
|
||
afterAll(async () => {
|
||
if (previousRoot === undefined) delete process.env.UPLOAD_DIR
|
||
else process.env.UPLOAD_DIR = previousRoot
|
||
await rm(root, { recursive: true, force: true })
|
||
})
|
||
beforeEach(() => {
|
||
;(prisma.material.findMany as jest.Mock).mockResolvedValue([{ ...material, previewUrl: `uploads/${segments.join('/')}` }])
|
||
;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([])
|
||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'requester' } })
|
||
})
|
||
test('对外仅输出当前站点鉴权路径,不输出CDN/相对页面地址', () => {
|
||
expect(rewriteMediaUrlForClient(`uploads/${segments.join('/')}`, undefined, 'image'))
|
||
.toBe(`/api/uploads/${segments.join('/')}`)
|
||
})
|
||
test('需求运营得到实际图片字节,响应不可缓存', async () => {
|
||
const response = await GET(new NextRequest(`https://example.com/api/uploads/${segments.join('/')}`), { params: { path: segments } })
|
||
expect(response.status).toBe(200)
|
||
expect(response.headers.get('cache-control')).toBe('private, no-store')
|
||
expect(await response.text()).toBe('private-image-bytes')
|
||
})
|
||
|
||
test('超级管理员能读取私密预览,源文件仍需私密授权', async () => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'super-admin', role: 'ADMIN' } })
|
||
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
|
||
const response = await GET(new NextRequest(`https://example.com/api/uploads/${segments.join('/')}`), { params: { path: segments } })
|
||
expect(response.status).toBe(200)
|
||
expect(await response.text()).toBe('private-image-bytes')
|
||
|
||
;(prisma.material.findMany as jest.Mock).mockResolvedValue([{ ...material, zipFilePath: `uploads/${segments.join('/')}` }])
|
||
const source = await GET(new NextRequest(`https://example.com/api/uploads/${segments.join('/')}`), { params: { path: segments } })
|
||
expect(source.status).toBe(403)
|
||
})
|
||
|
||
test('未被源文件记录引用的旧原件不能按派生预览放行', async () => {
|
||
const sourceSegments = ['materials', 'private-one', 'protected-display', 'source.zip']
|
||
await mkdir(path.join(root, ...sourceSegments.slice(0, -1)), { recursive: true })
|
||
await writeFile(path.join(root, ...sourceSegments), 'source-bytes')
|
||
;(prisma.material.findMany as jest.Mock).mockResolvedValue([])
|
||
const response = await GET(new NextRequest(`https://example.com/api/uploads/${sourceSegments.join('/')}`), { params: { path: sourceSegments } })
|
||
expect(response.status).toBe(404)
|
||
})
|
||
|
||
test('中心内成员可读取独立生成的专属白底预览', async () => {
|
||
const previewSegments = ['materials', 'private-one', 'protected-display', 'white-background', 'private-one', 'sku-1', 'front.webp']
|
||
await mkdir(path.join(root, ...previewSegments.slice(0, -1)), { recursive: true })
|
||
await writeFile(path.join(root, ...previewSegments), 'white-background-preview')
|
||
;(prisma.material.findMany as jest.Mock).mockResolvedValue([])
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, accessScope: 'operations_g', taskDetailPrivate: false })
|
||
;(canAccessOperationsGMaterials as jest.Mock).mockResolvedValue(true)
|
||
const response = await GET(new NextRequest(`https://example.com/api/uploads/${previewSegments.join('/')}`), { params: { path: previewSegments } })
|
||
expect(response.status).toBe(200)
|
||
expect(response.headers.get('cache-control')).toContain('no-store')
|
||
expect(await response.text()).toBe('white-background-preview')
|
||
})
|
||
test.each(['requester', 'group-member'])('私密准入者可下载实际文件且不缓存:%s', async (userId) => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: userId, role: 'USER' } })
|
||
;(canUserAccessPrivateTaskDetail as jest.Mock).mockResolvedValue(userId === 'group-member')
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...material, zipFilePath: path.join(root, ...segments), zipFileName: 'fixture.zip', productModel: 'fixture',
|
||
})
|
||
const response = await getDownload(new NextRequest('https://example.com/api/materials/download?id=private-one'))
|
||
expect(response.status).toBe(200)
|
||
expect(response.headers.get('cache-control')).toContain('no-store')
|
||
expect(await response.text()).toBe('private-image-bytes')
|
||
})
|
||
test.each([null, { user: { id: 'uploader', role: 'ADMIN' } }])('匿名与未授权上传者不能通过GET/HEAD/Range访问:%j', async (session) => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue(session)
|
||
for (const handler of [GET, HEAD]) {
|
||
const response = await handler(new NextRequest(`https://example.com/api/uploads/${segments.join('/')}`, { headers: { Range: 'bytes=0-3' } }), { params: { path: segments } })
|
||
expect(response.status).toBe(session ? 403 : 401)
|
||
expect(response.headers.get('cache-control')).toContain('no-store')
|
||
}
|
||
})
|
||
})
|
||
|
||
|
||
test('有效外部下载 token 不能绕过运营中心 G 的组织范围', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, accessScope: 'operations_g', taskDetailPrivate: false })
|
||
expect((await getExternalDownload(new NextRequest('https://example.com/api/external/download?token=valid-old-token'))).status).toBe(404)
|
||
})
|