107 lines
4.9 KiB
TypeScript
107 lines
4.9 KiB
TypeScript
/** @jest-environment node */
|
|
import { promises as fs } from 'fs'
|
|
import os from 'os'
|
|
import path from 'path'
|
|
import { collectPurgeFiles, identifyPurgeFile, removePurgeFile, findPurgeFileReferences } from '@/lib/material-purge-files'
|
|
|
|
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
|
|
jest.mock('@/lib/s3', () => ({
|
|
getTrustedS3MutationKey: (url: string) => {
|
|
const parsed = new URL(url)
|
|
if (parsed.origin !== 'http://storage.test' || !parsed.pathname.startsWith('/project/')) throw new Error('拒绝外部对象')
|
|
return parsed.pathname.slice(1)
|
|
},
|
|
deleteFromS3: jest.fn(),
|
|
}))
|
|
|
|
let root: string
|
|
let oldUpload: string | undefined
|
|
let oldBucket: string | undefined
|
|
const material = (source: string) => ({ id: '11111111-1111-4111-8111-111111111111', previewUrl: source, zipFilePath: source,
|
|
displayImages: [], videoSourceUrl: null, videoPreview540Url: null, videoPreviewClipUrl: null, videoCoverUrl: null, assets: [], skus: [] })
|
|
|
|
beforeEach(async () => {
|
|
oldUpload = process.env.UPLOAD_DIR
|
|
oldBucket = process.env.S3_BUCKET
|
|
root = await fs.mkdtemp(path.join(os.tmpdir(), 'gongxiangp2-purge-files-'))
|
|
process.env.UPLOAD_DIR = root
|
|
})
|
|
afterEach(async () => {
|
|
await fs.rm(root, { recursive: true, force: true })
|
|
if (oldUpload === undefined) delete process.env.UPLOAD_DIR
|
|
else process.env.UPLOAD_DIR = oldUpload
|
|
if (oldBucket === undefined) delete process.env.S3_BUCKET
|
|
else process.env.S3_BUCKET = oldBucket
|
|
})
|
|
|
|
test('规范化等价的 uploads URL 并去重,重复删除缺失文件可续跑', async () => {
|
|
await fs.writeFile(path.join(root, 'asset.bin'), 'owned')
|
|
const input = material('uploads/asset.bin')
|
|
input.displayImages = ['/uploads/asset.bin'] as never[]
|
|
const files = await collectPurgeFiles(input)
|
|
expect(files).toHaveLength(1)
|
|
await removePurgeFile(files[0])
|
|
await expect(removePurgeFile(files[0])).resolves.toBeUndefined()
|
|
})
|
|
|
|
test.each(['uploads/../outside', '/etc/passwd', 'https://foreign.test/project/file', 'uploads/'])('拒绝不受控删除地址 %s', source => {
|
|
expect(() => identifyPurgeFile(source)).toThrow()
|
|
})
|
|
|
|
test('拒绝符号链接,不跟随链接删除文件', async () => {
|
|
const file = path.join(root, 'owned.bin')
|
|
await fs.writeFile(file, 'keep')
|
|
await fs.symlink(file, path.join(root, 'link.bin'))
|
|
await expect(collectPurgeFiles(material('uploads/link.bin'))).rejects.toThrow('符号链接')
|
|
expect(await fs.readFile(file, 'utf8')).toBe('keep')
|
|
})
|
|
|
|
test('任务入队后同路径文件被替换时拒绝删除', async () => {
|
|
const file = path.join(root, 'owned.bin')
|
|
await fs.writeFile(file, 'first')
|
|
const files = await collectPurgeFiles(material('uploads/owned.bin'))
|
|
await fs.writeFile(file, 'replacement content')
|
|
await expect(removePurgeFile(files[0])).rejects.toThrow('文件已发生变化')
|
|
expect(await fs.readFile(file, 'utf8')).toBe('replacement content')
|
|
})
|
|
|
|
test('没有物理文件的 data 预览不产生删除目标', async () => {
|
|
expect(await collectPurgeFiles(material('data:image/svg+xml;base64,Zm9v'))).toEqual([])
|
|
})
|
|
|
|
test('候选筛选保留特殊文件名、编码 URL 和控制字符 URL 的等价引用', async () => {
|
|
process.env.S3_BUCKET = 'test'
|
|
const targets = [
|
|
{ source: 'uploads/shared[1]+.bin', storage: 'local' as const, key: 'shared[1]+.bin', root, outcome: 'pending' as const },
|
|
{ source: 'http://storage.test/project/shared.bin', storage: 's3' as const, key: 'project/shared.bin', bucket: 'test', outcome: 'pending' as const },
|
|
]
|
|
const sources = [
|
|
'/uploads/shared[1]+.bin',
|
|
'/uploads/other/shared[1]+.bin',
|
|
'http://legacy.test/project/%73hared.bin?cache=1',
|
|
'http://legacy.test/project/sha\tred.bin',
|
|
'http://legacy.test/project/unrelated.bin',
|
|
]
|
|
const rows = sources.map(fileUrl => ({ fileUrl }))
|
|
const empty = { findMany: jest.fn().mockResolvedValue([]) }
|
|
const db: any = { material: empty, materialAsset: { findMany: jest.fn().mockResolvedValue(rows) },
|
|
materialSku: empty, brandKnowledge: empty, systemSettings: empty, performanceSurveyWorkItem: empty,
|
|
workReviewVersion: empty, productPackageUploadJob: empty, taskDetailChangeRequest: empty,
|
|
$queryRaw: jest.fn().mockResolvedValue(rows.map(row => ({ sources: [row.fileUrl], derived: true }))) }
|
|
const full = await findPurgeFileReferences(db)
|
|
const targeted = await findPurgeFileReferences(db, targets)
|
|
expect(targeted.has('local::shared[1]+.bin')).toBe(true)
|
|
expect(targeted.has('s3:test:project/shared.bin')).toBe(true)
|
|
expect(targeted.has('s3:test:project/unrelated.bin')).toBe(false)
|
|
for (const identity of ['local::shared[1]+.bin', 's3:test:project/shared.bin']) {
|
|
expect(targeted.has(identity)).toBe(full.has(identity))
|
|
}
|
|
})
|
|
|
|
test('没有待处理文件时不访问数据库', async () => {
|
|
const db = { material: { findMany: jest.fn() }, $queryRaw: jest.fn() }
|
|
expect(await findPurgeFileReferences(db as any, [])).toEqual(new Set())
|
|
expect(db.material.findMany).not.toHaveBeenCalled()
|
|
expect(db.$queryRaw).not.toHaveBeenCalled()
|
|
})
|