gongxiangp2/lib/__tests__/material-upload-safe-cleanup.test.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

197 lines
6.5 KiB
TypeScript

/** @jest-environment node */
import {
mkdir,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from 'fs/promises'
import os from 'os'
import path from 'path'
import {
beginUploadCompletionSeal,
completeUploadCompletionSeal,
readUploadSessionSeal,
} from '@/lib/material-upload-session-seal'
import {
cleanupConsumedUploadSession,
safelyCleanupMaterialUploadSession,
} from '@/lib/temp-cleanup'
import { uploadSessions, type UploadSession } from '@/lib/upload-sessions'
describe('safe material upload cleanup protocol', () => {
let uploadRoot: string
const uploadId = 'safe-cleanup-upload-1'
beforeEach(async () => {
uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'material-safe-cleanup-'))
process.env.UPLOAD_DIR = uploadRoot
process.env.USE_REDIS = 'false'
uploadSessions.clear()
})
afterEach(async () => {
uploadSessions.clear()
delete process.env.UPLOAD_DIR
delete process.env.USE_REDIS
await rm(uploadRoot, { recursive: true, force: true })
})
test('completed + valid merged is never deleted by public cancellation', async () => {
const { tempDir, mergedPath } = await createUploadDirectory(uploadId)
seedMemorySession(uploadId)
const seal = await beginUploadCompletionSeal(uploadId)
expect(seal.state).toBe('sealing')
if (seal.state !== 'sealing') throw new Error('missing seal handle')
await completeUploadCompletionSeal(seal.handle)
const result = await safelyCleanupMaterialUploadSession({
uploadId,
intent: 'cancel',
expectedOwnerId: 'user-1',
})
expect(result).toMatchObject({
success: false,
status: 'protected_completed',
code: 'UPLOAD_SESSION_COMPLETED',
httpStatus: 409,
})
await expect(readFile(mergedPath, 'utf8')).resolves.toBe('data')
await expect(stat(tempDir)).resolves.toMatchObject({})
expect(uploadSessions.has(uploadId)).toBe(true)
})
test('sealing + valid merged is retained by public and scheduled cleanup', async () => {
const { mergedPath } = await createUploadDirectory(uploadId)
seedMemorySession(uploadId)
await beginUploadCompletionSeal(uploadId)
const result = await safelyCleanupMaterialUploadSession({
uploadId,
intent: 'cancel',
expectedOwnerId: 'user-1',
})
expect(result.status).toBe('protected_sealing')
await expect(readFile(mergedPath, 'utf8')).resolves.toBe('data')
expect(uploadSessions.has(uploadId)).toBe(true)
})
test('scheduled cleanup skips sealing even before merged publication', async () => {
const { tempDir } = await createUploadDirectory(uploadId, false)
await beginUploadCompletionSeal(uploadId)
const result = await safelyCleanupMaterialUploadSession({
uploadId,
intent: 'sweep',
minAgeMs: 8 * 60 * 60 * 1000,
})
expect(result.status).toBe('protected_sealing')
await expect(stat(tempDir)).resolves.toMatchObject({})
})
test('state I/O ambiguity retains directory and session with a 503 result', async () => {
const { tempDir, mergedPath } = await createUploadDirectory(uploadId)
seedMemorySession(uploadId)
const sealDir = path.join(uploadRoot, '.locks', 'completed-upload-sessions')
await mkdir(path.join(sealDir, `${uploadId}.json`), { recursive: true })
const result = await safelyCleanupMaterialUploadSession({
uploadId,
intent: 'cancel',
expectedOwnerId: 'user-1',
})
expect(result).toMatchObject({
success: false,
status: 'unavailable',
code: 'UPLOAD_SESSION_SEAL_INVALID',
httpStatus: 503,
})
await expect(readFile(mergedPath, 'utf8')).resolves.toBe('data')
await expect(stat(tempDir)).resolves.toMatchObject({})
expect(uploadSessions.has(uploadId)).toBe(true)
})
test('cancellable session persists cancelled tombstone before deleting state and directory', async () => {
const { tempDir } = await createUploadDirectory(uploadId, false)
seedMemorySession(uploadId)
const result = await safelyCleanupMaterialUploadSession({
uploadId,
intent: 'cancel',
expectedOwnerId: 'user-1',
})
expect(result).toMatchObject({ success: true, status: 'deleted' })
expect(uploadSessions.has(uploadId)).toBe(false)
await expect(stat(tempDir)).rejects.toMatchObject({ code: 'ENOENT' })
await expect(readUploadSessionSeal(uploadId)).resolves.toMatchObject({
state: 'cancelled',
})
await expect(beginUploadCompletionSeal(uploadId)).rejects.toMatchObject({
status: 409,
code: 'UPLOAD_SESSION_CANCELLED',
})
})
test('internal consumed cleanup removes completed assembled + temp data but retains seal', async () => {
const { tempDir } = await createUploadDirectory(uploadId, false)
const assembledPath = path.join(uploadRoot, 'assembled', `${uploadId}-source.bin`)
await mkdir(path.dirname(assembledPath), { recursive: true })
await writeFile(assembledPath, 'data')
seedMemorySession(uploadId)
const seal = await beginUploadCompletionSeal(uploadId)
if (seal.state !== 'sealing') throw new Error('missing seal handle')
await completeUploadCompletionSeal(seal.handle)
const result = await cleanupConsumedUploadSession(uploadId)
expect(result).toMatchObject({ success: true, status: 'deleted' })
await expect(stat(tempDir)).rejects.toMatchObject({ code: 'ENOENT' })
await expect(stat(assembledPath)).rejects.toMatchObject({ code: 'ENOENT' })
await expect(readUploadSessionSeal(uploadId)).resolves.toMatchObject({
state: 'completed',
})
})
async function createUploadDirectory(id: string, withMerged = true) {
const tempDir = path.join(uploadRoot, 'temp', id)
const mergedPath = path.join(tempDir, 'merged-source.bin')
await mkdir(tempDir, { recursive: true })
await writeFile(path.join(tempDir, 'meta.json'), JSON.stringify({
fileName: 'source.bin',
fileSize: 4,
totalChunks: 1,
chunkSize: 4,
userId: 'user-1',
createdAt: Date.now() - 10 * 60 * 60 * 1000,
}))
if (withMerged) {
await writeFile(mergedPath, 'data')
} else {
await writeFile(path.join(tempDir, 'chunk-0'), 'data')
}
return { tempDir, mergedPath }
}
function seedMemorySession(id: string) {
const now = Date.now() - 10 * 60 * 60 * 1000
const session: UploadSession = {
fileName: 'source.bin',
fileSize: 4,
totalChunks: 1,
chunkSize: 4,
chunks: new Map([[0, path.join(uploadRoot, 'temp', id, 'chunk-0')]]),
createdAt: now,
userId: 'user-1',
}
uploadSessions.set(id, session)
}
})