gongxiangp2/lib/__tests__/file-cleanup-security.test.ts

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()
})
})