DUNGEONS-30: add access gateway with Telegram and Discord approvals

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-05 17:09:53 +05:00
commit b87a8ef70e
30 changed files with 1838 additions and 0 deletions

83
test/bots.test.js Normal file
View File

@@ -0,0 +1,83 @@
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 { 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);
await bot.handle({ message: { from: user, chat: { type: 'group' }, text: '/start' } }, f.service);
assert.equal(calls.filter(c => c.method === 'sendMessage').length, 3);
});
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);
});

35
test/config.test.js Normal file
View File

@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadConfig } from '../src/config.js';
import { safeNext, readCookie, clientIp } from '../src/http.js';
const base = { SESSION_SECRET: 'a-secure-test-secret-longer-than-32-chars' };
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' },
{ UPSTREAM_URL: 'file:///tmp/secret' }, { UPSTREAM_URL: 'https://example.com/path' },
{ UPSTREAM_URL: 'https://user:password@example.com' }, { UPSTREAM_URL: 'http://localhost:8080' },
{ CODE_LENGTH: '7' }, { CODE_TTL_DAYS: '0' }, { PORT: '3000x' }, { TRUST_PROXY: 'yes' },
{ TELEGRAM_BOT_TOKEN: '123:token' }, { DISCORD_BOT_TOKEN: 'secret' },
{ TELEGRAM_ADMIN_IDS: '@admin' }, { ADMIN_REQUEST_FIELDS: 'password' },
{ NODE_ENV: 'production' }, { NODE_ENV: 'production', PUBLIC_URL: 'https://gate.example' },
]) assert.throws(() => loadConfig({ ...base, ...options }));
const config = loadConfig({ ...base, NODE_ENV: 'production', PUBLIC_URL: 'https://gate.example', TELEGRAM_BOT_TOKEN: '123:token', TELEGRAM_BOT_USERNAME: 'example_bot', TELEGRAM_ADMIN_IDS: '1,2,1' });
assert.deepEqual(config.telegram.admins, ['1', '2']);
assert.equal(config.secureCookie, true);
});
test('return paths cannot redirect outside the gateway or back into its controls', () => {
for (const value of [undefined, 'https://evil.example', '//evil.example', '/\\evil.example', '/%5cevil.example', '/_access/login', '/a/../_access', '/%00', '/x\ny', '/%']) assert.equal(safeNext(value), '/');
assert.equal(safeNext('/campaigns/123?tab=party#round'), '/campaigns/123?tab=party#round');
});
test('ambiguous cookies and untrusted forwarding cannot grant access or bypass rate limiting', () => {
assert.equal(readCookie({ headers: { cookie: 'dng_access=first; dng_access=second' } }), '');
const req = { headers: { 'x-forwarded-for': '192.0.2.1' }, socket: { remoteAddress: '127.0.0.1' } };
assert.equal(clientIp(req, { trustProxy: false }), '127.0.0.1');
assert.equal(clientIp(req, { trustProxy: true }), '192.0.2.1');
req.headers['x-forwarded-for'] = '192.0.2.1, 198.51.100.1';
assert.equal(clientIp(req, { trustProxy: true }), '127.0.0.1');
});

141
test/gateway.test.js Normal file
View File

@@ -0,0 +1,141 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { WebSocket, WebSocketServer } from 'ws';
import { createGateway } from '../src/server.js';
import { fixture, grant } from './helpers.js';
async function setup(t, options) {
const f = fixture(options);
const received = [];
const upstream = createServer(async (req, res) => {
let body = '';
for await (const chunk of req) body += chunk;
received.push({ url: req.url, headers: req.headers, method: req.method, body });
if (req.url === '/redirect') { res.writeHead(302, { location: `${f.config.upstreamUrl.origin}/dashboard?tab=party` }); return res.end(); }
res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': ['app=abc; Domain=127.0.0.1; Path=/; HttpOnly', 'dng_access=forged; Path=/'], 'cache-control': 'public, max-age=3600' });
res.end(JSON.stringify(received.at(-1)));
});
const wss = new WebSocketServer({ server: upstream });
wss.on('connection', (ws, req) => { received.push({ ws: true, headers: req.headers }); ws.on('message', data => ws.send(data)); });
upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening');
f.config.upstreamUrl = new URL(`http://127.0.0.1:${upstream.address().port}`);
const gateway = createGateway(f.config, f.store, { log: { error() {} } });
gateway.server.listen(0, '127.0.0.1'); await once(gateway.server, 'listening');
f.config.publicUrl = new URL(`http://127.0.0.1:${gateway.server.address().port}`);
const url = f.config.publicUrl.origin;
t.after(async () => {
await gateway.close();
for (const ws of wss.clients) ws.terminate();
wss.close();
upstream.closeAllConnections();
await new Promise(resolve => upstream.close(resolve));
});
return { ...f, url, received, upstream, gateway,
login: (code, next = '/', headers = {}) => fetch(`${url}/_access/login`, { method: 'POST', headers: { origin: url, 'content-type': 'application/json', ...headers }, body: JSON.stringify({ code, next }) }),
};
}
async function deniedSocket(url, headers = {}) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url.replace('http:', 'ws:') + '/socket', { headers });
ws.on('unexpected-response', (_req, res) => { res.resume(); ws.terminate(); resolve(res.statusCode); });
ws.on('open', () => { ws.terminate(); reject(new Error('Unauthenticated websocket was accepted')); });
ws.on('error', () => {});
});
}
test('unauthenticated pages, API, assets, HEAD, uploads and websocket upgrades never reach upstream', async t => {
const f = await setup(t);
const root = await fetch(f.url);
assert.equal(root.status, 200);
assert.match(await root.text(), /YOUR NEXT/);
const page = await fetch(`${f.url}/campaigns/5?tab=round`, { headers: { accept: 'text/html' }, redirect: 'manual' });
assert.equal(page.status, 303);
assert.equal(page.headers.get('location'), '/_access?next=%2Fcampaigns%2F5%3Ftab%3Dround');
for (const [method, path] of [['GET', '/api/v1/campaigns'], ['HEAD', '/_nuxt/file.js'], ['POST', '/upload'], ['GET', '/private?x=1']]) {
assert.equal((await fetch(f.url + path, { method })).status, 401);
}
assert.equal((await fetch(`${f.url}/_access/unknown`)).status, 404);
assert.equal(await deniedSocket(f.url), 401);
assert.equal(f.received.length, 0);
assert.equal((await fetch(`${f.url}/_access/assets/style.css`)).status, 200);
});
test('a reusable code unlocks deep links and streams methods, bodies, application cookies and bearer auth', async t => {
const f = await setup(t);
const { code } = await grant(f);
const result = await f.login(code, '/campaigns/123?tab=party');
assert.equal(result.status, 200);
assert.deepEqual(await result.json(), { next: '/campaigns/123?tab=party' });
const setCookie = result.headers.get('set-cookie');
assert.match(setCookie, /HttpOnly; SameSite=Lax; Max-Age=1296000/);
const cookie = setCookie.split(';')[0];
const response = await fetch(`${f.url}/api/v1/campaigns?x=1`, { method: 'PATCH', body: '{"name":"A new world"}', headers: {
cookie: `${cookie}; app=existing`, authorization: 'Bearer upstream-token', 'content-type': 'application/json',
'x-forwarded-for': '192.0.2.1', 'x-forwarded-host': 'evil.example', 'x-forwarded-proto': 'ftp', 'x-dng-user': 'forged',
} });
const body = await response.json();
assert.equal(body.url, '/api/v1/campaigns?x=1');
assert.equal(body.method, 'PATCH');
assert.equal(body.body, '{"name":"A new world"}');
assert.equal(body.headers.cookie, 'app=existing');
assert.equal(body.headers.authorization, 'Bearer upstream-token');
assert.equal(body.headers['x-forwarded-host'], f.config.publicUrl.host);
assert.equal(body.headers['x-forwarded-proto'], 'http');
assert.equal(body.headers['x-forwarded-for'], '127.0.0.1');
assert.equal(body.headers['x-dng-user'], undefined);
assert.equal(response.headers.get('cache-control'), 'private, no-store');
assert.match(response.headers.get('set-cookie'), /app=abc/);
assert.doesNotMatch(response.headers.get('set-cookie'), /Domain|dng_access/);
const redirect = await fetch(`${f.url}/redirect`, { headers: { cookie }, redirect: 'manual' });
assert.equal(redirect.headers.get('location'), `${f.url}/dashboard?tab=party`);
assert.equal((await f.login(code)).status, 200);
assert.equal(await deniedSocket(f.url), 401, 'proxy must not attach an unguarded listener after HTTP traffic');
assert.equal((await fetch(`${f.url}/_access/logout`, { method: 'POST', headers: { origin: f.url, cookie } })).status, 200);
assert.equal((await fetch(`${f.url}/api`, { headers: { cookie } })).status, 401);
});
test('WebSockets authenticate, preserve application cookies, reject cross-origin access, and close at expiry', async t => {
const f = await setup(t);
const { code } = await grant(f);
const cookie = (await f.login(code)).headers.get('set-cookie').split(';')[0];
assert.equal(await deniedSocket(f.url, { cookie, origin: 'https://evil.example' }), 401);
const ws = new WebSocket(f.url.replace('http:', 'ws:') + '/socket', { headers: { cookie: `${cookie}; app=ws`, origin: f.url } });
await once(ws, 'open');
const echo = once(ws, 'message'); ws.send('round:ready');
assert.equal((await echo)[0].toString(), 'round:ready');
assert.equal(f.received.find(r => r.ws).headers.cookie, 'app=ws');
const closed = once(ws, 'close');
f.advance(15 * 86400_000);
await closed;
assert.equal((await f.login(code)).status, 401);
});
test('login rejects cross-origin posts, malformed bodies, forged cookies and rate-limit evasion', async t => {
const f = await setup(t, { loginAttempts: 2 });
assert.equal((await f.login('0000000000', '/', { origin: 'https://evil.example' })).status, 403);
assert.equal((await fetch(`${f.url}/_access/login`)).status, 405);
assert.equal((await fetch(`${f.url}/_access/login`, { method: 'POST', headers: { origin: f.url, 'content-type': 'text/plain' }, body: '{}' })).status, 415);
const malformed = await fetch(`${f.url}/_access/login`, { method: 'POST', headers: { origin: f.url, 'content-type': 'application/json' }, body: '{' });
assert.equal(malformed.status, 400);
assert.equal((await f.login('0000000000')).status, 401);
const limited = await f.login('0000000000', '/', { 'x-forwarded-for': '203.0.113.1' });
assert.equal(limited.status, 429);
assert.equal(limited.headers.get('retry-after'), '900');
assert.equal((await fetch(`${f.url}/api`, { headers: { cookie: `dng_access=${'a'.repeat(43)}` } })).status, 401);
});
test('oversized login requests are bounded and upstream outages return a usable 502', async t => {
const f = await setup(t);
const oversized = await fetch(`${f.url}/_access/login`, { method: 'POST', headers: { origin: f.url, 'content-type': 'application/json' }, body: JSON.stringify({ code: '1'.repeat(5000) }) });
assert.equal(oversized.status, 413);
const { code } = await grant(f);
const cookie = (await f.login(code, '//evil.example')).headers.get('set-cookie').split(';')[0];
f.upstream.closeAllConnections();
await new Promise(resolve => f.upstream.close(resolve));
const response = await fetch(`${f.url}/dashboard`, { headers: { cookie } });
assert.equal(response.status, 502);
assert.match((await response.json()).error, /temporarily unreachable/);
});

21
test/helpers.js Normal file
View File

@@ -0,0 +1,21 @@
import { loadConfig } from '../src/config.js';
import { AccessStore } from '../src/store.js';
import { AccessService } from '../src/bots/service.js';
export function fixture(options = {}) {
let time = Date.now();
const config = { ...loadConfig({ SESSION_SECRET: 'test-secret-with-at-least-32-characters', TELEGRAM_ADMIN_IDS: '100,101', DISCORD_ADMIN_IDS: '200' }), ...options };
const store = new AccessStore(config, { memory: true, now: () => time });
const messages = [];
const provider = { async sendUser(userId, text, buttons = []) { messages.push({ userId, text, buttons }); } };
const service = new AccessService(config, store, { telegram: provider, discord: provider });
return { config, store, service, messages, provider, advance: ms => { time += ms; }, now: () => time };
}
export async function grant(f, userId = '5') {
await f.service.request({ platform: 'telegram', user_id: userId, username: 'adventurer' });
const request = f.store.state.requests.at(-1);
await f.service.decide('telegram', '100', request.id, 'approve');
const code = f.messages.findLast(m => m.text.includes('Your number:')).text.match(/Your number: (\d+)/)[1];
return { code, request: f.store.getRequest(request.id) };
}

146
test/store.test.js Normal file
View File

@@ -0,0 +1,146 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AccessStore } from '../src/store.js';
import { fixture, grant } from './helpers.js';
test('the same number works on multiple devices for 15 days; all sessions stop at code expiry', async () => {
const f = fixture();
const { code, request } = await grant(f);
assert.equal(code.length, 10);
assert.equal(request.codeExpiresAt - request.approvedAt, 15 * 86400_000);
const first = f.store.redeem(code);
f.advance(14 * 86400_000);
const second = f.store.redeem(code);
assert.notEqual(first, second);
assert.ok(f.store.session(first));
assert.equal(f.store.session(second).expiresAt, request.codeExpiresAt);
assert.equal(JSON.stringify(f.store.state).includes(code), false);
assert.equal(JSON.stringify(f.store.state).includes(first), false);
f.advance(86400_000);
assert.equal(f.store.redeem(code), null);
assert.equal(f.store.session(first), null);
assert.equal(f.store.session(second), null);
await f.service.request({ platform: 'telegram', user_id: '5' });
assert.equal(f.store.state.requests.length, 2);
assert.equal(f.store.state.requests[1].status, 'pending');
});
test('only configured admins from the correct platform can approve; repeated reviews do not send another code', async () => {
const f = fixture();
await f.service.request({ platform: 'telegram', user_id: '5' });
const id = f.store.state.requests[0].id;
assert.match(await f.service.decide('telegram', '999', id, 'approve'), /Only configured/);
assert.match(await f.service.decide('discord', '200', id, 'approve'), /not found/);
assert.equal(f.store.getRequest(id).status, 'pending');
const results = await Promise.all([f.service.decide('telegram', '100', id, 'approve'), f.service.decide('telegram', '101', id, 'approve')]);
assert.equal(results.filter(r => r.startsWith('Approved.')).length, 1);
assert.equal(f.messages.filter(m => m.text.includes('Your number:')).length, 1);
assert.match(await f.service.decide('telegram', '100', id, 'deny'), /already approved/);
});
test('failed code delivery leaves the request retryable and the attempted number unusable', async () => {
const f = fixture();
await f.service.request({ platform: 'telegram', user_id: '5' });
const id = f.store.state.requests[0].id;
let attemptedCode;
const original = f.provider.sendUser;
f.provider.sendUser = async (_id, message) => {
attemptedCode = message.match(/Your number: (\d+)/)[1];
assert.equal(f.store.redeem(attemptedCode), null);
throw new Error('DM blocked');
};
assert.match(await f.service.decide('telegram', '100', id, 'approve'), /No number was activated/);
assert.equal(f.store.getRequest(id).status, 'pending');
assert.equal(f.store.getRequest(id).codeHash, undefined);
assert.equal(f.store.redeem(attemptedCode), null);
f.provider.sendUser = original;
assert.match(await f.service.decide('telegram', '100', id, 'approve'), /Approved/);
});
test('duplicate requests are coalesced; failed admin notifications can be retried', async () => {
const f = fixture();
const send = f.provider.sendUser;
f.provider.sendUser = async (id, ...args) => { if (id === '100' || id === '101') throw new Error('Unavailable'); return send(id, ...args); };
const identity = { platform: 'telegram', user_id: '5' };
assert.match(await f.service.request(identity), /could not contact/);
f.provider.sendUser = send;
await Promise.all([f.service.request(identity), f.service.request(identity)]);
assert.equal(f.store.state.requests.length, 1);
assert.deepEqual(f.store.state.requests[0].notifiedAdmins, ['100', '101']);
assert.equal(f.messages.filter(m => m.buttons.length === 2).length, 2);
});
test('denial, pending expiry, malformed numbers, logout and revocation fail closed', async () => {
const f = fixture();
await f.service.request({ platform: 'telegram', user_id: '5' });
const id = f.store.state.requests[0].id;
await f.service.decide('telegram', '100', id, 'deny');
assert.equal(f.store.getRequest(id).status, 'denied');
await assert.rejects(f.service.request({ platform: 'telegram', user_id: '5' }), /wait a few minutes/);
await f.service.request({ platform: 'telegram', user_id: '6' });
f.advance(f.config.requestTtlMs);
assert.match(await f.service.decide('telegram', '100', f.store.state.requests[1].id, 'approve'), /expired/);
for (const code of [null, {}, 1234567890, '', '1'.repeat(11), 'abcd123456']) assert.equal(f.store.redeem(code), null);
const { code, request } = await grant(f, '7');
const token = f.store.redeem(code);
f.store.logout(token);
assert.equal(f.store.session(token), null);
const other = f.store.redeem(code);
f.store.updateRequest(request.id, r => { r.status = 'revoked'; });
assert.equal(f.store.session(other), null);
assert.equal(f.store.redeem(code), null);
});
test('per-IP and global login limits use fixed windows and do not store raw IPs', () => {
const f = fixture({ loginAttempts: 2, globalLoginAttempts: 3 });
assert.equal(f.store.attempt('192.0.2.1'), 0);
assert.equal(f.store.attempt('192.0.2.1'), 0);
assert.equal(f.store.attempt('192.0.2.1'), 900);
assert.equal(f.store.attempt('192.0.2.2'), 0);
assert.equal(f.store.attempt('192.0.2.3'), 900);
assert.equal(JSON.stringify(f.store.state).includes('192.0.2.'), false);
f.advance(900_000);
assert.equal(f.store.attempt('192.0.2.1'), 0);
});
test('atomic storage survives restart, preserves limits, locks a second writer, and rejects corrupt data', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dng-store-'));
const f = fixture({ dataFile: join(dir, 'access.json') });
let disk;
try {
disk = new AccessStore(f.config);
const { request } = disk.request({ platform: 'telegram', user_id: '5' });
const code = disk.reserveCode(request.id, '100');
disk.updateRequest(request.id, r => { r.status = 'approved'; });
const token = disk.redeem(code);
disk.attempt('192.0.2.1');
assert.throws(() => new AccessStore(f.config), /locked/);
assert.equal(statSync(f.config.dataFile).mode & 0o777, 0o600);
assert.equal(readFileSync(f.config.dataFile, 'utf8').includes(code), false);
disk.close();
disk = new AccessStore(f.config);
assert.ok(disk.session(token));
assert.ok(disk.redeem(code));
assert.equal(disk.state.limits.global.count, 1);
disk.close();
writeFileSync(f.config.dataFile, 'invalid json');
assert.throws(() => new AccessStore(f.config));
} finally { disk?.close(); rmSync(dir, { recursive: true, force: true }); }
});
test('interrupted approval is reset on restart without activating the undelivered code', () => {
const dir = mkdtempSync(join(tmpdir(), 'dng-recovery-'));
const f = fixture({ dataFile: join(dir, 'access.json') });
let disk = new AccessStore(f.config);
try {
const { request } = disk.request({ platform: 'telegram', user_id: '5' });
const code = disk.reserveCode(request.id, '100');
disk.close();
disk = new AccessStore(f.config);
assert.equal(disk.getRequest(request.id).status, 'pending');
assert.equal(disk.redeem(code), null);
} finally { disk.close(); rmSync(dir, { recursive: true, force: true }); }
});