Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults
This commit is contained in:
182
apps/server/test/resumable.test.js
Normal file
182
apps/server/test/resumable.test.js
Normal file
@@ -0,0 +1,182 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user