import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { unlink, chmod } from 'node:fs/promises'; import { unlinkSync } from 'node:fs'; import { resolve } from 'node:path'; import { eq } from 'drizzle-orm'; import { fixture } from './helpers.js'; import * as s from '../src/db/schema.js'; async function signIn(f, client) { const id = randomUUID(), email = `${id}@hop.test`; f.service.db .insert(s.users) .values({ id, email, displayName: email, passwordHash: await f.service.auth.password('12345678'), status: 'active', verifiedAt: Date.now(), createdAt: Date.now(), }) .run(); const response = await f.call(client, '/auth/login', 'POST', { email, password: '12345678' }); assert.equal(response.statusCode, 200, response.body); client.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0]; client.socket.disconnect(); await f.connect(client); } async function save(f, client, bytes = Buffer.from('test')) { const request = await f.call( client, '/vault', 'POST', { kind: 'file', files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }], }, randomUUID(), ); assert.equal(request.statusCode, 200, request.body); const boundary = 'missing-file-test'; const body = Buffer.concat([ Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="saved.txt"\r\nContent-Type: text/plain\r\n\r\n`, ), bytes, Buffer.from(`\r\n--${boundary}--\r\n`), ]); const response = await f.call( client, `/uploads?requestId=${request.json().requestId}&fileIndex=0`, 'POST', body, randomUUID(), { 'content-type': `multipart/form-data; boundary=${boundary}` }, ); assert.equal(response.statusCode, 200, response.body); const row = f.service.storage.fileRows(request.json().requestId)[0]; const item = (await f.call(client, '/vault')) .json() .items.find((item) => item.file?.id === row.id); return { file: row, item, path: resolve(f.service.storage.originals, row.storageKey) }; } async function forward(f, sender, receiver, item, accept = true) { const response = await f.call( sender, `/vault/${item.accessId}/send`, 'POST', { targetDeviceId: receiver.deviceId }, randomUUID(), ); assert.equal(response.statusCode, 200, response.body); const id = response.json().requestId; if (accept) assert.equal( (await f.call(receiver, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())) .statusCode, 200, ); return id; } function limits(f) { f.cfg.sessionRate = 60; f.cfg.pairRate = 60; f.cfg.ipRate = 100; } for (const trigger of ['download', 'list', 'quota', 'cleanup']) { test(`수동 삭제(${trigger}): 공유 원본의 모든 기록·개인 용량 정리와 정상 파일/링크·페이지 유지`, async () => { const f = await fixture(); try { limits(f); const a = await f.create(), b = await f.create(), outsider = await f.create(); await f.space(a, b); await signIn(f, a); await signIn(f, b); const healthy = await save(f, a, Buffer.from('healthy')); const link = await f.call( a, '/vault', 'POST', { kind: 'url', urls: ['https://example.com/healthy'] }, randomUUID(), ); assert.equal(link.statusCode, 200, link.body); const missing = await save(f, a); await forward(f, a, b, missing.item); const received = (await f.call(b, '/inbox')).json().items[0]; assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 11); assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 4); await unlink(missing.path); const foreign = await f.call(outsider, `/access/${missing.item.accessId}/download`); assert(foreign.statusCode === 401 || foreign.statusCode === 404); assert.equal( f.service.db.select().from(s.files).where(eq(s.files.id, missing.file.id)).get().status, 'stored', '무권한 접근으로 정리/존재 확인을 유발하지 않음', ); if (trigger === 'download') { const response = await f.call(b, `/access/${received.accessId}/download`); assert.equal(response.statusCode, 404, response.body); assert.match(response.json().message, /다시 전송/); } else if (trigger === 'list') { await f.call(a, '/vault'); } else if (trigger === 'quota') { assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0); } else { const update = new Promise((resolve, reject) => { const timer = setTimeout(() => reject(Error('missing notification')), 2000); b.socket.once('inbox:updated', () => { clearTimeout(timer); resolve(); }); }); await f.service.storage.cleanup(); await update; } assert.equal((await f.call(b, '/inbox')).json().items.length, 0); assert.equal((await f.call(b, '/device/items')).json().items.length, 0); assert.equal( (await f.call(a, '/vault/contents')) .json() .items.some((i) => i.file?.id === missing.file.id), false, ); assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 7); assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0); const page = (await f.call(a, '/vault?limit=1')).json(); assert.equal(page.items.length, 1); assert(page.nextCursor); const next = ( await f.call(a, `/vault?limit=1&cursor=${encodeURIComponent(page.nextCursor)}`) ).json(); assert.equal(next.items.length, 1); assert.equal(next.nextCursor, null); const remaining = [...page.items, ...next.items]; assert(remaining.some((i) => i.file?.id === healthy.file.id)); assert(remaining.some((i) => i.url === 'https://example.com/healthy')); assert.deepEqual( (await f.call(a, `/access/${healthy.item.accessId}/download`)).rawPayload, Buffer.from('healthy'), ); assert.equal((await f.call(a, `/access/${missing.item.accessId}/preview`)).statusCode, 404); f.service.cleanup(); await f.service.storage.cleanup(); assert.equal((await f.call(a, '/vault')).json().items.length, 2); } finally { await f.close(); } }); } for (const action of ['send', 'accept']) { test(`수동 삭제 뒤 ${action} 실패: 외부 멱등 트랜잭션 롤백 후에도 삭제 감지와 대기 전송 취소 유지`, async () => { const f = await fixture(); try { limits(f); const a = await f.create(), b = await f.create(); await f.space(a, b); await signIn(f, a); const missing = await save(f, a); const pending = action === 'accept' ? await forward(f, a, b, missing.item, false) : null; await unlink(missing.path); const result = action === 'accept' ? await f.call(b, `/transfer-requests/${pending}/accept`, 'POST', {}, randomUUID()) : await f.call( a, `/vault/${missing.item.accessId}/send`, 'POST', { targetDeviceId: b.deviceId }, randomUUID(), ); assert.equal(result.statusCode, 404, result.body); assert.equal( f.service.db.select().from(s.files).where(eq(s.files.id, missing.file.id)).get().status, 'failed', ); assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 0); assert.equal((await f.call(a, '/vault')).json().items.length, 0); assert.equal((await f.call(b, '/device/items')).json().items.length, 0); if (pending) { assert.equal( f.service.db.select().from(s.requests).where(eq(s.requests.id, pending)).get().status, 'cancelled', ); assert.equal( f.service.db.select().from(s.batches).where(eq(s.batches.requestId, pending)).get() .status, 'failed', ); } } finally { await f.close(); } }); } test('권한 오류는 삭제로 오인하지 않고 503 안내 후 권한 복구 시 원본·DB·용량 유지', async (t) => { if (process.getuid?.() === 0) return t.skip('root bypasses filesystem permissions'); const f = await fixture(); try { const a = await f.create(); await signIn(f, a); const saved = await save(f, a); await chmod(f.service.storage.originals, 0); const response = await f.call(a, `/access/${saved.item.accessId}/download`); assert.equal(response.statusCode, 503, response.body); assert.equal(response.json().code, 'STORAGE_UNAVAILABLE'); assert.equal( f.service.db.select().from(s.files).where(eq(s.files.id, saved.file.id)).get().status, 'stored', ); await chmod(f.service.storage.originals, 0o700); assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 4); assert.deepEqual( (await f.call(a, `/access/${saved.item.accessId}/download`)).rawPayload, Buffer.from('test'), ); } finally { await chmod(f.service.storage.originals, 0o700); await f.close(); } }); test('메타데이터 확인 직후 원본이 사라져도 다운로드는 500 대신 명확한 404 반환', async () => { const f = await fixture(); try { const a = await f.create(); await signIn(f, a); const saved = await save(f, a); const download = f.service.storage.download.bind(f.service.storage); f.service.storage.download = (...args) => { const result = download(...args); unlinkSync(saved.path); return result; }; const response = await f.call(a, `/access/${saved.item.accessId}/download`); assert.equal(response.statusCode, 404, response.body); assert.equal(response.json().code, 'FILE_NOT_FOUND'); assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 0); } finally { await f.close(); } });