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

117 lines
3.7 KiB
TypeScript

/** @jest-environment node */
import { mkdir, mkdtemp, rm } from 'fs/promises'
import os from 'os'
import path from 'path'
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { GET, POST } from './route'
import { hasSystemAdminPermission } from '@/lib/department-permission'
import { safelyCleanupMaterialUploadSession } from '@/lib/temp-cleanup'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({
hasSystemAdminPermission: jest.fn(),
}))
jest.mock('@/lib/temp-cleanup', () => ({
safelyCleanupMaterialUploadSession: jest.fn(),
}))
jest.mock('@/lib/upload-sessions', () => ({
isSafeUploadId: (value: string) => /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value),
}))
describe('material upload cleanup route helper coverage', () => {
let uploadRoot: string
beforeEach(async () => {
jest.clearAllMocks()
uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'cleanup-route-'))
process.env.UPLOAD_DIR = uploadRoot
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-1', role: 'SYSTEM_ADMIN', isSuperAdmin: true },
})
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true)
;(safelyCleanupMaterialUploadSession as jest.Mock).mockResolvedValue({
status: 'deleted',
success: true,
deletedFiles: 2,
freedSpace: 4,
})
})
afterEach(async () => {
delete process.env.UPLOAD_DIR
await rm(uploadRoot, { recursive: true, force: true })
})
test('single-id GET delegates cancellation to the exclusive-lock helper', async () => {
const response = await GET(new NextRequest(
'http://localhost/api/materials/upload/cleanup?uploadId=single-upload',
))
expect(response.status).toBe(200)
expect(safelyCleanupMaterialUploadSession).toHaveBeenCalledWith({
uploadId: 'single-upload',
intent: 'cancel',
})
})
test('batch GET delegates every safe candidate to sweep helper', async () => {
await mkdir(path.join(uploadRoot, 'temp', 'batch-upload'), { recursive: true })
const response = await GET(new NextRequest(
'http://localhost/api/materials/upload/cleanup?maxAge=12&expiredOnly=false',
))
expect(response.status).toBe(200)
expect(safelyCleanupMaterialUploadSession).toHaveBeenCalledWith({
uploadId: 'batch-upload',
intent: 'sweep',
minAgeMs: 12 * 60 * 60 * 1000,
allowYoungOrphaned: true,
})
})
test('POST delegates owner recheck to the exclusive-lock helper', async () => {
const request = new NextRequest('http://localhost/api/materials/upload/cleanup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ uploadId: 'owned-upload' }),
})
const response = await POST(request)
expect(response.status).toBe(200)
expect(safelyCleanupMaterialUploadSession).toHaveBeenCalledWith({
uploadId: 'owned-upload',
intent: 'cancel',
expectedOwnerId: 'user-1',
})
})
test('helper uncertainty is surfaced as 503 without route-side deletion', async () => {
;(safelyCleanupMaterialUploadSession as jest.Mock).mockResolvedValue({
status: 'unavailable',
success: false,
deletedFiles: 0,
freedSpace: 0,
error: 'Redis 不可用,已保留数据',
code: 'UPLOAD_CLEANUP_REDIS_UNAVAILABLE',
httpStatus: 503,
})
const response = await GET(new NextRequest(
'http://localhost/api/materials/upload/cleanup?uploadId=uncertain-upload',
))
const body = await response.json()
expect(response.status).toBe(503)
expect(body.code).toBe('UPLOAD_CLEANUP_REDIS_UNAVAILABLE')
})
})