import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { fixture } from './helpers.js'; import * as s from '../src/db/schema.js'; import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; async function signed(f, client, email) { const id = randomUUID(); 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 login = await f.call(client, '/auth/login', 'POST', { email, password: '12345678' }); assert.equal(login.statusCode, 200, login.body); client.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0]; return id; } async function upload(f, client, id, bytes) { const boundary = 'hop-vault-test'; const payload = 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`), ]); return f.call(client, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), { 'content-type': `multipart/form-data; boundary=${boundary}`, }); } function limits(f) { f.cfg.sessionRate = 30; f.cfg.pairRate = 30; f.cfg.ipRate = 60; } test('개인 보관함은 임시 저장·받은 파일을 합산하고 초과 수신은 다운로드만 유지한다', async () => { const f = await fixture(); try { limits(f); f.service.admin.state.accountBytes = 7; const sender = await f.create(), receiver = await f.create(), outsider = await f.create(); await f.space(sender, receiver); await signed(f, receiver, 'quota@hop.test'); await signed(f, outsider, 'other-quota@hop.test'); const saved = ( await f.call( receiver, '/vault', 'POST', { kind: 'file', files: [{ name: 'saved.txt', size: 4, type: 'text/plain' }], }, randomUUID(), ) ).json(); assert.equal((await upload(f, receiver, saved.requestId, Buffer.from('save'))).statusCode, 200); async function received(bytes) { const r = await f.call( sender, '/transfer-requests', 'POST', { targetDeviceId: receiver.deviceId, kind: 'file', byteSize: bytes.length, count: 1, files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }], }, randomUUID(), ); const id = r.json().requestId; assert.equal( (await f.call(receiver, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())) .statusCode, 200, ); assert.equal((await upload(f, sender, id, bytes)).statusCode, 200); return id; } const skipped = await received(Buffer.from('full')); assert.equal((await f.call(receiver, '/inbox')).json().items.length, 0); const guestItem = (await f.call(receiver, '/device/items')) .json() .items.find((item) => item.file?.size === 4 && item.role === 'recipient'); assert.deepEqual( (await f.call(receiver, `/access/${guestItem.accessId}/download`)).rawPayload, Buffer.from('full'), ); await received(Buffer.from('fit')); assert.equal((await f.call(receiver, '/account/storage')).json().usedBytes, 7); assert.equal((await f.call(outsider, '/account/storage')).json().usedBytes, 0); const own = (await f.call(receiver, '/vault')).json().items[0]; const deleted = ( await f.call(receiver, '/access/delete-batch', 'POST', { ids: [own.accessId] }) ).json(); assert.equal((await f.call(receiver, '/account/storage')).json().usedBytes, 3); const replacement = ( await f.call( receiver, '/vault', 'POST', { kind: 'file', files: [{ name: 'saved.txt', size: 4, type: 'text/plain' }], }, randomUUID(), ) ).json(); assert.equal( (await upload(f, receiver, replacement.requestId, Buffer.from('more'))).statusCode, 200, ); assert.equal( (await f.call(receiver, `/deletion-operations/${deleted.operationId}/undo`, 'POST', {})) .statusCode, 413, ); } finally { await f.close(); } }); test('기기 수신은 로그인 계정의 날짜 기록에만 자동 저장, 익명 과거 자료와 로그아웃 후 도착은 가져오지 않음', async () => { const f = await fixture(); try { limits(f); const a = await f.create(), b = await f.create(); await f.space(a, b); await f.complete(a, b, 'https://example.com/before'); await signed(f, b, 'receiver@hop.test'); assert.equal((await f.call(b, '/inbox')).json().items.length, 0); await f.complete(a, b, 'https://example.com/after'); const item = (await f.call(b, '/inbox')).json().items[0]; assert.equal(item.url, 'https://example.com/after'); assert.equal(item.role, 'recipient'); assert.equal((await f.call(b, '/vault')).json().items.length, 0); assert(item.expiresAt - item.createdAt <= 7 * 86400000); const guestItem = (await f.call(b, '/device/items')) .json() .items.find((i) => i.url === item.url); await f.call(b, `/access/${guestItem.accessId}/read`, 'PATCH', { read: true }); assert.equal((await f.call(b, '/inbox')).json().items[0].read, true); const request = (await f.request(a, b, 'https://example.com/logged-out')).json().requestId; await f.call(b, `/transfer-requests/${request}/accept`, 'POST', {}, randomUUID()); await f.call(b, '/auth/logout', 'POST', {}); await f.call( a, `/transfers/${request}/complete`, 'POST', { urls: ['https://example.com/logged-out'] }, randomUUID(), ); const login = await f.call(b, '/auth/login', 'POST', { email: 'receiver@hop.test', password: '12345678', }); b.cookie = b.cookie.split('; hop_account=')[0] + '; ' + String(login.headers['set-cookie']).split(';')[0]; assert.equal((await f.call(b, '/inbox')).json().items.length, 1); } finally { await f.close(); } }); test('임시 링크 보관: 메모, 7일, 중복 방지, 타인 접근 차단과 이메일 전송 제거', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); await signed(f, a, 'owner@hop.test'); await signed(f, b, 'other@hop.test'); const key = randomUUID(), input = { kind: 'url', urls: ['https://example.com/saved'], note: '나중에 읽을 글' }; const result = await f.call(a, '/vault', 'POST', input, key); assert.equal(result.statusCode, 200, result.body); assert.equal( (await f.call(a, '/vault', 'POST', input, key)).json().requestId, result.json().requestId, ); const list = (await f.call(a, '/vault')).json().items; assert.equal(list.length, 1); const item = list[0]; assert.equal(item.note, input.note); assert.equal(item.expiresAt - item.createdAt, 7 * 86400000); assert.equal((await f.call(a, '/inbox')).json().items.length, 0); assert.equal( (await f.call(b, `/vault/${item.accessId}/note`, 'PATCH', { note: 'stolen' })).statusCode, 404, ); assert.equal( (await f.call(a, `/vault/${item.accessId}/note`, 'PATCH', { note: '새 메모' })).statusCode, 200, ); assert.equal((await f.call(a, '/vault?search=새')).json().items.length, 1); assert.equal( (await f.call(a, '/vault', 'POST', { ...input, note: '줄\n바꿈' }, randomUUID())).statusCode, 400, ); assert.equal( (await f.call(a, '/vault', 'POST', { kind: 'file', files: [] }, randomUUID())).statusCode, 400, ); assert.equal( ( await f.call( a, '/vault', 'POST', { kind: 'url', urls: ['javascript:alert(1)'] }, randomUUID(), ) ).statusCode, 400, ); assert.equal( ( await f.call( a, '/transfer-requests', 'POST', { mode: 'email', email: 'other@hop.test', kind: 'url', count: 1, byteSize: 1 }, randomUUID(), ) ).statusCode, 410, ); } finally { await f.close(); } }); test('보관함 링크 전송은 수락 전 비공개이며 수락 후 서버에서 전달, 삭제된 원본과 계정 세션 변경은 차단', async () => { const f = await fixture(); try { limits(f); const a = await f.create(), b = await f.create(), c = await f.create(); await signed(f, a, 'owner@hop.test'); await signed(f, b, 'receiver@hop.test'); await signed(f, c, 'other@hop.test'); await f.space(a, b); await f.call( a, '/vault', 'POST', { kind: 'url', urls: ['https://example.com/private'] }, randomUUID(), ); const item = (await f.call(a, '/vault')).json().items[0]; assert.equal( ( await f.call( c, `/vault/${item.accessId}/send`, 'POST', { targetDeviceId: b.deviceId }, randomUUID(), ) ).statusCode, 404, ); const sent = await f.call( a, `/vault/${item.accessId}/send`, 'POST', { targetDeviceId: b.deviceId }, randomUUID(), ); assert.equal(sent.statusCode, 200, sent.body); assert.equal((await f.call(b, '/inbox')).json().items.length, 0); const requests = (await f.call(b, '/transfer-requests')).json().requests; assert(!JSON.stringify(requests).includes(item.url)); assert.equal( ( await f.call( b, `/transfer-requests/${sent.json().requestId}/accept`, 'POST', {}, randomUUID(), ) ).statusCode, 200, ); assert.equal((await f.call(b, '/inbox')).json().items[0].url, item.url); const next = await f.call( a, `/vault/${item.accessId}/send`, 'POST', { targetDeviceId: b.deviceId }, randomUUID(), ); await f.call(a, '/access/delete-batch', 'POST', { ids: [item.accessId] }); assert.equal( ( await f.call( b, `/transfer-requests/${next.json().requestId}/accept`, 'POST', {}, randomUUID(), ) ).statusCode, 404, ); f.service.cleanup(); assert.equal((await f.call(b, '/inbox')).json().items.length, 1); await f.call(a, `/transfer-requests/${next.json().requestId}/cancel`, 'POST', {}); await f.call( a, '/vault', 'POST', { kind: 'url', urls: ['https://example.com/session'] }, randomUUID(), ); const another = (await f.call(a, '/vault')).json().items[0]; const revokedSource = await f.call( a, `/vault/${another.accessId}/send`, 'POST', { targetDeviceId: b.deviceId }, randomUUID(), ); await f.call(a, '/auth/logout', 'POST', {}); assert.equal( ( await f.call( b, `/transfer-requests/${revokedSource.json().requestId}/accept`, 'POST', {}, randomUUID(), ) ).statusCode, 401, ); assert.equal((await f.call(b, '/inbox')).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 signed(f, a, 'owner@hop.test'); await signed(f, b, 'receiver@hop.test'); await f.space(a, b); const bytes = Buffer.from('stored vault file'); const saved = await f.call( a, '/vault', 'POST', { kind: 'file', files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }], note: '업무 자료', }, randomUUID(), ); assert.equal(saved.statusCode, 200, saved.body); assert.equal((await upload(f, a, saved.json().requestId, bytes)).statusCode, 200); const item = (await f.call(a, '/vault')).json().items[0]; assert.equal(item.note, '업무 자료'); const sent = await f.call( a, `/vault/${item.accessId}/send`, 'POST', { targetDeviceId: b.deviceId }, randomUUID(), ); assert.equal(sent.statusCode, 200, sent.body); assert.equal( ( await f.call( b, `/transfer-requests/${sent.json().requestId}/accept`, 'POST', {}, randomUUID(), ) ).statusCode, 200, ); const received = (await f.call(b, '/inbox')).json().items[0]; assert.equal(received.file.id, item.file.id); assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes); assert.equal((await f.call(a, '/account/storage')).json().usedBytes, bytes.length); const file = f.service.db.select().from(s.files).where(eq(s.files.id, item.file.id)).get(); f.service.db .update(s.items) .set({ expiresAt: Date.now() - 1 }) .where(eq(s.items.id, item.id)) .run(); f.service.cleanup(); await f.service.storage.cleanup(); assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes); f.service.db .update(s.items) .set({ expiresAt: Date.now() - 1 }) .run(); f.service.cleanup(); await f.service.storage.cleanup(); assert.equal((await f.call(b, `/access/${received.accessId}/download`)).statusCode, 404); assert.equal(existsSync(resolve(f.service.storage.originals, file.storageKey)), false); assert.equal(f.service.db.select().from(s.items).all().length, 0); f.service.db .update(s.requests) .set({ createdAt: Date.now() - 8 * 86400000, note: '만료된 메모', manifest: '[]' }) .where(eq(s.requests.id, saved.json().requestId)) .run(); f.service.cleanup(); const expired = f.service.db .select() .from(s.requests) .where(eq(s.requests.id, saved.json().requestId)) .get(); assert.equal(expired.note, null); assert.equal(expired.manifest, null); } finally { await f.close(); } }); test('보관함 콘텐츠에는 직접 저장과 받은 항목이 포함되며 자기 수신 기록도 같은 방 기기에 전달 가능', async () => { const f = await fixture(); try { limits(f); const a = await f.create(), b = await f.create(), c = await f.create(); await signed(f, a, 'a@hop.test'); await signed(f, b, 'b@hop.test'); await signed(f, c, 'c@hop.test'); await f.space(a, b); await f.space(a, c); await f.complete(a, b, 'https://example.com/received-source'); await f.call( b, '/vault', 'POST', { kind: 'url', urls: ['https://example.com/saved-source'] }, randomUUID(), ); const sources = (await f.call(b, '/vault/contents')).json().items; assert.equal(sources.length, 2); assert(sources.some((item) => item.role === 'recipient')); assert(sources.some((item) => item.role === 'self')); const received = sources.find((item) => item.role === 'recipient'); const request = await f.call( b, `/vault/${received.accessId}/send`, 'POST', { targetDeviceId: c.deviceId }, randomUUID(), ); assert.equal(request.statusCode, 200, request.body); assert.equal( ( await f.call( c, `/transfer-requests/${request.json().requestId}/accept`, 'POST', {}, randomUUID(), ) ).statusCode, 200, ); assert.equal( (await f.call(c, '/inbox')).json().items[0].url, 'https://example.com/received-source', ); assert.equal((await f.call(a, '/vault/contents')).json().items.length, 0); } finally { await f.close(); } });