DUNGEONS-33: clean shutdown locks and add optional Telegram 2048

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-06 09:14:08 +05:00
parent eb135ac6ef
commit 5e7b24cbaf
9 changed files with 240 additions and 22 deletions

View File

@@ -5,6 +5,7 @@ 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 () => {
@@ -25,10 +26,61 @@ test('Telegram /start offers Get access; private callback creates request and ad
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');

View File

@@ -5,6 +5,15 @@ import { safeNext, readCookie, clientIp } from '../src/http.js';
const base = { SESSION_SECRET: 'a-secure-test-secret-longer-than-32-chars' };
test('Telegram games are optional and use a BotFather short name', () => {
assert.equal(loadConfig(base).telegram.gameShortName, '');
assert.equal(loadConfig({ ...base, TELEGRAM_GAME_SHORT_NAME: ' ' }).telegram.gameShortName, '');
assert.equal(loadConfig({ ...base, TELEGRAM_GAME_SHORT_NAME: ' dungeons_2048 ' }).telegram.gameShortName, 'dungeons_2048');
for (const name of ['@gamebot', 'https://t.me/gamebot?game=2048', 'my game', 'a'.repeat(31)]) {
assert.throws(() => loadConfig({ ...base, TELEGRAM_GAME_SHORT_NAME: name }), /TELEGRAM_GAME_SHORT_NAME/);
}
});
test('configuration rejects unsafe origins, weak secrets, invalid numbers, and incomplete bots', () => {
for (const options of [
{ SESSION_SECRET: '' }, { SESSION_SECRET: 'replace-with-a-random-secret-at-least-32-characters' },

95
test/shutdown.test.js Normal file
View File

@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createServer } from 'node:net';
import { fileURLToPath } from 'node:url';
import { AccessStore } from '../src/store.js';
const root = fileURLToPath(new URL('../', import.meta.url));
function setup(t) {
const directory = mkdtempSync(join(tmpdir(), 'dng-shutdown-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
return join(directory, 'access.json');
}
function launch(t, args, env) {
const child = spawn(process.execPath, args, { cwd: root, env: { PATH: process.env.PATH, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
const exited = once(child, 'exit');
let output = '';
child.stdout.on('data', chunk => { output += chunk; });
child.stderr.on('data', chunk => { output += chunk; });
t.after(async () => {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
await exited;
});
const ready = async text => {
while (!output.includes(text)) {
const result = await Promise.race([once(child.stdout, 'data').then(() => true), exited.then(() => false)]);
assert.ok(result, output);
}
};
return { child, exited, ready, output: () => output };
}
for (const signal of ['SIGINT', 'SIGTERM']) {
test(`gateway releases its lock on ${signal} and can restart`, { timeout: 10000 }, async t => {
const dataFile = setup(t);
const reservation = createServer().listen(0, '127.0.0.1');
await once(reservation, 'listening');
const port = reservation.address().port;
await new Promise(resolve => reservation.close(resolve));
const env = { SESSION_SECRET: 'test-secret-with-at-least-32-characters', DATA_FILE: dataFile, HOST: '127.0.0.1', PORT: String(port), DOTENV_CONFIG_PATH: join(dataFile, 'no-env') };
for (let attempt = 0; attempt < 2; attempt++) {
const run = launch(t, ['src/index.js'], env);
await run.ready('gateway listening');
assert.ok(existsSync(`${dataFile}.lock`));
run.child.kill(signal);
assert.deepEqual(await run.exited, [0, null], run.output());
assert.equal(existsSync(`${dataFile}.lock`), false);
assert.equal(JSON.parse(readFileSync(dataFile)).version, 1);
}
});
}
test('startup failure releases the acquired lock', { timeout: 10000 }, async t => {
const dataFile = setup(t);
const occupied = createServer().listen(0, '127.0.0.1');
await once(occupied, 'listening');
t.after(() => new Promise(resolve => occupied.close(resolve)));
const run = launch(t, ['src/index.js'], { SESSION_SECRET: 'test-secret-with-at-least-32-characters', DATA_FILE: dataFile, HOST: '127.0.0.1', PORT: String(occupied.address().port), DOTENV_CONFIG_PATH: join(dataFile, 'no-env') });
assert.deepEqual(await run.exited, [1, null]);
assert.match(run.output(), /EADDRINUSE/);
assert.equal(existsSync(`${dataFile}.lock`), false);
});
for (const mode of ['drain', 'hang', 'error']) {
test(`shutdown ${mode}: repeated interrupts keep the lock until writers stop`, { timeout: 10000 }, async t => {
const dataFile = setup(t);
const run = launch(t, ['--input-type=module', '-e', `
import { AccessStore } from './src/store.js';
import { installShutdown } from './src/shutdown.js';
const store = new AccessStore({ dataFile: process.env.DATA_FILE });
const keepAlive = setInterval(() => {}, 1000);
installShutdown(store, async () => {
console.log('draining');
await new Promise(resolve => setTimeout(resolve, 200));
if (process.env.MODE === 'error') throw new Error('failed drain');
if (process.env.MODE === 'hang') await new Promise(() => {});
store.change(state => { state.telegramOffset = 42; });
clearInterval(keepAlive);
}, { timeout: 500 });
console.log('ready');
`], { DATA_FILE: dataFile, MODE: mode });
await run.ready('ready');
run.child.kill('SIGINT');
await run.ready('draining');
assert.throws(() => new AccessStore({ dataFile }), /locked/);
run.child.kill('SIGINT');
assert.deepEqual(await run.exited, [mode === 'drain' ? 0 : 1, null], run.output());
assert.equal(existsSync(`${dataFile}.lock`), false);
assert.equal(JSON.parse(readFileSync(dataFile)).telegramOffset, mode === 'drain' ? 42 : 0);
});
}