Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
411
apps/server/test/phase2.test.ts
Normal file
411
apps/server/test/phase2.test.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile, mkdtemp, rm, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { readMigrationFiles } from 'drizzle-orm/migrator';
|
||||
import Database from 'better-sqlite3';
|
||||
import sharp from 'sharp';
|
||||
import { fixture } from './helpers.js';
|
||||
import * as s from '../src/db/schema.js';
|
||||
import { openDatabase } from '../src/db/index.js';
|
||||
async function fileRequest(
|
||||
f: Awaited<ReturnType<typeof fixture>>,
|
||||
a: any,
|
||||
b: any,
|
||||
files: { name: string; size: number; type: string }[],
|
||||
) {
|
||||
const r = await f.call(
|
||||
a,
|
||||
'/transfer-requests',
|
||||
'POST',
|
||||
{
|
||||
targetDeviceId: b.deviceId,
|
||||
kind: 'file',
|
||||
byteSize: files.reduce((sum, v) => sum + v.size, 0),
|
||||
count: files.length,
|
||||
files,
|
||||
},
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(r.statusCode, 200, r.body);
|
||||
return r.json().requestId as string;
|
||||
}
|
||||
async function upload(
|
||||
f: Awaited<ReturnType<typeof fixture>>,
|
||||
a: any,
|
||||
id: string,
|
||||
name: string,
|
||||
bytes: Buffer,
|
||||
index = 0,
|
||||
key = randomUUID(),
|
||||
) {
|
||||
const boundary = `hop${randomUUID()}`;
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: application/octet-stream\r\n\r\n`,
|
||||
),
|
||||
bytes,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
]);
|
||||
return f.call(a, `/uploads?requestId=${id}&fileIndex=${index}`, 'POST', body, key, {
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
});
|
||||
}
|
||||
test('파일은 수락 전 업로드되지 않으며 실제 스트리밍 저장과 인증 다운로드', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create(),
|
||||
c = await f.create();
|
||||
await f.space(a, b);
|
||||
const bytes = Buffer.from('실제 파일 내용'),
|
||||
id = await fileRequest(f, a, b, [
|
||||
{ name: '파일.txt', size: bytes.length, type: 'text/plain' },
|
||||
]);
|
||||
assert.equal((await upload(f, a, id, '파일.txt', bytes)).statusCode, 409);
|
||||
assert.equal((await readdir(f.service.storage.originals)).length, 0);
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
|
||||
200,
|
||||
);
|
||||
const uploaded = await upload(f, a, id, '파일.txt', bytes);
|
||||
assert.equal(uploaded.statusCode, 200, uploaded.body);
|
||||
const item = (await f.call(b, '/device/items')).json().items[0];
|
||||
assert.equal(item.file.name, '파일.txt');
|
||||
assert.equal((await f.call(c, `/access/${item.accessId}/download`)).statusCode, 404);
|
||||
const download = await f.call(b, `/access/${item.accessId}/download`);
|
||||
assert.equal(download.statusCode, 200);
|
||||
assert.deepEqual(download.rawPayload, bytes);
|
||||
assert.match(String(download.headers['content-disposition']), /attachment/);
|
||||
assert.equal(download.headers['x-content-type-options'], 'nosniff');
|
||||
assert.equal(f.service.db.select().from(s.reservations).all().length, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('선언 크기와 다른 파일, 요청 밖 파일, 이름 변경을 거절하고 임시 파일 회수', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
const id = await fileRequest(f, a, b, [{ name: 'a.txt', size: 4, type: 'text/plain' }]);
|
||||
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
|
||||
assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('excess'))).statusCode, 413);
|
||||
assert.equal((await readdir(f.service.storage.temp)).length, 0);
|
||||
assert.equal((await upload(f, a, id, 'changed.txt', Buffer.from('test'))).statusCode, 409);
|
||||
assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('test'), 1)).statusCode, 400);
|
||||
assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('test'))).statusCode, 200);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('부분 성공은 유지하고 실패 파일만 재시도', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
const id = await fileRequest(f, a, b, [
|
||||
{ name: 'one.txt', size: 3, type: 'text/plain' },
|
||||
{ name: 'two.txt', size: 3, type: 'text/plain' },
|
||||
]);
|
||||
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
|
||||
assert.equal((await upload(f, a, id, 'one.txt', Buffer.from('one'))).statusCode, 200);
|
||||
assert.equal((await upload(f, a, id, 'two.txt', Buffer.from('badsize'), 1)).statusCode, 413);
|
||||
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
|
||||
assert.equal((await upload(f, a, id, 'two.txt', Buffer.from('two'), 1)).statusCode, 200);
|
||||
assert.equal((await f.call(b, '/device/items')).json().items.length, 2);
|
||||
assert.equal(
|
||||
f.service.storage.fileRows(id).every((v) => v.status === 'stored'),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('예약을 원자적으로 적용해 전체 익명 상한을 우회하지 못한다', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.cfg.totalGuestQuota = 4;
|
||||
const a = await f.create(),
|
||||
b = await f.create(),
|
||||
c = await f.create();
|
||||
await f.space(a, b);
|
||||
await f.space(a, c);
|
||||
const one = await fileRequest(f, a, b, [
|
||||
{ name: 'one', size: 4, type: 'application/octet-stream' },
|
||||
]),
|
||||
two = await fileRequest(f, c, b, [
|
||||
{ name: 'two', size: 4, type: 'application/octet-stream' },
|
||||
]);
|
||||
const results = await Promise.all([
|
||||
f.call(b, `/transfer-requests/${one}/accept`, 'POST', {}, randomUUID()),
|
||||
f.call(b, `/transfer-requests/${two}/accept`, 'POST', {}, randomUUID()),
|
||||
]);
|
||||
assert.deepEqual(results.map((r) => r.statusCode).sort(), [200, 507]);
|
||||
assert.equal(
|
||||
f.service.db
|
||||
.select()
|
||||
.from(s.reservations)
|
||||
.all()
|
||||
.reduce((sum, r) => sum + r.bytes, 0),
|
||||
4,
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('개별 익명 용량, 파일당 제한과 취소 후 예약 회수', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.cfg.guestQuota = 3;
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
const id = await fileRequest(f, a, b, [
|
||||
{ name: 'large', size: 4, type: 'application/octet-stream' },
|
||||
]);
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
|
||||
413,
|
||||
);
|
||||
f.cfg.guestQuota = 4;
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
|
||||
200,
|
||||
);
|
||||
await f.call(a, `/transfer-requests/${id}/cancel`, 'POST', {});
|
||||
assert.equal(f.service.db.select().from(s.reservations).all().length, 0);
|
||||
assert.equal((await upload(f, a, id, 'large', Buffer.from('test'))).statusCode, 409);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('인증 래스터 미리보기는 읽음이 아니며 SVG와 실행 콘텐츠는 거절', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
const png = await sharp({
|
||||
create: { width: 16, height: 16, channels: 3, background: '#365e40' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const id = await fileRequest(f, a, b, [
|
||||
{ name: 'test.png', size: png.length, type: 'image/png' },
|
||||
]);
|
||||
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
|
||||
await upload(f, a, id, 'test.png', png);
|
||||
const item = (await f.call(b, '/device/items')).json().items[0];
|
||||
const preview = await f.call(b, `/access/${item.accessId}/preview`);
|
||||
assert.equal(preview.statusCode, 200);
|
||||
assert.equal(preview.headers['content-type'], 'image/webp');
|
||||
assert.equal((await f.call(b, '/device/items')).json().items[0].read, false);
|
||||
const c = await f.create();
|
||||
await f.space(a, c);
|
||||
const svg = Buffer.from(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
|
||||
);
|
||||
const svgId = await fileRequest(f, c, b, [
|
||||
{ name: 'evil.svg', size: svg.length, type: 'image/svg+xml' },
|
||||
]);
|
||||
await f.call(b, `/transfer-requests/${svgId}/accept`, 'POST', {}, randomUUID());
|
||||
await upload(f, c, svgId, 'evil.svg', svg);
|
||||
const svgItem = (await f.call(b, '/device/items'))
|
||||
.json()
|
||||
.items.find((v: any) => v.file.name === 'evil.svg');
|
||||
assert.equal((await f.call(b, `/access/${svgItem.accessId}/preview`)).statusCode, 415);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('내 목록 삭제는 상대 접근권을 유지하고 서버 복원 ID로 실행 취소', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create(),
|
||||
c = await f.create();
|
||||
await f.space(a, b);
|
||||
await f.complete(a, b);
|
||||
const item = (await f.call(b, '/device/items')).json().items[0];
|
||||
const deletion = (
|
||||
await f.call(b, '/access/delete-batch', 'POST', { ids: [item.accessId] })
|
||||
).json();
|
||||
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
|
||||
assert.equal((await f.call(a, '/device/items')).json().items.length, 1);
|
||||
assert.equal(
|
||||
(await f.call(c, `/deletion-operations/${deletion.operationId}/undo`, 'POST', {})).statusCode,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call(b, `/deletion-operations/${deletion.operationId}/undo`, 'POST', {})).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('실행 취소 기한과 자연 만료를 검사하며 마지막 권한 뒤 파일 정리', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
const id = await fileRequest(f, a, b, [{ name: 'delete.txt', size: 4, type: 'text/plain' }]);
|
||||
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
|
||||
await upload(f, a, id, 'delete.txt', Buffer.from('test'));
|
||||
const sender = (await f.call(a, '/device/items')).json().items[0],
|
||||
receiver = (await f.call(b, '/device/items')).json().items[0];
|
||||
await f.call(a, '/access/delete-batch', 'POST', { ids: [sender.accessId] });
|
||||
const d = (
|
||||
await f.call(b, '/access/delete-batch', 'POST', { ids: [receiver.accessId] })
|
||||
).json();
|
||||
await f.service.storage.cleanup();
|
||||
assert.equal((await readdir(f.service.storage.originals)).length, 1);
|
||||
f.service.db
|
||||
.update(s.deletions)
|
||||
.set({ undoUntil: Date.now() - 1 })
|
||||
.where(eq(s.deletions.id, d.operationId))
|
||||
.run();
|
||||
assert.equal(
|
||||
(await f.call(b, `/deletion-operations/${d.operationId}/undo`, 'POST', {})).statusCode,
|
||||
409,
|
||||
);
|
||||
f.service.db
|
||||
.update(s.access)
|
||||
.set({ deletedAt: Date.now() - 6000 })
|
||||
.run();
|
||||
f.service.cleanup();
|
||||
await f.service.storage.cleanup();
|
||||
assert.equal((await readdir(f.service.storage.originals)).length, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('Phase 1 자료가 있는 DB를 Phase 2로 마이그레이션해도 본문과 접근권 유지', async () => {
|
||||
const folder = await mkdtemp(join(tmpdir(), 'hop-migrate-'));
|
||||
try {
|
||||
const path = join(folder, 'old.sqlite'),
|
||||
old = new Database(path);
|
||||
const migrations = readMigrationFiles({
|
||||
migrationsFolder: join(import.meta.dirname, '../drizzle'),
|
||||
});
|
||||
old.exec(
|
||||
'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT,hash TEXT NOT NULL,created_at INTEGER)',
|
||||
);
|
||||
for (const migration of migrations.slice(0, 2)) {
|
||||
for (const sql of migration.sql) old.exec(sql);
|
||||
old
|
||||
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
|
||||
.run(migration.hash, migration.folderMillis);
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const id of ['a', 'b'])
|
||||
old
|
||||
.prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)')
|
||||
.run(id, id, now + 86400000, now);
|
||||
old
|
||||
.prepare(
|
||||
'INSERT INTO transfer_requests(id,mode,senderGuestId,recipientGuestId,kind,byteSize,count,status,createdAt,expiresAt) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run('request', 'local', 'a', 'b', 'text', 4, 1, 'accepted', now, now + 60000);
|
||||
old
|
||||
.prepare('INSERT INTO transfer_batches(id,requestId,status) VALUES (?,?,?)')
|
||||
.run('batch', 'request', 'stored');
|
||||
old
|
||||
.prepare('INSERT INTO items(id,batchId,kind,text,createdAt,expiresAt) VALUES (?,?,?,?,?,?)')
|
||||
.run('item', 'batch', 'text', 'keep', now, now + 86400000);
|
||||
old
|
||||
.prepare('INSERT INTO item_access(id,itemId,guestId,role,read) VALUES (?,?,?,?,?)')
|
||||
.run('access', 'item', 'b', 'recipient', 0);
|
||||
old.close();
|
||||
const { sqlite } = openDatabase(path);
|
||||
assert.equal(
|
||||
(sqlite.prepare('SELECT text FROM items WHERE id=?').get('item') as any).text,
|
||||
'keep',
|
||||
);
|
||||
assert.equal((sqlite.prepare('SELECT count(*) AS n FROM item_access').get() as any).n, 1);
|
||||
assert.equal(sqlite.pragma('foreign_key_check').length, 0);
|
||||
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
|
||||
sqlite.close();
|
||||
} finally {
|
||||
await rm(folder, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('업로드 확정 재시도는 같은 키와 바이트에만 동일 결과를 반환', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
const id = await fileRequest(f, a, b, [{ name: 'retry.txt', size: 4, type: 'text/plain' }]),
|
||||
key = randomUUID();
|
||||
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
|
||||
const first = await upload(f, a, id, 'retry.txt', Buffer.from('same'), 0, key),
|
||||
second = await upload(f, a, id, 'retry.txt', Buffer.from('same'), 0, key);
|
||||
assert.equal(first.statusCode, 200);
|
||||
assert.deepEqual(first.json(), second.json());
|
||||
assert.equal(
|
||||
(await upload(f, a, id, 'retry.txt', Buffer.from('diff'), 0, key)).statusCode,
|
||||
409,
|
||||
);
|
||||
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('실제 HTTP 업로드 중단 시 임시 파일과 해당 파일 예약을 회수', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
const id = await fileRequest(f, a, b, [
|
||||
{ name: 'abort.bin', size: 1048576, type: 'application/octet-stream' },
|
||||
]);
|
||||
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
|
||||
const { Readable } = await import('node:stream');
|
||||
const controller = new AbortController(),
|
||||
boundary = 'hop-abort';
|
||||
async function* bytes() {
|
||||
yield Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="abort.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`,
|
||||
);
|
||||
yield Buffer.alloc(65536);
|
||||
while (!controller.signal.aborted) await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
const pending = fetch(f.url + `/api/uploads?requestId=${id}&fileIndex=0`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Origin: 'http://hop.test',
|
||||
Cookie: a.cookie,
|
||||
'X-CSRF-Token': a.csrf,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
body: Readable.from(bytes()),
|
||||
duplex: 'half',
|
||||
signal: controller.signal,
|
||||
} as any).catch(() => null);
|
||||
for (let i = 0; i < 100 && f.service.storage.fileRows(id)[0]?.status !== 'uploading'; i++)
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(f.service.storage.fileRows(id)[0]!.status, 'uploading');
|
||||
controller.abort();
|
||||
await pending;
|
||||
for (let i = 0; i < 100 && f.service.storage.fileRows(id)[0]?.status !== 'failed'; i++)
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(f.service.storage.fileRows(id)[0]!.status, 'failed');
|
||||
assert.equal((await readdir(f.service.storage.temp)).length, 0);
|
||||
assert.equal(f.service.db.select().from(s.reservations).all().length, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user