183 lines
6.6 KiB
JavaScript
183 lines
6.6 KiB
JavaScript
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();
|
|
}
|
|
});
|