v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
158
tests/multipart-file-upload.test.js
Normal file
158
tests/multipart-file-upload.test.js
Normal file
@@ -0,0 +1,158 @@
|
||||
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 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user