Merge pull request 'DUNGEONS-33: fix shutdown lock cleanup and add optional Telegram 2048' (#1) from agent/codex-chatgpt/0403ec7892cd into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -26,6 +26,9 @@ TRUST_PROXY=false
|
|||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_BOT_USERNAME=
|
TELEGRAM_BOT_USERNAME=
|
||||||
TELEGRAM_ADMIN_IDS=
|
TELEGRAM_ADMIN_IDS=
|
||||||
|
# Optional BotFather game short name for THIS bot (not a numeric game ID).
|
||||||
|
# Empty disables the game. The native Play button opens the original 2048.
|
||||||
|
TELEGRAM_GAME_SHORT_NAME=
|
||||||
|
|
||||||
# Discord uses a signed HTTP interactions endpoint at /_access/discord.
|
# Discord uses a signed HTTP interactions endpoint at /_access/discord.
|
||||||
DISCORD_BOT_TOKEN=
|
DISCORD_BOT_TOKEN=
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -40,6 +40,14 @@ For a visual preview, development mode may run with both tokens empty. It displa
|
|||||||
|
|
||||||
`/start` and `/access` show **Get access** in a private chat. The bot acknowledges button presses, checks the acting admin's actual Telegram ID, and delivers approved numbers privately. The last processed update offset is persisted.
|
`/start` and `/access` show **Get access** in a private chat. The bot acknowledges button presses, checks the acting admin's actual Telegram ID, and delivers approved numbers privately. The last processed update offset is persisted.
|
||||||
|
|
||||||
|
### Optional 2048 game while waiting
|
||||||
|
|
||||||
|
If you added a game to this bot in BotFather, set `TELEGRAM_GAME_SHORT_NAME` to its **short name**, for example `dungeons_2048`. This is the value after `?game=` in BotFather's game link, not a numeric game ID or the full link. To create one, use BotFather's `/newgame` for the same bot whose token is configured above.
|
||||||
|
|
||||||
|
After **Get access**, users with a pending request receive a native Telegram game card. Its **Play** button opens [Gabriele Cirulli's 2048](https://github.com/gabrielecirulli/2048) at `https://play2048.co/` inside Telegram. The bot uses [`sendGame` and game callbacks](https://core.telegram.org/bots/api#games); playing does not grant access or change the approval request. Telegram score reporting is not integrated.
|
||||||
|
|
||||||
|
Leave `TELEGRAM_GAME_SHORT_NAME` empty to disable games; it is entirely optional. A wrong or unavailable game does not cancel the request or prevent admin approval. Correct the short name and press **Get access** again while the request is pending to retry.
|
||||||
|
|
||||||
## Discord setup
|
## Discord setup
|
||||||
|
|
||||||
1. Create an application and bot in the Discord Developer Portal. Put the bot token, application ID, and application's public key into `DISCORD_BOT_TOKEN`, `DISCORD_APPLICATION_ID`, and `DISCORD_PUBLIC_KEY`.
|
1. Create an application and bot in the Discord Developer Portal. Put the bot token, application ID, and application's public key into `DISCORD_BOT_TOKEN`, `DISCORD_APPLICATION_ID`, and `DISCORD_PUBLIC_KEY`.
|
||||||
@@ -71,6 +79,7 @@ For a visual preview, development mode may run with both tokens empty. It displa
|
|||||||
| `GLOBAL_LOGIN_ATTEMPTS` | Aggregate entry limit per window; 100, including successful entries |
|
| `GLOBAL_LOGIN_ATTEMPTS` | Aggregate entry limit per window; 100, including successful entries |
|
||||||
| `TRUST_PROXY` | Trust a single IP in `X-Forwarded-For`; false by default |
|
| `TRUST_PROXY` | Trust a single IP in `X-Forwarded-For`; false by default |
|
||||||
| `ADMIN_REQUEST_FIELDS` | Optional details shown to admins: `username,display_name,language_code,guild_id,created_at` |
|
| `ADMIN_REQUEST_FIELDS` | Optional details shown to admins: `username,display_name,language_code,guild_id,created_at` |
|
||||||
|
| `TELEGRAM_GAME_SHORT_NAME` | Optional BotFather game short name for this bot; empty disables the 2048 game card |
|
||||||
|
|
||||||
Platform, numeric user ID, and request ID always appear in the admin message. Missing optional details show as `—`. Display names and usernames are informational; only configured numeric IDs authorize reviews. These details are collected from the bot interaction, not submitted by the website.
|
Platform, numeric user ID, and request ID always appear in the admin message. Missing optional details show as `—`. Display names and usernames are informational; only configured numeric IDs authorize reviews. These details are collected from the bot interaction, not submitted by the website.
|
||||||
|
|
||||||
@@ -93,7 +102,7 @@ Compose binds the gateway to host loopback for your TLS edge and stores data in
|
|||||||
|
|
||||||
- Requests, approvals, hashed codes, hashed session tokens, hashed-IP rate limits, and the Telegram offset persist in an atomically replaced, fsynced JSON file with mode `0600`. Plain access numbers exist only during generation and DM delivery; they are not stored or logged.
|
- Requests, approvals, hashed codes, hashed session tokens, hashed-IP rate limits, and the Telegram offset persist in an atomically replaced, fsynced JSON file with mode `0600`. Plain access numbers exist only during generation and DM delivery; they are not stored or logged.
|
||||||
- This is a small private-alpha **single-process** store, capped at 10,000 retained requests and 10,000 active sessions. It is not a distributed database. Use one instance and a durable local volume; do not use autoscaling, clustered workers, or multiple replicas sharing this file. Expired sessions and old request records are pruned periodically; requests are retained for 30 days beyond their last expiry.
|
- This is a small private-alpha **single-process** store, capped at 10,000 retained requests and 10,000 active sessions. It is not a distributed database. Use one instance and a durable local volume; do not use autoscaling, clustered workers, or multiple replicas sharing this file. Expired sessions and old request records are pruned periodically; requests are retained for 30 days beyond their last expiry.
|
||||||
- A `.lock` file prevents simultaneous writers. Normal SIGINT/SIGTERM shutdown releases it. After an abrupt kill or host crash, verify the recorded PID belongs to the stopped gateway before removing the stale lock; never delete a live instance's lock. In Docker, inspect the container state before restarting with a stale lock.
|
- A `.lock` file prevents simultaneous writers. Ctrl+C (SIGINT), SIGTERM, and startup failures release it. Shutdown allows up to five seconds for pending work; if it stalls, the process exits with status 1 and removes the lock synchronously on exit. Repeated Ctrl+C does not interrupt cleanup. After an uncatchable SIGKILL or host crash, verify the recorded PID belongs to the stopped gateway before removing the stale lock; never delete a live instance's lock. In Docker, inspect the container state before restarting with a stale lock.
|
||||||
- Back up the data file and secret together while the gateway is stopped. Corrupt state fails startup instead of silently discarding access controls. Rotating the secret invalidates existing numbers and browser sessions.
|
- Back up the data file and secret together while the gateway is stopped. Corrupt state fails startup instead of silently discarding access controls. Rotating the secret invalidates existing numbers and browser sessions.
|
||||||
- If a code DM fails, the request stays pending and no code is activated; the admin can press Approve again after the user enables DMs. Undelivered admin notifications can be retried with Get access. Duplicate requests/reviews do not create duplicate approvals. After a crash during delivery, the request returns to pending and the admin should approve it again; an uncertain delivery is never activated automatically.
|
- If a code DM fails, the request stays pending and no code is activated; the admin can press Approve again after the user enables DMs. Undelivered admin notifications can be retried with Get access. Duplicate requests/reviews do not create duplicate approvals. After a crash during delivery, the request returns to pending and the admin should approve it again; an uncertain delivery is never activated automatically.
|
||||||
- While a number remains valid, pressing Get access points the user to their existing DM. Lost numbers cannot be recovered from hashes: revoke that user's access below, then let them request a fresh approval.
|
- While a number remains valid, pressing Get access points the user to their existing DM. Lost numbers cannot be recovered from hashes: revoke that user's access below, then let them request a fresh approval.
|
||||||
@@ -117,6 +126,6 @@ npm run check
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
The suite covers both bot adapters with mocked provider APIs, administrator authorization, failed DM recovery, duplicate reviews, reusable codes and expiry/renewal, persistent sessions, rate limits, signed Discord interactions, unauthenticated HTTP/WebSocket denial, streaming proxy requests, redirects, cookies, logout, and upstream outages. It requires no external credentials. Live Telegram/Discord delivery still needs an installation with real tokens, configured admins and working DMs.
|
The suite covers both bot adapters with mocked provider APIs, optional Telegram game cards and Play callbacks, game failure isolation, process shutdown and restart, administrator authorization, failed DM recovery, duplicate reviews, reusable codes and expiry/renewal, persistent sessions, rate limits, signed Discord interactions, unauthenticated HTTP/WebSocket denial, streaming proxy requests, redirects, cookies, logout, and upstream outages. It requires no external credentials. Live Telegram/Discord delivery still needs an installation with real tokens, configured admins and working DMs.
|
||||||
|
|
||||||
Protocol references: [Telegram Bot API](https://core.telegram.org/bots/api), [Discord interactions](https://docs.discord.com/developers/interactions/receiving-and-responding), [Discord application commands](https://docs.discord.com/developers/interactions/application-commands), [http-proxy-middleware 3.x](https://github.com/chimurai/http-proxy-middleware/tree/v3.0.7).
|
Protocol references: [Telegram Bot API](https://core.telegram.org/bots/api), [Discord interactions](https://docs.discord.com/developers/interactions/receiving-and-responding), [Discord application commands](https://docs.discord.com/developers/interactions/application-commands), [http-proxy-middleware 3.x](https://github.com/chimurai/http-proxy-middleware/tree/v3.0.7).
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ export class TelegramBot {
|
|||||||
}
|
}
|
||||||
const callback = update.callback_query;
|
const callback = update.callback_query;
|
||||||
if (!callback || callback.from?.is_bot) return;
|
if (!callback || callback.from?.is_bot) return;
|
||||||
|
if (callback.game_short_name) {
|
||||||
|
const enabled = this.config.telegram.gameShortName;
|
||||||
|
await this.call('answerCallbackQuery', {
|
||||||
|
callback_query_id: callback.id,
|
||||||
|
...(enabled && callback.game_short_name === enabled
|
||||||
|
? { url: 'https://play2048.co/' }
|
||||||
|
: { text: 'This game is unavailable.', show_alert: true }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.call('answerCallbackQuery', { callback_query_id: callback.id });
|
await this.call('answerCallbackQuery', { callback_query_id: callback.id });
|
||||||
if (callback.message?.chat.type !== 'private') return;
|
if (callback.message?.chat.type !== 'private') return;
|
||||||
const userId = String(callback.from.id);
|
const userId = String(callback.from.id);
|
||||||
@@ -43,6 +53,14 @@ export class TelegramBot {
|
|||||||
result = await service.decide('telegram', userId, match[2], match[1]);
|
result = await service.decide('telegram', userId, match[2], match[1]);
|
||||||
}
|
}
|
||||||
await this.sendUser(userId, result);
|
await this.sendUser(userId, result);
|
||||||
|
if (callback.data === 'request' && this.config.telegram.gameShortName &&
|
||||||
|
this.store.state.requests.some(r => r.platform === 'telegram' && r.user_id === userId && r.status === 'pending' && r.expiresAt > this.store.now())) {
|
||||||
|
try {
|
||||||
|
await this.call('sendGame', { chat_id: userId, game_short_name: this.config.telegram.gameShortName });
|
||||||
|
} catch {
|
||||||
|
this.log.error('Telegram game could not be sent. Check TELEGRAM_GAME_SHORT_NAME in BotFather; the access request remains saved.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(service) {
|
async run(service) {
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export function loadConfig(env = process.env) {
|
|||||||
if (secret.length < 32 || /replace-with|change.?me/i.test(secret)) throw new Error('Set SESSION_SECRET to a random secret of at least 32 characters');
|
if (secret.length < 32 || /replace-with|change.?me/i.test(secret)) throw new Error('Set SESSION_SECRET to a random secret of at least 32 characters');
|
||||||
if (env.TRUST_PROXY && !['true', 'false'].includes(env.TRUST_PROXY)) throw new Error('TRUST_PROXY must be true or false');
|
if (env.TRUST_PROXY && !['true', 'false'].includes(env.TRUST_PROXY)) throw new Error('TRUST_PROXY must be true or false');
|
||||||
const telegram = { token: env.TELEGRAM_BOT_TOKEN || '', username: env.TELEGRAM_BOT_USERNAME || '', admins: ids('TELEGRAM_ADMIN_IDS') };
|
const telegram = { token: env.TELEGRAM_BOT_TOKEN || '', username: env.TELEGRAM_BOT_USERNAME || '', admins: ids('TELEGRAM_ADMIN_IDS') };
|
||||||
|
telegram.gameShortName = (env.TELEGRAM_GAME_SHORT_NAME || '').trim();
|
||||||
|
if (telegram.gameShortName && !/^[A-Za-z0-9_]{3,30}$/.test(telegram.gameShortName)) {
|
||||||
|
throw new Error('TELEGRAM_GAME_SHORT_NAME must be the BotFather game short name (3–30 letters, digits or underscores)');
|
||||||
|
}
|
||||||
if (telegram.token && (!/^\d+:[A-Za-z0-9_-]+$/.test(telegram.token) || !/^[A-Za-z0-9_]{5,32}$/.test(telegram.username) || !telegram.admins.length)) {
|
if (telegram.token && (!/^\d+:[A-Za-z0-9_-]+$/.test(telegram.token) || !/^[A-Za-z0-9_]{5,32}$/.test(telegram.username) || !telegram.admins.length)) {
|
||||||
throw new Error('Telegram requires TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME (without @), and TELEGRAM_ADMIN_IDS');
|
throw new Error('Telegram requires TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME (without @), and TELEGRAM_ADMIN_IDS');
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/index.js
39
src/index.js
@@ -5,36 +5,35 @@ import { AccessService } from './bots/service.js';
|
|||||||
import { TelegramBot } from './bots/telegram.js';
|
import { TelegramBot } from './bots/telegram.js';
|
||||||
import { DiscordBot } from './bots/discord.js';
|
import { DiscordBot } from './bots/discord.js';
|
||||||
import { createGateway } from './server.js';
|
import { createGateway } from './server.js';
|
||||||
|
import { installShutdown } from './shutdown.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const store = new AccessStore(config);
|
const store = new AccessStore(config);
|
||||||
const providers = {};
|
const providers = {};
|
||||||
if (config.telegram.token) providers.telegram = new TelegramBot(config, store);
|
let gateway, poller, maintenance;
|
||||||
if (config.discord.token) providers.discord = new DiscordBot(config);
|
|
||||||
const service = new AccessService(config, store, providers);
|
|
||||||
const gateway = createGateway(config, store, { discord: providers.discord, service });
|
|
||||||
try {
|
|
||||||
await new Promise((resolve, reject) => { gateway.server.once('error', reject); gateway.server.listen(config.port, config.host, resolve); });
|
|
||||||
} catch (error) { store.close(); throw error; }
|
|
||||||
console.log(`Dungeons & Ground access gateway listening on ${config.publicUrl.origin}`);
|
|
||||||
if (!Object.keys(providers).length) console.log('Preview mode: no bots configured. Access cannot be granted until a bot is enabled.');
|
|
||||||
const poller = providers.telegram?.run(service);
|
|
||||||
const maintenance = setInterval(() => {
|
|
||||||
try { store.prune(); } catch { console.error('Access store maintenance failed. Check disk space and permissions.'); }
|
|
||||||
}, 60000).unref();
|
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
const stop = async () => {
|
const stop = installShutdown(store, async () => {
|
||||||
if (stopping) return;
|
|
||||||
stopping = true;
|
stopping = true;
|
||||||
clearInterval(maintenance);
|
clearInterval(maintenance);
|
||||||
providers.telegram?.stop();
|
providers.telegram?.stop();
|
||||||
await gateway.close();
|
await gateway?.close();
|
||||||
await Promise.allSettled([poller, providers.discord?.drain()]);
|
await Promise.allSettled([poller, providers.discord?.drain()]);
|
||||||
store.close();
|
});
|
||||||
};
|
try {
|
||||||
process.once('SIGINT', () => void stop());
|
if (config.telegram.token) providers.telegram = new TelegramBot(config, store);
|
||||||
process.once('SIGTERM', () => void stop());
|
if (config.discord.token) providers.discord = new DiscordBot(config);
|
||||||
|
const service = new AccessService(config, store, providers);
|
||||||
|
gateway = createGateway(config, store, { discord: providers.discord, service });
|
||||||
|
await new Promise((resolve, reject) => { gateway.server.once('error', reject); gateway.server.listen(config.port, config.host, resolve); });
|
||||||
|
if (stopping) return;
|
||||||
|
console.log(`Dungeons & Ground access gateway listening on ${config.publicUrl.origin}`);
|
||||||
|
if (!Object.keys(providers).length) console.log('Preview mode: no bots configured. Access cannot be granted until a bot is enabled.');
|
||||||
|
poller = providers.telegram?.run(service);
|
||||||
|
maintenance = setInterval(() => {
|
||||||
|
try { store.prune(); } catch { console.error('Access store maintenance failed. Check disk space and permissions.'); }
|
||||||
|
}, 60000).unref();
|
||||||
|
} catch (error) { await stop(); throw error; }
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(error => { console.error(`Startup failed: ${error.message}`); process.exitCode = 1; });
|
main().catch(error => { console.error(`Startup failed: ${error.message}`); process.exitCode = 1; });
|
||||||
|
|||||||
29
src/shutdown.js
Normal file
29
src/shutdown.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Keep the store locked until writers finish, or until the process exits.
|
||||||
|
export function installShutdown(store, drain, { timeout = 5000 } = {}) {
|
||||||
|
const release = () => {
|
||||||
|
try { store.close(); }
|
||||||
|
catch { console.error('Could not remove the access store lock. Check file permissions.'); process.exitCode = 1; }
|
||||||
|
};
|
||||||
|
process.once('exit', release);
|
||||||
|
let stopping;
|
||||||
|
const stop = () => {
|
||||||
|
if (stopping) return stopping;
|
||||||
|
const deadline = setTimeout(() => {
|
||||||
|
console.error('Shutdown timed out; exiting and releasing the access store lock.');
|
||||||
|
process.exit(1);
|
||||||
|
}, timeout).unref();
|
||||||
|
stopping = Promise.resolve().then(drain).catch(() => {
|
||||||
|
console.error('Gateway shutdown failed.');
|
||||||
|
// Do not unlock while unfinished jobs could still write to the store.
|
||||||
|
process.exit(1);
|
||||||
|
}).then(() => {
|
||||||
|
clearTimeout(deadline);
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
return stopping;
|
||||||
|
};
|
||||||
|
// Keep handlers installed so repeated Ctrl+C cannot interrupt cleanup.
|
||||||
|
process.on('SIGINT', stop);
|
||||||
|
process.on('SIGTERM', stop);
|
||||||
|
return stop;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { once } from 'node:events';
|
|||||||
import { DiscordBot } from '../src/bots/discord.js';
|
import { DiscordBot } from '../src/bots/discord.js';
|
||||||
import { TelegramBot } from '../src/bots/telegram.js';
|
import { TelegramBot } from '../src/bots/telegram.js';
|
||||||
import { createGateway } from '../src/server.js';
|
import { createGateway } from '../src/server.js';
|
||||||
|
import { AccessService } from '../src/bots/service.js';
|
||||||
import { fixture } from './helpers.js';
|
import { fixture } from './helpers.js';
|
||||||
|
|
||||||
test('Telegram /start offers Get access; private callback creates request and admin callback delivers number', async () => {
|
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.equal(f.store.getRequest(id).status, 'approved');
|
||||||
assert.ok(f.messages.some(m => m.userId === '5' && m.text.includes('Your number:')));
|
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 === '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);
|
await bot.handle({ message: { from: user, chat: { type: 'group' }, text: '/start' } }, f.service);
|
||||||
assert.equal(calls.filter(c => c.method === 'sendMessage').length, 3);
|
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 => {
|
test('Discord HTTP endpoint validates the exact signed body, rejects stale/forged signatures, and defers approvals', async t => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
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' };
|
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', () => {
|
test('configuration rejects unsafe origins, weak secrets, invalid numbers, and incomplete bots', () => {
|
||||||
for (const options of [
|
for (const options of [
|
||||||
{ SESSION_SECRET: '' }, { SESSION_SECRET: 'replace-with-a-random-secret-at-least-32-characters' },
|
{ 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