Files
hop.noname.company/apps/server/test/phase1.test.ts

558 lines
18 KiB
TypeScript

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: '<script>' })).statusCode, 400);
assert.equal(
(
await f.call(a, '/device/me', 'PATCH', { name: '테스트' }, undefined, {
origin: 'https://evil.test',
})
).statusCode,
403,
);
assert.equal(
(
await f.call(a, '/device/me', 'PATCH', { name: '테스트' }, undefined, {
'x-csrf-token': 'bad',
})
).statusCode,
403,
);
} finally {
await f.close();
}
});
test('검증 전 네트워크 탐색은 꺼지고 QR 코드로 다른 IP 연결', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create('198.51.100.2'),
c = await f.create('198.51.100.3');
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal((await f.request(a, b)).statusCode, 403);
await f.space(a, b);
const peers = (await f.call(a, '/discovery/devices')).json().devices;
assert.deepEqual(
peers.map((d: any) => d.id),
[b.deviceId],
);
assert.deepEqual(peers[0].sources, ['space']);
assert.equal((await f.request(a, c)).statusCode, 403);
} finally {
await f.close();
}
});
test('같은 공인 IP만 발견하고 다른 IP 기기에는 전송 금지', async () => {
const f = await fixture(true);
try {
const a = await f.create('8.8.8.8'),
b = await f.create('8.8.8.8'),
c = await f.create('1.1.1.1');
assert.deepEqual(
(await f.call(a, '/discovery/devices')).json().devices.map((d: any) => d.id),
[b.deviceId],
);
assert.equal((await f.request(a, c)).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);
await f.call(b, '/device/me', 'PATCH', { discoverable: false });
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal((await f.request(a, b)).statusCode, 403);
} finally {
await f.close();
}
});
test('승인 전 참여자는 발견되지 않으며 생성자만 승인 가능', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
const invite = await f.space(a, b, true);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal(
(await f.call(c, `/spaces/${invite.space.id}/members/${b.deviceId}/approve`, 'POST', {}))
.statusCode,
404,
);
assert.equal(
(await f.call(a, `/spaces/${invite.space.id}/members/${b.deviceId}/approve`, 'POST', {}))
.statusCode,
200,
);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1);
} finally {
await f.close();
}
});
test('공간 퇴장, 재가입 거절, 폐쇄, QR 토큰 참여', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
const invite = (await f.call(a, '/spaces', 'POST', {})).json();
assert.equal(
(await f.call(b, '/spaces/join', 'POST', { token: invite.token })).statusCode,
200,
);
assert.equal(
(await f.call(a, `/spaces/${invite.space.id}/members/${b.deviceId}`, 'DELETE')).statusCode,
200,
);
assert.equal((await f.request(a, b)).statusCode, 403);
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: invite.code })).statusCode, 403);
assert.equal((await f.call(a, `/spaces/${invite.space.id}`, 'DELETE')).statusCode, 200);
assert.equal(
(await f.call(b, '/spaces/join', 'POST', { token: invite.token })).statusCode,
404,
);
} 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 response = await f.request(a, b, '비밀 본문');
assert.equal(response.statusCode, 200);
const id = response.json().requestId;
assert.equal(
JSON.stringify((await f.call(b, '/transfer-requests')).json()).includes('비밀 본문'),
false,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('비밀 본문')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
} finally {
await f.close();
}
});
test('수락 이후 실제 URL 저장, 수신 접근권 격리, 읽음 공유', 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, '안녕\n<script>alert(1)</script>');
const received = (await f.call(b, '/device/items')).json().items[0];
assert.equal(received.url, urlFor('안녕\n<script>alert(1)</script>'));
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,
);
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;
second.disconnect();
await new Promise((r) => setTimeout(r, 35));
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: 1 },
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();
}
});