세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
159 lines
5.0 KiB
JavaScript
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 })
|
|
}
|
|
})
|