import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createHash, randomUUID } from 'node:crypto'; import { readdir, readFile, writeFile } from 'node:fs/promises'; import { eq } from 'drizzle-orm'; import { fixture } from './helpers.js'; import { CHUNK_BYTES, ResumableUploads } from '../src/resumable.js'; import * as schema from '../src/db/schema.js'; const digest = (bytes) => createHash('sha256').update(bytes).digest(); function fingerprint(bytes) { const hashes = []; for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) hashes.push(digest(bytes.subarray(offset, offset + CHUNK_BYTES))); return digest(Buffer.concat(hashes)).toString('hex'); } async function prepare(f, bytes, accept = true) { const a = await f.create(), b = await f.create(); await f.space(a, b); const response = await f.call( a, '/transfer-requests', 'POST', { targetDeviceId: b.deviceId, kind: 'file', byteSize: bytes.length, count: 1, files: [{ name: 'resumable.bin', size: bytes.length, type: 'application/octet-stream' }], }, randomUUID(), ); assert.equal(response.statusCode, 200, response.body); const requestId = response.json().requestId; if (accept) assert.equal( (await f.call(b, `/transfer-requests/${requestId}/accept`, 'POST', {}, randomUUID())) .statusCode, 200, ); const start = (sender = a, content = bytes) => f.call(sender, '/uploads/resumable', 'POST', { requestId, fileIndex: 0, fingerprint: fingerprint(content), }); return { a, b, requestId, start }; } const put = (f, sender, id, offset, bytes) => f.call(sender, `/uploads/resumable/${id}`, 'PUT', bytes, undefined, { 'content-type': 'application/octet-stream', 'upload-offset': String(offset), }); test('분할 업로드는 수락·발신 권한·Origin·CSRF를 검사한다', async () => { const f = await fixture(); try { const p = await prepare(f, Buffer.from('hello'), false); assert.equal((await p.start()).statusCode, 409); await f.call(p.b, `/transfer-requests/${p.requestId}/accept`, 'POST', {}, randomUUID()); assert.equal((await p.start(p.b)).statusCode, 403); const state = (await p.start()).json(); assert.equal( ( await f.call( p.a, `/uploads/resumable/${state.uploadId}`, 'PUT', Buffer.from('hello'), undefined, { 'content-type': 'application/octet-stream', 'upload-offset': '0', origin: 'https://evil.test', }, ) ).statusCode, 403, ); assert.equal( ( await f.call( p.a, `/uploads/resumable/${state.uploadId}`, 'PUT', Buffer.from('hello'), undefined, { 'content-type': 'application/octet-stream', 'upload-offset': '0', 'x-csrf-token': 'wrong', }, ) ).statusCode, 403, ); } finally { await f.close(); } }); test('받은 조각의 중복·다른 내용·순서를 검사하고 이어 받아 원본을 확정한다', async () => { const f = await fixture(); try { const bytes = Buffer.alloc(CHUNK_BYTES + 13, 71), p = await prepare(f, bytes); const state = (await p.start()).json(), path = `/uploads/resumable/${state.uploadId}`; const first = bytes.subarray(0, CHUNK_BYTES); assert.equal( (await put(f, p.a, state.uploadId, CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).json().code, 'UPLOAD_OFFSET', ); assert.equal((await put(f, p.a, state.uploadId, 0, first)).json().offset, CHUNK_BYTES); assert.equal((await put(f, p.a, state.uploadId, 0, first)).json().offset, CHUNK_BYTES); assert.equal( (await put(f, p.a, state.uploadId, 0, Buffer.alloc(CHUNK_BYTES, 72))).json().code, 'CHUNK_CONFLICT', ); assert.equal( (await f.call(p.a, `${path}/complete`, 'POST', {})).json().code, 'UPLOAD_INCOMPLETE', ); f.service.resumable = new ResumableUploads(f.service); assert.equal((await p.start()).json().offset, CHUNK_BYTES); assert.equal((await p.start(p.a, Buffer.alloc(bytes.length, 73))).json().code, 'FILE_CHANGED'); assert.equal( (await put(f, p.a, state.uploadId, CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).statusCode, 200, ); assert.equal((await f.call(p.a, `${path}/complete`, 'POST', {})).statusCode, 200); assert.equal((await f.call(p.a, `${path}/complete`, 'POST', {})).statusCode, 200); const item = (await f.call(p.b, '/device/items')).json().items[0]; const download = await f.call(p.b, `/access/${item.accessId}/download`); assert.deepEqual(download.rawPayload, bytes); assert.equal((await readdir(f.service.resumable.directory)).length, 0); } finally { await f.close(); } }); test('8MiB 초과·전체 파일 검증 실패·취소는 자료 확정을 막고 임시 파일을 정리한다', async () => { const f = await fixture(); try { const bytes = Buffer.from('original'), p = await prepare(f, bytes); const state = (await p.start()).json(); assert.equal( (await put(f, p.a, state.uploadId, 0, Buffer.alloc(CHUNK_BYTES + 1))).statusCode, 413, ); assert.equal((await put(f, p.a, state.uploadId, 0, Buffer.from('tampered'))).statusCode, 200); assert.equal( (await f.call(p.a, `/uploads/resumable/${state.uploadId}/complete`, 'POST', {})).json().code, 'FILE_CHECKSUM', ); assert.equal((await f.call(p.b, '/device/items')).json().items.length, 0); await f.call(p.a, `/transfer-requests/${p.requestId}/cancel`, 'POST', {}); assert.equal((await readdir(f.service.resumable.directory)).length, 0); assert.equal((await put(f, p.a, state.uploadId, 0, bytes)).statusCode, 409); } finally { await f.close(); } }); test('메타데이터 저장 전 중단된 쓰기는 마지막 확정 위치로 복구하고 만료 조각을 정리한다', async () => { const f = await fixture(); try { const p = await prepare(f, Buffer.from('crash')), state = (await p.start()).json(); await writeFile(f.service.resumable.path(state.uploadId, 'part'), 'uncommitted'); assert.equal((await p.start()).json().offset, 0); assert.equal((await readFile(f.service.resumable.path(state.uploadId, 'part'))).length, 0); f.service.db .update(schema.requests) .set({ startBy: Date.now() - 1 }) .where(eq(schema.requests.id, p.requestId)) .run(); f.service.resumable.cleanup(); assert.equal((await readdir(f.service.resumable.directory)).length, 0); } finally { await f.close(); } });