gongxiangp2/lib/__tests__/multipart-request-guard.test.ts
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

248 lines
6.9 KiB
TypeScript

/** @jest-environment node */
import {
assertBoundedMultipartHeaders,
assertMultipartFiles,
MultipartRequestError,
} from '@/lib/multipart-request-guard'
type FakeFile = {
name: string
size: number
type: string
arrayBuffer: () => Promise<ArrayBuffer>
}
function fakeFile(name: string, size: number, type = 'image/png'): FakeFile {
return {
name,
size,
type,
arrayBuffer: jest.fn(async () => new ArrayBuffer(0)),
}
}
function fakeFormData(entries: Array<[string, string | FakeFile]>): FormData {
return {
entries: () => entries[Symbol.iterator](),
} as unknown as FormData
}
function expectGuardError(
action: () => unknown,
status: number,
code: string,
): void {
try {
action()
throw new Error('expected guard error')
} catch (error) {
expect(error).toBeInstanceOf(MultipartRequestError)
expect(error).toMatchObject({ status, code })
expect((error as Error).message).toMatch(/[\u4e00-\u9fff]/)
}
}
describe('assertBoundedMultipartHeaders', () => {
const validHeaders = (length = '12'): Headers => new Headers({
'content-type': 'multipart/form-data; boundary=upload-boundary',
'content-length': length,
})
test('requires multipart/form-data and a non-empty boundary', () => {
expect(assertBoundedMultipartHeaders(validHeaders(), 100)).toBe(12)
expect(assertBoundedMultipartHeaders(new Headers({
'content-type': 'multipart/form-data; boundary="quoted-boundary"',
'content-length': '12',
}), 100)).toBe(12)
expectGuardError(
() => assertBoundedMultipartHeaders(new Headers({
'content-type': 'application/json',
'content-length': '12',
}), 100),
415,
'MULTIPART_REQUIRED',
)
expectGuardError(
() => assertBoundedMultipartHeaders(new Headers({
'content-type': 'multipart/form-data',
'content-length': '12',
}), 100),
415,
'MULTIPART_BOUNDARY_REQUIRED',
)
expectGuardError(
() => assertBoundedMultipartHeaders(new Headers({
'content-type': 'multipart/form-data; boundary=""',
'content-length': '12',
}), 100),
415,
'MULTIPART_BOUNDARY_REQUIRED',
)
})
test('allows an empty or identity content-encoding, but rejects compression', () => {
expect(assertBoundedMultipartHeaders(new Headers({
...Object.fromEntries(validHeaders()),
'content-encoding': '',
}), 100)).toBe(12)
expect(assertBoundedMultipartHeaders(new Headers({
...Object.fromEntries(validHeaders()),
'content-encoding': 'identity',
}), 100)).toBe(12)
expectGuardError(
() => assertBoundedMultipartHeaders(new Headers({
...Object.fromEntries(validHeaders()),
'content-encoding': 'gzip',
}), 100),
415,
'CONTENT_ENCODING_UNSUPPORTED',
)
})
test.each([
['', 'missing'],
['abc', 'non-decimal'],
['+12', 'signed'],
['12.0', 'fractional'],
['0', 'zero'],
[String(Number.MAX_SAFE_INTEGER) + '0', 'unsafe integer'],
])('returns a recognizable 411 for %s Content-Length (%s)', (length) => {
const headers = validHeaders(length)
if (!length) headers.delete('content-length')
expectGuardError(
() => assertBoundedMultipartHeaders(headers, 100),
411,
'LENGTH_REQUIRED',
)
})
test('returns 413 when the trusted request length is above the bound', () => {
expectGuardError(
() => assertBoundedMultipartHeaders(validHeaders('101'), 100),
413,
'REQUEST_TOO_LARGE',
)
})
})
describe('assertMultipartFiles', () => {
const policy = {
fields: {
logo: {
maxCount: 1,
maxFileBytes: 100,
allowedMimeTypes: ['image/png', 'image/jpeg'],
},
gallery: {
maxCount: 2,
maxFileBytes: 80,
maxTotalBytes: 120,
allowedMimeTypes: ['image/*'],
},
},
maxFiles: 3,
maxTotalBytes: 200,
} as const
test('walks all entries, ignores text fields, and returns a summary', () => {
const logo = fakeFile('logo.png', 20)
const gallery = fakeFile('gallery.jpg', 30, 'image/jpeg')
const summary = assertMultipartFiles(fakeFormData([
['type', 'header'],
['description', 'text is not a file'],
['logo', logo],
['gallery', gallery],
]), policy)
expect(summary.totalFiles).toBe(2)
expect(summary.totalBytes).toBe(50)
expect(summary.files).toEqual([logo, gallery])
expect(summary.filesByField).toEqual({ logo: [logo], gallery: [gallery] })
})
test('rejects a file in an unapproved field, including fields after text entries', () => {
expectGuardError(
() => assertMultipartFiles(fakeFormData([
['description', 'text'],
['unexpected', fakeFile('unexpected.bin', 1)],
]), policy),
400,
'INVALID_FILE_FIELD',
)
})
test('enforces per-field and total file counts for repeated entries', () => {
expectGuardError(
() => assertMultipartFiles(fakeFormData([
['logo', fakeFile('one.png', 1)],
['logo', fakeFile('two.png', 1)],
]), policy),
413,
'TOO_MANY_FILES',
)
expectGuardError(
() => assertMultipartFiles(fakeFormData([
['logo', fakeFile('one.png', 1)],
['gallery', fakeFile('two.png', 1)],
['gallery', fakeFile('three.png', 1)],
['gallery', fakeFile('four.png', 1)],
]), policy),
413,
'TOO_MANY_FILES',
)
})
test('rejects empty files before any file bytes are read', () => {
const empty = fakeFile('empty.png', 0)
expectGuardError(
() => assertMultipartFiles(fakeFormData([['logo', empty]]), policy),
400,
'EMPTY_FILE',
)
expect(empty.arrayBuffer).not.toHaveBeenCalled()
})
test('enforces single-file and per-field/overall byte limits', () => {
expectGuardError(
() => assertMultipartFiles(fakeFormData([
['logo', fakeFile('too-large.png', 101)],
]), policy),
413,
'FILE_TOO_LARGE',
)
expectGuardError(
() => assertMultipartFiles(fakeFormData([
['gallery', fakeFile('one.png', 70)],
['gallery', fakeFile('two.png', 51)],
]), policy),
413,
'FILES_TOO_LARGE',
)
expectGuardError(
() => assertMultipartFiles(fakeFormData([
['logo', fakeFile('logo.png', 100)],
['gallery', fakeFile('one.png', 80)],
['gallery', fakeFile('two.png', 21)],
]), policy),
413,
'FILES_TOO_LARGE',
)
})
test('enforces the allowed MIME list without rejecting text values', () => {
expectGuardError(
() => assertMultipartFiles(fakeFormData([
['logo', fakeFile('logo.gif', 10, 'image/gif')],
]), policy),
415,
'MIME_TYPE_UNSUPPORTED',
)
expect(() => assertMultipartFiles(fakeFormData([
['description', 'image/gif is only text here'],
['gallery', fakeFile('gallery.webp', 10, 'image/webp')],
]), policy)).not.toThrow()
})
})