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