Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
98 lines
2.9 KiB
TypeScript
98 lines
2.9 KiB
TypeScript
/** @jest-environment node */
|
|
|
|
import { mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'fs/promises'
|
|
import os from 'os'
|
|
import path from 'path'
|
|
|
|
const mockRedisGet = jest.fn()
|
|
const mockRedisDel = jest.fn()
|
|
|
|
jest.mock('@/lib/upload-sessions-redis', () => ({
|
|
redis: {
|
|
get: mockRedisGet,
|
|
del: mockRedisDel,
|
|
},
|
|
}))
|
|
|
|
import {
|
|
runCleanup,
|
|
safelyCleanupMaterialUploadSession,
|
|
} from '@/lib/temp-cleanup'
|
|
import { readUploadSessionSeal } from '@/lib/material-upload-session-seal'
|
|
|
|
describe('scheduled material upload cleanup Redis fail-closed', () => {
|
|
let uploadRoot: string
|
|
|
|
beforeEach(async () => {
|
|
jest.clearAllMocks()
|
|
uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'material-cleanup-redis-'))
|
|
process.env.UPLOAD_DIR = uploadRoot
|
|
process.env.USE_REDIS = 'true'
|
|
mockRedisGet.mockRejectedValue(new Error('redis unavailable'))
|
|
|
|
const uploadId = 'expired-redis-unknown'
|
|
const tempDir = path.join(uploadRoot, 'temp', uploadId)
|
|
const oldDate = new Date(Date.now() - 10 * 60 * 60 * 1000)
|
|
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: oldDate.getTime(),
|
|
}))
|
|
await writeFile(path.join(tempDir, 'chunk-0'), 'data')
|
|
await utimes(tempDir, oldDate, oldDate)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
delete process.env.UPLOAD_DIR
|
|
delete process.env.USE_REDIS
|
|
await rm(uploadRoot, { recursive: true, force: true })
|
|
})
|
|
|
|
test('Redis read failure skips the expired directory without deleting state', async () => {
|
|
const result = await runCleanup()
|
|
const tempDir = path.join(uploadRoot, 'temp', 'expired-redis-unknown')
|
|
|
|
expect(result).toMatchObject({
|
|
cleaned: 0,
|
|
skippedUnavailable: 1,
|
|
})
|
|
await expect(stat(tempDir)).resolves.toMatchObject({})
|
|
expect(mockRedisDel).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('Redis delete failure leaves directory behind after durable cancellation tombstone', async () => {
|
|
mockRedisGet.mockResolvedValue(JSON.stringify({
|
|
fileName: 'source.bin',
|
|
fileSize: 4,
|
|
totalChunks: 1,
|
|
chunkSize: 4,
|
|
chunks: [],
|
|
createdAt: Date.now() - 10 * 60 * 60 * 1000,
|
|
userId: 'user-1',
|
|
}))
|
|
mockRedisDel.mockRejectedValue(new Error('redis delete unavailable'))
|
|
|
|
const result = await safelyCleanupMaterialUploadSession({
|
|
uploadId: 'expired-redis-unknown',
|
|
intent: 'cancel',
|
|
expectedOwnerId: 'user-1',
|
|
})
|
|
const tempDir = path.join(uploadRoot, 'temp', 'expired-redis-unknown')
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
status: 'unavailable',
|
|
code: 'UPLOAD_CLEANUP_REDIS_UNAVAILABLE',
|
|
httpStatus: 503,
|
|
})
|
|
await expect(stat(tempDir)).resolves.toMatchObject({})
|
|
await expect(readUploadSessionSeal('expired-redis-unknown')).resolves.toMatchObject({
|
|
state: 'cancelled',
|
|
})
|
|
})
|
|
})
|