113 lines
3.4 KiB
TypeScript
113 lines
3.4 KiB
TypeScript
/** @jest-environment node */
|
|
|
|
import {
|
|
access,
|
|
mkdtemp,
|
|
mkdir,
|
|
rm,
|
|
writeFile,
|
|
} from 'fs/promises'
|
|
import os from 'os'
|
|
import path from 'path'
|
|
import {
|
|
deleteFileIfUnreferenced,
|
|
deleteLocalFile,
|
|
} from '@/lib/file-cleanup'
|
|
|
|
jest.mock('@/lib/prisma', () => ({
|
|
prisma: {
|
|
materialAsset: { count: jest.fn() },
|
|
materialSku: { count: jest.fn() },
|
|
material: { count: jest.fn() },
|
|
},
|
|
}))
|
|
|
|
jest.mock('@/lib/s3', () => ({
|
|
deleteFromS3: jest.fn(),
|
|
isS3Url: jest.fn(() => false),
|
|
}))
|
|
|
|
describe('file cleanup security boundaries', () => {
|
|
let sandboxRoot: string
|
|
let uploadRoot: string
|
|
let previousUploadDir: string | undefined
|
|
|
|
beforeEach(async () => {
|
|
previousUploadDir = process.env.UPLOAD_DIR
|
|
sandboxRoot = await mkdtemp(path.join(os.tmpdir(), 'gongxiangp2-cleanup-'))
|
|
uploadRoot = path.join(sandboxRoot, 'uploads')
|
|
await mkdir(uploadRoot, { recursive: true })
|
|
process.env.UPLOAD_DIR = uploadRoot
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if (previousUploadDir === undefined) {
|
|
delete process.env.UPLOAD_DIR
|
|
} else {
|
|
process.env.UPLOAD_DIR = previousUploadDir
|
|
}
|
|
await rm(sandboxRoot, { recursive: true, force: true })
|
|
})
|
|
|
|
test('拒绝删除受控上传目录之外的文件', async () => {
|
|
const sentinelPath = path.join(sandboxRoot, 'sentinel.txt')
|
|
await writeFile(sentinelPath, 'keep')
|
|
|
|
const result = await deleteLocalFile(sentinelPath)
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
error: '拒绝删除受控上传目录之外的路径',
|
|
})
|
|
await expect(access(sentinelPath)).resolves.toBeUndefined()
|
|
})
|
|
|
|
test('允许删除受控上传目录内的普通文件', async () => {
|
|
const filePath = path.join(uploadRoot, 'material-1', 'preview.webp')
|
|
await mkdir(path.dirname(filePath), { recursive: true })
|
|
await writeFile(filePath, 'preview')
|
|
|
|
const result = await deleteLocalFile(filePath)
|
|
|
|
expect(result).toEqual({ success: true, isDirectory: false })
|
|
await expect(access(filePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
|
|
test('目录默认不能递归删除,受信任调用方必须明确允许', async () => {
|
|
const directoryPath = path.join(uploadRoot, 'material-1')
|
|
const sentinelPath = path.join(directoryPath, 'sentinel.txt')
|
|
await mkdir(directoryPath, { recursive: true })
|
|
await writeFile(sentinelPath, 'keep')
|
|
|
|
const deniedResult = await deleteLocalFile(directoryPath)
|
|
expect(deniedResult).toEqual({
|
|
success: false,
|
|
isDirectory: true,
|
|
error: '目录递归删除必须由受信任调用方明确允许',
|
|
})
|
|
await expect(access(sentinelPath)).resolves.toBeUndefined()
|
|
|
|
const allowedResult = await deleteLocalFile(directoryPath, {
|
|
allowDirectoryDelete: true,
|
|
})
|
|
expect(allowedResult).toEqual({ success: true, isDirectory: true })
|
|
await expect(access(directoryPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
|
|
test('显式禁用物理删除时不触碰历史不可信 fileUrl', async () => {
|
|
const sentinelPath = path.join(sandboxRoot, 'legacy-target.txt')
|
|
await writeFile(sentinelPath, 'keep')
|
|
|
|
const result = await deleteFileIfUnreferenced(sentinelPath, {
|
|
allowPhysicalDelete: false,
|
|
})
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
skipped: true,
|
|
reason: 'physical_delete_disabled',
|
|
})
|
|
await expect(access(sentinelPath)).resolves.toBeUndefined()
|
|
})
|
|
})
|