Files
hop.noname.company/apps/server/test/phase2.test.ts

412 lines
16 KiB
TypeScript

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