/** @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) } })