Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
197 lines
6.5 KiB
TypeScript
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)
|
|
}
|
|
})
|