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

153 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @jest-environment node */
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises'
import os from 'os'
import path from 'path'
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { POST } from '@/app/api/materials/upload/complete/route'
import {
mergeUploadChunksAtomically,
} from '@/lib/material-upload-merge'
import { MaterialUploadStorageReservationManager } from '@/lib/material-upload-storage-reservation'
import { cancelUploadSessionSeal } from '@/lib/material-upload-session-seal'
import {
deleteUploadSession,
getOwnedUploadSession,
} from '@/lib/upload-sessions'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/upload-sessions', () => ({
deleteUploadSession: jest.fn(),
getOwnedUploadSession: jest.fn(),
isSafeUploadId: jest.fn((uploadId: string) => /^upload-[A-Za-z0-9-]+$/.test(uploadId)),
}))
function completeRequest(uploadId = 'upload-test-1') {
return new NextRequest('http://localhost/api/materials/upload/complete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ uploadId, originalFileName: 'video.mp4' }),
})
}
describe('material upload complete safety', () => {
let uploadRoot: string
const uploadId = 'upload-test-1'
const fileName = 'video.mp4'
const fileBytes = Buffer.from('abcdefgh')
beforeEach(async () => {
jest.clearAllMocks()
uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'material-complete-'))
process.env.UPLOAD_DIR = uploadRoot
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
;(getOwnedUploadSession as jest.Mock).mockResolvedValue({
fileName,
fileSize: fileBytes.length,
totalChunks: 2,
chunkSize: 4,
chunks: new Map(),
createdAt: Date.now(),
userId: 'user-1',
})
;(deleteUploadSession as jest.Mock).mockResolvedValue(undefined)
const tempDir = path.join(uploadRoot, 'temp', uploadId)
await mkdir(tempDir, { recursive: true })
await writeFile(path.join(tempDir, 'meta.json'), '{}')
await writeFile(path.join(tempDir, 'chunk-0'), fileBytes.subarray(0, 4))
await writeFile(path.join(tempDir, 'chunk-1'), fileBytes.subarray(4))
})
afterEach(async () => {
delete process.env.UPLOAD_DIR
await rm(uploadRoot, { recursive: true, force: true })
})
test('首次成功响应丢失后,第二次 complete 幂等返回同一个完整文件', async () => {
const firstResponse = await POST(completeRequest())
const firstBody = await firstResponse.json()
expect(firstResponse.status).toBe(200)
expect(await readFile(firstBody.existingFilePath)).toEqual(fileBytes)
await expect(readFile(path.join(uploadRoot, 'temp', uploadId, 'chunk-0')))
.rejects.toMatchObject({ code: 'ENOENT' })
const secondResponse = await POST(completeRequest())
const secondBody = await secondResponse.json()
expect(secondResponse.status).toBe(200)
expect(secondBody.existingFilePath).toBe(firstBody.existingFilePath)
expect(secondBody.fileSize).toBe(fileBytes.length)
expect(await readFile(secondBody.existingFilePath)).toEqual(fileBytes)
})
test('cancelled 终态让 complete 结构化 fail-closed', async () => {
await cancelUploadSessionSeal(uploadId)
const response = await POST(completeRequest())
const body = await response.json()
expect(response.status).toBe(409)
expect(body.code).toBe('UPLOAD_SESSION_CANCELLED')
await expect(readFile(path.join(uploadRoot, 'temp', uploadId, 'chunk-0')))
.resolves.toEqual(fileBytes.subarray(0, 4))
expect(deleteUploadSession).not.toHaveBeenCalled()
})
test('合并前磁盘不足时 fail-closed 并返回可识别的 507 错误', async () => {
const reservations = new MaterialUploadStorageReservationManager(
100,
async () => BigInt(1_000),
)
await expect(
reservations.acquire(uploadRoot, 901),
).rejects.toMatchObject({
status: 507,
code: 'UPLOAD_STORAGE_LOW',
})
})
test('合并预留会持续扣住额度,后续分片不能吃掉尚未写入的空间', async () => {
const reservations = new MaterialUploadStorageReservationManager(
100,
async () => BigInt(1_000),
)
const releaseMerge = await reservations.acquire(uploadRoot, 700)
expect(reservations.snapshot()).toEqual({ reservedBytes: BigInt(700) })
await expect(reservations.acquire(uploadRoot, 201)).rejects.toMatchObject({
status: 507,
code: 'UPLOAD_STORAGE_LOW',
})
releaseMerge()
const releaseChunk = await reservations.acquire(uploadRoot, 201)
expect(reservations.snapshot()).toEqual({ reservedBytes: BigInt(201) })
releaseChunk()
expect(reservations.snapshot()).toEqual({ reservedBytes: BigInt(0) })
})
test('原子改名失败时删除本次 partial,不留下占盘半成品', async () => {
const chunkPath = path.join(uploadRoot, 'chunk.bin')
const occupiedOutputPath = path.join(uploadRoot, 'occupied-output')
await writeFile(chunkPath, Buffer.from('safe'))
await mkdir(occupiedOutputPath)
await expect(mergeUploadChunksAtomically({
chunks: [{ path: chunkPath, expectedBytes: 4 }],
expectedTotalBytes: 4,
outputPath: occupiedOutputPath,
})).rejects.toBeTruthy()
expect(
(await readdir(uploadRoot)).filter((name) => name.startsWith('occupied-output.partial-')),
).toEqual([])
})
})