558 lines
18 KiB
TypeScript
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();
|
|
}
|
|
});
|