Files
hop.noname.company/apps/server/test/resumable.test.js

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();
}
});