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