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