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

247 lines
14 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 */
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)
})