136 lines
8.5 KiB
JavaScript
136 lines
8.5 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { generateKeyPairSync, sign } from 'node:crypto';
|
|
import { once } from 'node:events';
|
|
import { DiscordBot } from '../src/bots/discord.js';
|
|
import { TelegramBot } from '../src/bots/telegram.js';
|
|
import { createGateway } from '../src/server.js';
|
|
import { AccessService } from '../src/bots/service.js';
|
|
import { fixture } from './helpers.js';
|
|
|
|
test('Telegram /start offers Get access; private callback creates request and admin callback delivers number', async () => {
|
|
const f = fixture();
|
|
const calls = [];
|
|
const bot = new TelegramBot(f.config, f.store, { fetchImpl: async (url, options) => {
|
|
calls.push({ method: url.split('/').at(-1), body: JSON.parse(options.body) });
|
|
return Response.json({ ok: true, result: {} });
|
|
} });
|
|
const user = { id: 5, first_name: 'Player', username: 'player', language_code: 'en' };
|
|
await bot.handle({ message: { from: user, chat: { type: 'private' }, text: '/start access' } }, f.service);
|
|
assert.equal(calls[0].body.reply_markup.inline_keyboard[0][0].callback_data, 'request');
|
|
await bot.handle({ callback_query: { id: 'cb1', from: user, message: { chat: { type: 'private' } }, data: 'request' } }, f.service);
|
|
assert.equal(f.store.state.requests[0].user_id, '5');
|
|
assert.equal(f.store.state.requests[0].language_code, 'en');
|
|
const id = f.store.state.requests[0].id;
|
|
await bot.handle({ callback_query: { id: 'cb2', from: { id: 100 }, message: { chat: { type: 'private' } }, data: `approve:${id}` } }, f.service);
|
|
assert.equal(f.store.getRequest(id).status, 'approved');
|
|
assert.ok(f.messages.some(m => m.userId === '5' && m.text.includes('Your number:')));
|
|
assert.equal(calls.filter(c => c.method === 'answerCallbackQuery').length, 2);
|
|
assert.equal(calls.filter(c => c.method === 'sendGame').length, 0);
|
|
await bot.handle({ message: { from: user, chat: { type: 'group' }, text: '/start' } }, f.service);
|
|
assert.equal(calls.filter(c => c.method === 'sendMessage').length, 3);
|
|
});
|
|
|
|
test('Telegram sends native 2048 to pending users and handles Play without changing access', async () => {
|
|
const f = fixture();
|
|
f.config.telegram.gameShortName = 'dungeons_2048';
|
|
const calls = [];
|
|
const bot = new TelegramBot(f.config, f.store, { fetchImpl: async (url, options) => {
|
|
calls.push({ method: url.split('/').at(-1), body: JSON.parse(options.body) });
|
|
return Response.json({ ok: true, result: {} });
|
|
} });
|
|
const service = new AccessService(f.config, f.store, { telegram: bot });
|
|
const callback = { id: 'request1', from: { id: 5 }, message: { chat: { type: 'private' } }, data: 'request' };
|
|
await bot.handle({ callback_query: callback }, service);
|
|
assert.deepEqual(calls.at(-1), { method: 'sendGame', body: { chat_id: '5', game_short_name: 'dungeons_2048' } });
|
|
assert.ok(calls.some(c => c.body.chat_id === '100' && c.body.reply_markup));
|
|
assert.equal(f.store.state.requests[0].status, 'pending');
|
|
const before = structuredClone(f.store.state);
|
|
await bot.handle({ callback_query: { id: 'play1', from: { id: 5 }, game_short_name: 'dungeons_2048' } }, service);
|
|
assert.deepEqual(calls.at(-1), { method: 'answerCallbackQuery', body: { callback_query_id: 'play1', url: 'https://play2048.co/' } });
|
|
assert.deepEqual(f.store.state, before);
|
|
for (const name of ['unknown_game', 'dungeons_2048']) {
|
|
if (name === 'dungeons_2048') f.config.telegram.gameShortName = '';
|
|
await bot.handle({ callback_query: { id: 'unknown', from: { id: 5 }, game_short_name: name } }, service);
|
|
assert.equal(calls.at(-1).body.show_alert, true);
|
|
assert.equal(calls.at(-1).body.url, undefined);
|
|
}
|
|
f.config.telegram.gameShortName = 'dungeons_2048';
|
|
await service.decide('telegram', '100', f.store.state.requests[0].id, 'approve');
|
|
await bot.handle({ callback_query: callback }, service);
|
|
assert.equal(calls.filter(c => c.method === 'sendGame').length, 1, 'approved users do not get another waiting game');
|
|
});
|
|
|
|
test('a Telegram game failure does not lose the request or prevent approval', async () => {
|
|
const f = fixture();
|
|
f.config.telegram.gameShortName = 'dungeons_2048';
|
|
const errors = [];
|
|
const bot = new TelegramBot(f.config, f.store, {
|
|
log: { error: text => errors.push(text) },
|
|
fetchImpl: async url => url.endsWith('/sendGame')
|
|
? Response.json({ ok: false }, { status: 400 })
|
|
: Response.json({ ok: true, result: {} }),
|
|
});
|
|
const service = new AccessService(f.config, f.store, { telegram: bot });
|
|
await bot.handle({ callback_query: { id: 'request1', from: { id: 5 }, message: { chat: { type: 'private' } }, data: 'request' } }, service);
|
|
assert.equal(errors.length, 1);
|
|
const request = f.store.state.requests[0];
|
|
assert.equal(request.status, 'pending');
|
|
assert.deepEqual(request.notifiedAdmins, ['100', '101']);
|
|
await service.decide('telegram', '100', request.id, 'approve');
|
|
assert.equal(f.store.getRequest(request.id).status, 'approved');
|
|
});
|
|
|
|
test('Discord HTTP endpoint validates the exact signed body, rejects stale/forged signatures, and defers approvals', async t => {
|
|
const f = fixture();
|
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
f.config.discord.publicKey = publicKey.export({ type: 'spki', format: 'der' }).subarray(-32).toString('hex');
|
|
f.config.discord.applicationId = '321';
|
|
const edits = [];
|
|
const bot = new DiscordBot(f.config, { now: f.now, fetchImpl: async (url, options) => {
|
|
edits.push({ url, body: JSON.parse(options.body) }); return Response.json({ id: 'dm' });
|
|
} });
|
|
const gateway = createGateway(f.config, f.store, { discord: bot, service: f.service });
|
|
gateway.server.listen(0, '127.0.0.1'); await once(gateway.server, 'listening');
|
|
t.after(async () => { await bot.drain(); await gateway.close(); });
|
|
const endpoint = `http://127.0.0.1:${gateway.server.address().port}/_access/discord`;
|
|
const post = async (payload, overrides = {}) => {
|
|
const raw = JSON.stringify(payload);
|
|
const timestamp = String(Math.floor(f.now() / 1000));
|
|
const signature = sign(null, Buffer.from(timestamp + raw), privateKey).toString('hex');
|
|
return fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', 'x-signature-timestamp': timestamp, 'x-signature-ed25519': signature, ...overrides }, body: raw });
|
|
};
|
|
assert.deepEqual(await (await post({ type: 1 })).json(), { type: 1 });
|
|
assert.equal((await post({ type: 1 }, { 'x-signature-ed25519': '00'.repeat(64) })).status, 401);
|
|
assert.equal((await post({ type: 1 }, { 'x-signature-timestamp': '1' })).status, 401);
|
|
assert.equal((await fetch(endpoint, { method: 'POST', body: '{}' })).status, 401);
|
|
const start = { type: 2, application_id: '321', data: { name: 'access' } };
|
|
assert.equal((await (await post(start)).json()).data.components[0].components[0].custom_id, 'request');
|
|
const interaction = { type: 3, id: '111', token: 'interaction-token', application_id: '321', user: { id: '7', username: 'player' }, data: { custom_id: 'request' } };
|
|
assert.deepEqual(await (await post(interaction)).json(), { type: 5, data: { flags: 64 } });
|
|
await bot.drain();
|
|
assert.equal(f.store.state.requests[0].platform, 'discord');
|
|
assert.equal(f.store.state.requests[0].user_id, '7');
|
|
await post(interaction); await bot.drain();
|
|
assert.equal(edits.length, 1, 'replayed interaction must not re-run the action');
|
|
const requestId = f.store.state.requests[0].id;
|
|
await post({ ...interaction, id: '112', user: { id: '999' }, data: { custom_id: `approve:${requestId}` } });
|
|
await bot.drain();
|
|
assert.equal(f.store.getRequest(requestId).status, 'pending');
|
|
await post({ ...interaction, id: '113', user: { id: '200' }, data: { custom_id: `approve:${requestId}` } });
|
|
await bot.drain();
|
|
assert.equal(f.store.getRequest(requestId).status, 'approved');
|
|
assert.equal(f.messages.filter(m => m.userId === '7' && m.text.includes('Your number:')).length, 1);
|
|
});
|
|
|
|
test('Discord DMs disable mentions and render separate approval buttons', async () => {
|
|
const f = fixture();
|
|
f.config.discord.publicKey = generateKeyPairSync('ed25519').publicKey.export({ type: 'spki', format: 'der' }).subarray(-32).toString('hex');
|
|
const bodies = [];
|
|
const bot = new DiscordBot(f.config, { fetchImpl: async (_url, options) => { bodies.push(JSON.parse(options.body)); return Response.json({ id: '123' }); } });
|
|
await bot.sendUser('100', '@everyone malicious display name', [{ label: 'Approve', id: 'approve:123' }, { label: 'Deny', id: 'deny:123' }]);
|
|
assert.deepEqual(bodies[0], { recipient_id: '100' });
|
|
assert.deepEqual(bodies[1].allowed_mentions, { parse: [] });
|
|
assert.equal(bodies[1].components[0].components.length, 2);
|
|
});
|