/** @jest-environment node */ import { assertBoundedMultipartHeaders, assertMultipartFiles, MultipartRequestError, } from '@/lib/multipart-request-guard' type FakeFile = { name: string size: number type: string arrayBuffer: () => Promise } 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() }) })