172 lines
9.6 KiB
TypeScript
172 lines
9.6 KiB
TypeScript
/** @jest-environment node */
|
||
|
||
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 { 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() },
|
||
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()
|
||
;(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.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('所选需求运营可看详情,即使没有公开任务详情类型查看权限', 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' } }])
|
||
expect(result.where.AND[0].OR[0].taskDetailPrivate).toBe(false)
|
||
expect(result.where.AND[0].OR[1]).toEqual({ materialType: '任务-详情', taskDetailPrivate: true, 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, materialType: { in: ['视频'] } },
|
||
{ materialType: '任务-详情', taskDetailPrivate: true },
|
||
])
|
||
})
|
||
|
||
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.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')
|
||
}
|
||
})
|
||
})
|