Files
sori.studio/tests/multipart-file-upload.test.js
zenn de767095ba v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:05:40 +09:00

159 lines
5.0 KiB
JavaScript

import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import test from 'node:test'
import {
decodeMultipartFilename,
saveMultipartFilesToDisk
} from '../server/utils/multipart-file-upload.js'
const boundary = '----sori-stream-test'
/**
* 테스트용 multipart 요청 본문을 생성한다.
* @param {Array<{ name: string, type: string, content: string|Buffer }>} files - 파일 목록
* @returns {Buffer} multipart 본문
*/
const createMultipartBody = (files) => {
const chunks = []
for (const file of files) {
chunks.push(Buffer.from(
`--${boundary}\r\n`
+ 'Content-Disposition: form-data; name="files"; filename="'
))
chunks.push(Buffer.isBuffer(file.name) ? file.name : Buffer.from(String(file.name), 'utf8'))
chunks.push(Buffer.from(
`"\r\nContent-Type: ${file.type}\r\n\r\n`
))
chunks.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(String(file.content), 'utf8'))
chunks.push(Buffer.from('\r\n'))
}
chunks.push(Buffer.from(`--${boundary}--\r\n`))
return Buffer.concat(chunks)
}
/**
* 테스트용 H3 이벤트 요청 객체를 생성한다.
* @param {Buffer} body - 요청 본문
* @returns {{ node: { req: Readable & { headers: Record<string, string> } } }} 요청 이벤트 대체 객체
*/
const createMultipartEvent = (body) => {
const request = Readable.from([body])
request.headers = {
'content-type': `multipart/form-data; boundary=${boundary}`,
'content-length': String(body.length)
}
return {
node: {
req: request
}
}
}
/**
* 여러 파일이 메모리 Buffer 변환 없이 디스크에 저장되는지 검증한다.
* @returns {Promise<void>}
*/
const testMultipleFileStreaming = async () => {
const directory = await mkdtemp(join(tmpdir(), 'sori-multipart-test-'))
try {
const body = createMultipartBody([
{ name: 'one.txt', type: 'text/plain', content: '첫 번째' },
{ name: 'two.txt', type: 'text/plain', content: '두 번째 파일' }
])
const files = await saveMultipartFilesToDisk(createMultipartEvent(body), {
fieldName: 'files',
maxFiles: 2,
maxTotalBytes: 1024,
createFileTarget: async (_file, index) => ({
filePath: join(directory, `${index}.txt`),
maxBytes: 128
})
})
const contents = await Promise.all(files.map((file) => readFile(file.filePath, 'utf8')))
assert.deepEqual(contents, ['첫 번째', '두 번째 파일'])
} finally {
await rm(directory, { recursive: true, force: true })
}
}
/**
* 파일별 스트리밍 크기 제한을 검증한다.
* @returns {Promise<void>}
*/
const testFileSizeLimit = async () => {
const directory = await mkdtemp(join(tmpdir(), 'sori-multipart-limit-test-'))
try {
const body = createMultipartBody([
{ name: 'large.txt', type: 'text/plain', content: '1234567890' }
])
await assert.rejects(
saveMultipartFilesToDisk(createMultipartEvent(body), {
fieldName: 'files',
maxFiles: 1,
maxTotalBytes: 1024,
createFileTarget: async () => ({
filePath: join(directory, 'large.txt'),
maxBytes: 5
})
}),
(error) => error?.code === 'MULTIPART_FILE_TOO_LARGE'
)
} finally {
await rm(directory, { recursive: true, force: true })
}
}
test('다중 multipart 파일을 디스크로 스트리밍한다', testMultipleFileStreaming)
test('multipart 파일별 크기 제한을 적용한다', testFileSizeLimit)
test('busboy Latin-1 오해로 깨진 한글 파일명을 복구한다', () => {
const nfdName = '스크린샷 2026-06-25 오후 5.13.27'.normalize('NFD')
const latin1Mojibake = Buffer.from(nfdName, 'utf8').toString('latin1')
assert.equal(
decodeMultipartFilename(latin1Mojibake),
'스크린샷 2026-06-25 오후 5.13.27'
)
assert.equal(
decodeMultipartFilename('스크린샷-2026-06-25-오후-5.13.27.png'),
'스크린샷-2026-06-25-오후-5.13.27.png'
)
assert.equal(decodeMultipartFilename('café.png'), 'café.png')
})
test('UTF-8 한글 파일명 multipart를 올바르게 읽는다', async () => {
const directory = await mkdtemp(join(tmpdir(), 'sori-multipart-korean-'))
try {
const koreanName = Buffer.from('스크린샷 2026-06-25 오후 5.13.27.png', 'utf8')
const body = createMultipartBody([
{ name: koreanName, type: 'image/png', content: 'png-bytes' }
])
const files = await saveMultipartFilesToDisk(createMultipartEvent(body), {
fieldName: 'files',
maxFiles: 1,
maxTotalBytes: 1024,
createFileTarget: async (file) => ({
filePath: join(directory, 'upload.bin'),
maxBytes: 128,
metadata: { filename: file.filename }
})
})
assert.equal(files[0].filename, '스크린샷 2026-06-25 오후 5.13.27.png')
assert.equal(files[0].metadata.filename, '스크린샷 2026-06-25 오후 5.13.27.png')
} finally {
await rm(directory, { recursive: true, force: true })
}
})