412 lines
16 KiB
TypeScript
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();
|
|
}
|
|
});
|