DUNGEONS-33: clean shutdown locks and add optional Telegram 2048
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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
95
test/shutdown.test.js
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user