import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { io, type Socket } from 'socket.io-client'; import { eq } from 'drizzle-orm'; import { createApp } from '../src/app.js'; import { loadConfig } from '../src/config.js'; import * as schema from '../src/db/schema.js'; import { clientIp } from '../src/security.js'; import { fixture, urlFor } from './helpers.js'; test('익명 이름과 비밀 쿠키가 유지되고 공개 ID는 인증이 아니다', async () => { const f = await fixture(); try { const a = await f.create(); const changed = await f.call(a, '/device/me', 'PATCH', { name: '나의 Mac' }); assert.equal(changed.statusCode, 200); const again = await f.call(a, '/guest/session', 'POST', {}); assert.equal(again.json().device.name, '나의 Mac'); assert.equal(again.json().device.id, a.deviceId); assert.equal((await f.call(null, '/device/me')).statusCode, 401); assert.equal( (await f.call({ ...a, cookie: `hop_guest=${a.deviceId}` }, '/device/me')).statusCode, 401, ); assert.match(String(again.headers['set-cookie']), /HttpOnly/); assert.match(String(again.headers['set-cookie']), /SameSite=Lax/); } finally { await f.close(); } }); test('Origin, CSRF, HTML 이름 검증', async () => { const f = await fixture(); try { const a = await f.create(); assert.equal((await f.call(a, '/device/me', 'PATCH', { name: ''); const received = (await f.call(b, '/device/items')).json().items[0]; assert.equal(received.url, urlFor('안녕\n')); assert.equal(received.read, false); assert.equal( (await f.call(c, `/access/${received.accessId}/read`, 'PATCH', { read: true })).statusCode, 404, ); await f.call(b, `/access/${received.accessId}/read`, 'PATCH', { read: true }); assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, true); await f.call(b, `/access/${received.accessId}/read`, 'PATCH', { read: false }); assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, false); } finally { await f.close(); } }); test('다중 URL을 개별 항목으로 저장하고 내부 URL 전달을 허용', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); await f.space(a, b); const urls = ['https://example.com/', 'http://192.168.1.10/']; const response = await f.request(a, b, urls.join('\n'), 'url', 2); const id = response.json().requestId; await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); const result = await f.call(a, `/transfers/${id}/complete`, 'POST', { urls }, randomUUID()); assert.equal(result.statusCode, 200); assert.equal((await f.call(b, '/device/items')).json().items.length, 2); } finally { await f.close(); } }); test('URL 스크립트 프로토콜 및 승인 메타데이터 변경 차단', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); await f.space(a, b); const r = await f.request(a, b, 'https://example.com/', 'url', 1); const id = r.json().requestId; await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); assert.equal( ( await f.call( a, `/transfers/${id}/complete`, 'POST', { urls: ['javascript:alert(1)'] }, randomUUID(), ) ).statusCode, 400, ); assert.equal( ( await f.call( a, `/transfers/${id}/complete`, 'POST', { urls: ['https://example.com/longer'] }, randomUUID(), ) ).statusCode, 409, ); } 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 key = randomUUID(), text = 'repeat'; const one = await f.request(a, b, text, 'url', 1, key), two = await f.request(a, b, text, 'url', 1, key); assert.equal(one.json().requestId, two.json().requestId); assert.equal((await f.request(a, b, 'different', 'url', 1, key)).statusCode, 409); const id = one.json().requestId, acceptKey = randomUUID(), completeKey = randomUUID(); assert.equal( (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, acceptKey)).statusCode, 200, ); assert.equal( (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, acceptKey)).statusCode, 200, ); await f.call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, completeKey); await f.call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, completeKey); 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(), c = await f.create(); await f.space(a, b); const id = (await f.request(a, b)).json().requestId; assert.equal( (await f.call(a, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, 403, ); assert.equal( (await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, 404, ); await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); assert.equal( ( await f.call( b, `/transfers/${id}/complete`, 'POST', { urls: [urlFor('안녕하세요')] }, randomUUID(), ) ).statusCode, 403, ); } 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 f.request(a, b)).json().requestId; await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {}); assert.equal( ( await f.call( a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor('안녕하세요')] }, randomUUID(), ) ).statusCode, 409, ); for (let i = 0; i < 2; i++) { const next = await f.request(a, b); assert.equal(next.statusCode, 200); await f.call(b, `/transfer-requests/${next.json().requestId}/reject`, 'POST', {}); } assert.equal((await f.request(a, b)).statusCode, 429); const c = await f.create(); await f.space(a, c); const second = (await f.request(a, c)).json().requestId; f.service.db .update(schema.requests) .set({ expiresAt: Date.now() - 1 }) .where(eq(schema.requests.id, second)) .run(); assert.equal( (await f.call(c, `/transfer-requests/${second}/accept`, 'POST', {}, randomUUID())).statusCode, 409, ); f.service.cleanup(); assert.equal( f.service.db.select().from(schema.requests).where(eq(schema.requests.id, second)).get()! .status, 'expired', ); } 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 first = b.socket!; const second = await f.connect(b); first.disconnect(); await new Promise((r) => setTimeout(r, 35)); assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1); const id = (await f.request(a, b)).json().requestId; const disconnected = new Promise((resolve) => { f.service.io!.sockets.sockets.get(second.id!)!.once('disconnect', () => resolve()); }); second.disconnect(); await disconnected; await new Promise((r) => setTimeout(r, f.cfg.offlineGraceMs + 15)); assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0); assert.equal( (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, 409, ); } finally { await f.close(); } }); test('방 종료 시 진행 중 수락 전송도 취소되며 재연결로 확정할 수 없음', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); const invite = await f.space(a, b); const id = (await f.request(a, b)).json().requestId; await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); await f.call(a, `/spaces/${invite.space.id}`, 'DELETE'); b.socket!.disconnect(); await new Promise((r) => setTimeout(r, 35)); assert.equal( ( await f.call( a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor('안녕하세요')] }, randomUUID(), ) ).statusCode, 409, ); await f.connect(b); assert.equal((await f.call(b, '/device/items')).json().items.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 f.request(a, b)).json().requestId; await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); assert.equal((await f.call(b, `/transfer-requests/${id}/block`, 'POST', {})).statusCode, 200); assert.equal( ( await f.call( a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor('안녕하세요')] }, randomUUID(), ) ).statusCode, 409, ); assert.equal((await f.call(a, '/discovery/devices')).json().devices.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); await f.complete(a, b); const response = await f.call(b, '/device/reset', 'POST', {}); assert.equal(response.statusCode, 200); assert.equal((await f.call(b, '/device/items')).statusCode, 401); const next = response.json(); const fresh = { ...b, cookie: String(response.headers['set-cookie']).split(';')[0]!, csrf: next.csrfToken, deviceId: next.device.id, }; assert.equal((await f.call(fresh, '/device/items')).json().items.length, 0); assert.equal((await f.call(a, '/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); await f.complete(a, b); f.service.db .update(schema.items) .set({ expiresAt: Date.now() - 1 }) .run(); assert.equal((await f.call(b, '/device/items')).json().items.length, 0); f.service.cleanup(); assert.equal(f.service.db.select().from(schema.items).all().length, 0); } finally { await f.close(); } }); test('코드 대입 횟수 제한과 공간 만료 적용', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); const invite = (await f.call(a, '/spaces', 'POST', {})).json(); f.service.db .update(schema.spaces) .set({ expiresAt: Date.now() - 1 }) .run(); assert.equal((await f.call(b, '/spaces/join', 'POST', { code: invite.code })).statusCode, 404); for (let i = 0; i < 10; i++) await f.call(b, '/spaces/join', 'POST', { code: 'ABCDEFGH' }); assert.equal((await f.call(b, '/spaces/join', 'POST', { code: 'ABCDEFGH' })).statusCode, 429); } finally { await f.close(); } }); test('임의 X-Forwarded-For는 신뢰하지 않고 명시된 프록시 체인만 해석', () => { assert.equal(clientIp('8.8.8.8', { 'x-forwarded-for': '1.1.1.1' }, []), '8.8.8.8'); assert.equal(clientIp('127.0.0.1', { 'x-forwarded-for': '1.1.1.1' }, ['127.0.0.1']), '1.1.1.1'); assert.equal(clientIp('127.0.0.1', {}, ['127.0.0.1']), null); assert.equal(clientIp('127.0.0.1', { 'x-forwarded-for': 'bogus' }, ['127.0.0.1']), null); assert.equal( clientIp('127.0.0.1', { 'x-forwarded-for': '8.8.8.8, 1.1.1.1' }, ['127.0.0.1']), '1.1.1.1', ); }); test('텍스트 요청은 한 항목만 허용하고 URL 요청에 다른 본문 타입을 확정하지 않는다', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); await f.space(a, b); assert.equal( ( await f.call( a, '/transfer-requests', 'POST', { targetDeviceId: b.deviceId, kind: 'text', byteSize: 5, count: 2 }, randomUUID(), ) ).statusCode, 400, ); const id = (await f.request(a, b, 'https://example.com/')).json().requestId; await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); assert.equal( (await f.call(a, `/transfers/${id}/complete`, 'POST', { text: 'hello' }, randomUUID())) .statusCode, 400, ); assert.equal((await f.call(b, '/device/snapshot')).json().items.length, 0); assert.equal( ( await f.call( a, `/transfers/${id}/complete`, 'POST', { urls: ['https://example.com/'] }, randomUUID(), ) ).statusCode, 200, ); const snapshot = (await f.call(b, '/device/snapshot')).json(); assert.equal(snapshot.items[0].kind, 'url'); assert.equal(snapshot.items[0].url, 'https://example.com/'); assert.equal('text' in snapshot.items[0], false); } finally { await f.close(); } });