332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
'use strict';
|
|
|
|
import type { EventEmitter as EventEmitterType } from 'node:events';
|
|
import type { Server } from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
import type { TestContext } from 'node:test';
|
|
import type { RawData, WebSocket as WsConnection } from 'ws';
|
|
import { isRecord } from '../src/types';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const { EventEmitter } = require('node:events');
|
|
const fs = require('node:fs/promises');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
const WebSocket = require('ws');
|
|
|
|
const {
|
|
createAuthChecker,
|
|
createChatService,
|
|
inferImageContentType,
|
|
isAllowedRemoteImageUrl,
|
|
normalizeChatConfig
|
|
} = require('../src/server/chat-service');
|
|
|
|
type WsMessage = Record<string, unknown> & {
|
|
items?: Array<Record<string, unknown>>;
|
|
};
|
|
|
|
type WsInbox = {
|
|
next: () => Promise<WsMessage>;
|
|
nextWithin: (timeoutMs: number) => Promise<WsMessage | null>;
|
|
};
|
|
|
|
type TestSteamUser = EventEmitterType & {
|
|
chat: {
|
|
sendFriendMessage: (id: unknown, msg: unknown, callback: (error: Error | null, result?: unknown) => void) => void;
|
|
};
|
|
};
|
|
|
|
type TestSteamCommunity = {
|
|
sendImageToUser: (id: unknown, image: Buffer, filename: string, callback: (error: Error | null, result?: unknown) => void) => void;
|
|
};
|
|
|
|
type ChatServiceRuntime = {
|
|
server: Server;
|
|
stop: () => Promise<void>;
|
|
};
|
|
|
|
type WsConstructor = new (url: string) => WsConnection;
|
|
const WsClient = WebSocket as WsConstructor;
|
|
|
|
function listen(server: Server): Promise<number> {
|
|
return new Promise((resolve) => {
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const address = server.address() as AddressInfo;
|
|
resolve(address.port);
|
|
});
|
|
});
|
|
}
|
|
|
|
function wsOpen(url: string): Promise<{ ws: WsConnection; inbox: WsInbox }> {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WsClient(url);
|
|
const inbox = createWsInbox(ws);
|
|
ws.once('open', () => resolve({ ws, inbox }));
|
|
ws.once('error', reject);
|
|
});
|
|
}
|
|
|
|
function createWsInbox(ws: WsConnection): WsInbox {
|
|
const queue: WsMessage[] = [];
|
|
const waiters: Array<{ resolve: (value: WsMessage) => void }> = [];
|
|
ws.on('message', (data: RawData) => {
|
|
const parsed: unknown = JSON.parse(data.toString());
|
|
const payload: WsMessage = isRecord(parsed) ? parsed : {};
|
|
const waiter = waiters.shift();
|
|
if (waiter) waiter.resolve(payload);
|
|
else queue.push(payload);
|
|
});
|
|
return {
|
|
next() {
|
|
if (queue.length) return Promise.resolve(queue.shift() as WsMessage);
|
|
return new Promise((resolve, reject) => {
|
|
const waiter = {
|
|
resolve(value: WsMessage) {
|
|
clearTimeout(timer);
|
|
resolve(value);
|
|
}
|
|
};
|
|
const timer = setTimeout(() => {
|
|
const index = waiters.indexOf(waiter);
|
|
if (index >= 0) waiters.splice(index, 1);
|
|
reject(new Error('Timed out waiting for WebSocket message'));
|
|
}, 2000);
|
|
waiters.push(waiter);
|
|
});
|
|
},
|
|
nextWithin(timeoutMs: number) {
|
|
if (queue.length) return Promise.resolve(queue.shift() as WsMessage);
|
|
return new Promise((resolve) => {
|
|
const waiter = {
|
|
resolve(value: WsMessage) {
|
|
clearTimeout(timer);
|
|
resolve(value);
|
|
}
|
|
};
|
|
const timer = setTimeout(() => {
|
|
const index = waiters.indexOf(waiter);
|
|
if (index >= 0) waiters.splice(index, 1);
|
|
resolve(null);
|
|
}, timeoutMs);
|
|
waiters.push(waiter);
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
test('normalizeChatConfig accepts boolean shorthand', () => {
|
|
assert.equal(normalizeChatConfig(true).enabled, true);
|
|
assert.equal(normalizeChatConfig({ port: 3100 }).port, 3100);
|
|
});
|
|
|
|
test('image proxy URL guard rejects local and private targets', () => {
|
|
assert.equal(isAllowedRemoteImageUrl('https://example.com/a.png'), true);
|
|
assert.equal(isAllowedRemoteImageUrl('ftp://example.com/a.png'), false);
|
|
assert.equal(isAllowedRemoteImageUrl('http://localhost/a.png'), false);
|
|
assert.equal(isAllowedRemoteImageUrl('http://127.0.0.1/a.png'), false);
|
|
assert.equal(isAllowedRemoteImageUrl('http://192.168.1.2/a.png'), false);
|
|
assert.equal(inferImageContentType('https://x.test/a.webp'), 'image/webp');
|
|
});
|
|
|
|
test('auth checker bypasses local clients and validates proxied public clients with timing safe hashes', () => {
|
|
const auth = createAuthChecker({
|
|
username: 'u',
|
|
password: 'p',
|
|
trustProxy: true
|
|
});
|
|
assert.equal(auth.isAuthorized({
|
|
headers: {},
|
|
socket: { remoteAddress: '127.0.0.1' }
|
|
}), true);
|
|
assert.equal(auth.isAuthorized({
|
|
headers: { 'x-forwarded-for': '8.8.8.8' },
|
|
socket: { remoteAddress: '127.0.0.1' }
|
|
}), false);
|
|
assert.equal(auth.isAuthorized({
|
|
headers: {
|
|
'x-forwarded-for': '8.8.8.8',
|
|
authorization: `Basic ${Buffer.from('u:p').toString('base64')}`
|
|
},
|
|
socket: { remoteAddress: '127.0.0.1' }
|
|
}), true);
|
|
});
|
|
|
|
test('HTTP API sends messages, writes history, and builds conversations', async (t: TestContext) => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-http-'));
|
|
const logPath = path.join(dir, 'chat.jsonl');
|
|
const steamUser = new EventEmitter() as TestSteamUser;
|
|
steamUser.chat = {
|
|
sendFriendMessage(id: unknown, msg: unknown, callback: (error: Error | null, result?: unknown) => void) {
|
|
callback(null, { id, msg });
|
|
}
|
|
};
|
|
const service = createChatService({
|
|
config: { host: '127.0.0.1', port: 0, wsPath: '/ws' },
|
|
steamUser,
|
|
logPath,
|
|
getSelfName: async () => 'Me',
|
|
logger: { info() {}, warn() {}, error() {} }
|
|
}) as ChatServiceRuntime;
|
|
t.after(() => service.stop().catch(() => {}));
|
|
const port = await listen(service.server);
|
|
|
|
const configResponse = await fetch(`http://127.0.0.1:${port}/api/config`);
|
|
assert.deepEqual(await configResponse.json(), { wsPath: '/ws' });
|
|
|
|
const sendResponse = await fetch(`http://127.0.0.1:${port}/message`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: '7656119', msg: 'hello' })
|
|
});
|
|
assert.equal(sendResponse.status, 200);
|
|
const sendPayload = await sendResponse.json();
|
|
assert.equal(sendPayload.item.message, 'hello');
|
|
assert.equal(sendPayload.item.echo, true);
|
|
assert.equal(typeof sendPayload.item.ordinal, 'number');
|
|
assert.equal((await fs.readFile(logPath, 'utf8')).includes('"ordinal":null'), false);
|
|
|
|
const history = await (await fetch(`http://127.0.0.1:${port}/history?id=7656119`)).json();
|
|
assert.equal(history.length, 1);
|
|
assert.equal(history[0].name, 'Me');
|
|
assert.equal(typeof history[0].ordinal, 'number');
|
|
|
|
const conversations = await (await fetch(`http://127.0.0.1:${port}/conversations`)).json();
|
|
assert.equal(conversations[0].id, '7656119');
|
|
assert.equal(conversations[0].preview, 'hello');
|
|
});
|
|
|
|
test('HTTP image API sends images without writing the legacy-incompatible image row', async (t: TestContext) => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-http-image-'));
|
|
const logPath = path.join(dir, 'chat.jsonl');
|
|
const steamUser = new EventEmitter() as TestSteamUser;
|
|
steamUser.chat = {
|
|
sendFriendMessage(id: unknown, msg: unknown, callback: (error: Error | null, result?: unknown) => void) {
|
|
callback(null, { id, msg });
|
|
}
|
|
};
|
|
const steamCommunity: TestSteamCommunity = {
|
|
sendImageToUser(_id: unknown, _image: Buffer, _filename: string, callback: (error: Error | null, result?: unknown) => void) {
|
|
callback(null, {
|
|
url: 'https://images.steamusercontent.com/ugc/example/'
|
|
});
|
|
}
|
|
};
|
|
const service = createChatService({
|
|
config: { host: '127.0.0.1', port: 0, wsPath: '/ws' },
|
|
steamUser,
|
|
steamCommunity,
|
|
logPath,
|
|
getSelfName: async () => 'Me',
|
|
logger: { info() {}, warn() {}, error() {} }
|
|
}) as ChatServiceRuntime;
|
|
t.after(() => service.stop().catch(() => {}));
|
|
const port = await listen(service.server);
|
|
|
|
const encoded = Buffer.from('hello image').toString('base64');
|
|
const sendResponse = await fetch(`http://127.0.0.1:${port}/image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: '7656119', img: `data:image/png;base64,${encoded}` })
|
|
});
|
|
assert.equal(sendResponse.status, 200);
|
|
const sendPayload = await sendResponse.json();
|
|
assert.equal(sendPayload.item.type, 'message');
|
|
assert.equal(sendPayload.item.ordinal, 0);
|
|
|
|
const content = await fs.readFile(logPath, 'utf8').catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'ENOENT') return '';
|
|
throw error;
|
|
});
|
|
assert.equal(content.includes('"type":"image"'), false);
|
|
assert.equal(content.includes('imageUrl'), false);
|
|
assert.equal(content.includes('sentAt'), false);
|
|
assert.equal(content.includes('"ordinal":null'), false);
|
|
assert.equal(content.trim(), '');
|
|
});
|
|
|
|
test('WebSocket sends ready, handles ping, rejects invalid JSON, and supports history requests', async (t: TestContext) => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-ws-'));
|
|
const logPath = path.join(dir, 'chat.jsonl');
|
|
const steamUser = new EventEmitter() as TestSteamUser;
|
|
steamUser.chat = {
|
|
sendFriendMessage(id: unknown, msg: unknown, callback: (error: Error | null, result?: unknown) => void) {
|
|
callback(null);
|
|
}
|
|
};
|
|
const service = createChatService({
|
|
config: { host: '127.0.0.1', port: 0, wsPath: '/ws' },
|
|
steamUser,
|
|
logPath,
|
|
getSelfName: async () => 'Me',
|
|
logger: { info() {}, warn() {}, error() {} }
|
|
}) as ChatServiceRuntime;
|
|
t.after(() => service.stop().catch(() => {}));
|
|
const port = await listen(service.server);
|
|
const { ws, inbox } = await wsOpen(`ws://127.0.0.1:${port}/ws`);
|
|
t.after(() => ws.close());
|
|
|
|
assert.deepEqual(await inbox.next(), { type: 'ready', wsPath: '/ws' });
|
|
|
|
ws.send(JSON.stringify({ type: 'ping', requestId: 'p1' }));
|
|
assert.deepEqual(await inbox.next(), { requestId: 'p1', type: 'pong' });
|
|
|
|
ws.send('bad json');
|
|
assert.deepEqual(await inbox.next(), { type: 'error', error: 'Invalid JSON' });
|
|
|
|
ws.send(JSON.stringify({ type: 'send_message', id: '42', msg: 'via ws', requestId: 'm1' }));
|
|
const sent = await inbox.next();
|
|
assert.equal(sent.type, 'message');
|
|
const receipt = await inbox.next();
|
|
assert.equal(receipt.requestId, 'm1');
|
|
assert.equal(receipt.type, 'message_sent');
|
|
|
|
ws.send(JSON.stringify({ type: 'history', id: '42', requestId: 'h1' }));
|
|
const history = await inbox.next();
|
|
assert.equal(history.requestId, 'h1');
|
|
assert.equal(history.items[0].message, 'via ws');
|
|
});
|
|
|
|
test('WebSocket broadcasts merged chat object events with Steam ordinals', async (t: TestContext) => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-ws-merged-'));
|
|
const logPath = path.join(dir, 'chat.jsonl');
|
|
const steamUser = new EventEmitter() as TestSteamUser;
|
|
const chat = new EventEmitter() as EventEmitterType & TestSteamUser['chat'];
|
|
chat.sendFriendMessage = (_id: unknown, _msg: unknown, callback: (error: Error | null, result?: unknown) => void) => {
|
|
callback(null);
|
|
};
|
|
steamUser.chat = chat;
|
|
const service = createChatService({
|
|
config: { host: '127.0.0.1', port: 0, wsPath: '/ws' },
|
|
steamUser,
|
|
logPath,
|
|
getUserInfo: async () => ({ player_name: 'Alice' }),
|
|
getSelfName: async () => 'Me',
|
|
logger: { info() {}, warn() {}, error() {} }
|
|
}) as ChatServiceRuntime;
|
|
t.after(() => service.stop().catch(() => {}));
|
|
const port = await listen(service.server);
|
|
const { ws, inbox } = await wsOpen(`ws://127.0.0.1:${port}/ws`);
|
|
t.after(() => ws.close());
|
|
|
|
assert.deepEqual(await inbox.next(), { type: 'ready', wsPath: '/ws' });
|
|
|
|
chat.emit('friendMessage', {
|
|
steamid_friend: '42',
|
|
message: '[sticker type="happy" limit="0"][/sticker]',
|
|
message_no_bbcode: 'happy',
|
|
ordinal: 22,
|
|
server_timestamp: new Date(1710000000 * 1000)
|
|
});
|
|
steamUser.emit('friendMessage', '42', 'happy');
|
|
|
|
const event = await inbox.next();
|
|
assert.equal(event.type, 'message');
|
|
assert.equal(event.id, '42');
|
|
assert.equal(event.name, 'Alice');
|
|
assert.equal(event.message, '[sticker type="happy" limit="0"][/sticker]');
|
|
assert.equal(event.ordinal, 22);
|
|
assert.equal(await inbox.nextWithin(50), null);
|
|
});
|