Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
134 lines
4.0 KiB
TypeScript
134 lines
4.0 KiB
TypeScript
/** @jest-environment node */
|
|
|
|
import {
|
|
MATERIAL_UPLOAD_REQUEST_LIMITS,
|
|
MaterialUploadRequestError,
|
|
MaterialUploadRequestLimiter,
|
|
validateMaterialUploadFormData,
|
|
validateMaterialUploadRequestHeaders,
|
|
} from '@/lib/material-upload-request-guard'
|
|
|
|
type FakeFile = {
|
|
name: string
|
|
size: number
|
|
arrayBuffer: () => Promise<ArrayBuffer>
|
|
}
|
|
|
|
function fakeFile(name: string, size: number): FakeFile {
|
|
return {
|
|
name,
|
|
size,
|
|
arrayBuffer: jest.fn(async () => new ArrayBuffer(0)),
|
|
}
|
|
}
|
|
|
|
function fakeFormData(entries: Array<[string, string | FakeFile]>): FormData {
|
|
return {
|
|
entries: () => entries[Symbol.iterator](),
|
|
get: (field: string) => entries.find(([key]) => key === field)?.[1] ?? null,
|
|
} as unknown as FormData
|
|
}
|
|
|
|
function expectGuardError(action: () => unknown, status: number, code: string) {
|
|
try {
|
|
action()
|
|
throw new Error('expected guard error')
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(MaterialUploadRequestError)
|
|
expect(error).toMatchObject({ status, code })
|
|
}
|
|
}
|
|
|
|
describe('material upload request guard', () => {
|
|
test('requires a bounded multipart Content-Length before parsing', () => {
|
|
expectGuardError(
|
|
() => validateMaterialUploadRequestHeaders(new Headers({
|
|
'content-type': 'multipart/form-data; boundary=test',
|
|
})),
|
|
411,
|
|
'CONTENT_LENGTH_REQUIRED',
|
|
)
|
|
|
|
expect(validateMaterialUploadRequestHeaders(new Headers({
|
|
'content-length': String(MATERIAL_UPLOAD_REQUEST_LIMITS.maxRequestBytes),
|
|
'content-type': 'multipart/form-data; boundary=test',
|
|
}))).toBe(MATERIAL_UPLOAD_REQUEST_LIMITS.maxRequestBytes)
|
|
|
|
expectGuardError(
|
|
() => validateMaterialUploadRequestHeaders(new Headers({
|
|
'content-length': String(MATERIAL_UPLOAD_REQUEST_LIMITS.maxRequestBytes + 1),
|
|
'content-type': 'multipart/form-data; boundary=test',
|
|
})),
|
|
413,
|
|
'UPLOAD_REQUEST_TOO_LARGE',
|
|
)
|
|
})
|
|
|
|
test('forces a 5MB direct source file onto the chunk upload path', () => {
|
|
const source = fakeFile('source.zip', 5 * 1024 * 1024)
|
|
expectGuardError(
|
|
() => validateMaterialUploadFormData(fakeFormData([['zipFile', source]])),
|
|
413,
|
|
'CHUNK_UPLOAD_REQUIRED',
|
|
)
|
|
expect(source.arrayBuffer).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('rejects excessive file counts and image field limits before decoding', () => {
|
|
const tooMany = Array.from({ length: 51 }, (_, index) => (
|
|
['modelImages', fakeFile(`${index}.jpg`, 1)] as [string, FakeFile]
|
|
))
|
|
expectGuardError(
|
|
() => validateMaterialUploadFormData(fakeFormData(tooMany)),
|
|
413,
|
|
'TOO_MANY_UPLOAD_FILES',
|
|
)
|
|
|
|
expectGuardError(
|
|
() => validateMaterialUploadFormData(fakeFormData([
|
|
['previewImage', fakeFile('cover.png', 8 * 1024 * 1024 + 1)],
|
|
])),
|
|
413,
|
|
'UPLOAD_FILE_TOO_LARGE',
|
|
)
|
|
|
|
expectGuardError(
|
|
() => validateMaterialUploadFormData(fakeFormData([
|
|
['modelImages', fakeFile('gallery.jpg', 16 * 1024 * 1024 + 1)],
|
|
])),
|
|
413,
|
|
'UPLOAD_FILE_TOO_LARGE',
|
|
)
|
|
})
|
|
|
|
test('allows an existing merged source with bounded image metadata request', () => {
|
|
expect(() => validateMaterialUploadFormData(fakeFormData([
|
|
['existingFilePath', 'uploads/temp/upload-1/merged.mp4'],
|
|
['previewImage', fakeFile('cover.jpg', 1024)],
|
|
]))).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('material upload request limiter', () => {
|
|
test('hands the only slot to one waiter and releases it on every completion', async () => {
|
|
const limiter = new MaterialUploadRequestLimiter(1, 1)
|
|
const releaseFirst = await limiter.acquire()
|
|
const secondAcquire = limiter.acquire()
|
|
|
|
expect(limiter.snapshot()).toEqual({ active: 1, waiting: 1 })
|
|
await expect(limiter.acquire()).rejects.toMatchObject({
|
|
status: 503,
|
|
code: 'UPLOAD_BUSY',
|
|
retryAfterSeconds: 3,
|
|
})
|
|
|
|
releaseFirst()
|
|
const releaseSecond = await secondAcquire
|
|
expect(limiter.snapshot()).toEqual({ active: 1, waiting: 0 })
|
|
|
|
releaseSecond()
|
|
releaseSecond()
|
|
expect(limiter.snapshot()).toEqual({ active: 0, waiting: 0 })
|
|
})
|
|
})
|