gongxiangp2/lib/__tests__/task-detail-privacy.test.ts

172 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @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')
}
})
})