// A failed or refused Resi login must hold off, not retry on every poll. // // Seen at North Attleboro: with a bad password, a rate limit or a login without // decoder access, 0.1.2 signed in again on every 1s player poll. Resi answered with // 429s and then locked the account. Drives the real module code on a fake clock // against a fake Resi; touches nothing real. // // node backoff_test.js (defaults to dist/main.js) // node backoff_test.js path/to/main.js 'use strict'; const Module = require('module'); const path = require('path'); const assert = require('assert'); const target = path.resolve(process.argv[2] || path.join(__dirname, 'dist/main.js')); const stubs = { '@companion-module/base': { InstanceBase: class { updateStatus() {} log() {} setVariableValues() {} }, InstanceStatus: { Ok: 'ok', Connecting: 'c', ConnectionFailure: 'f', BadConfig: 'b' }, runEntrypoint() {}, combineRgb() { return 0 }, Regex: {}, }, './config.js': { GetConfigFields: () => [] }, './variables.js': { UpdateVariableDefinitions() {} }, './upgrades.js': { UpgradeScripts: [] }, './actions.js': { UpdateActions() {} }, './feedbacks.js': { UpdateFeedbacks() {} }, './presets.js': { UpdatePresets() {} }, }; const realLoad = Module._load; Module._load = function (req, parent, isMain) { if (stubs[req]) return stubs[req]; return realLoad.apply(this, arguments); }; let now = 1700000000000; Date.now = () => now; let mode = 'limited'; // what the fake Resi does with a login let logins = 0; let venueCalls = 0; let playerCalls = 0; const reply = (status, body, headers) => ({ ok: status >= 200 && status < 300, status, headers: { get: (k) => (headers || {})[k.toLowerCase()] ?? null, getSetCookie: () => (headers || {})['set-cookie'] || [] }, json: async () => body, text: async () => JSON.stringify(body), }); global.fetch = async (url, opts) => { const p = String(url).replace('https://central.resi.io', '').split('?')[0]; if (p === '/api/v3/login') { logins++; if (mode === 'limited') return reply(429, { message: 'Rate limit exceeded' }); if (mode === 'locked') return reply(401, { code: 'USER_LOCKED', message: 'Unauthorized' }); return reply(200, {}, { 'set-cookie': ['livingas1=T; Path=/'] }); } if (p === '/api_v2.svc/users/me') return reply(200, { customerId: 'C' }); if (p === '/api/v3/customers/C/venues') { venueCalls++; return reply(mode === 'noaccess' ? 403 : 200, [{ uuid: 'V1', name: 'NA' }]); } if (p.endsWith('/players')) { playerCalls++; return reply(mode === 'noaccess' ? 403 : 200, [{ playerHardwareId: 'DEC1', userName: 'NA', position: '00:10:00', eventId: null }]); } return reply(404, {}); }; const { ModuleInstance } = require(target); function fresh() { const inst = Object.create(ModuleInstance.prototype); inst.config = { username: 'a@b.c' }; inst.secrets = { password: 'pw' }; inst.lastLoginMs = 0; inst.selectedVenueUuid = 'V1'; inst.selectedPlayerHardwareId = 'DEC1'; inst.playerPollInFlight = false; inst.updateStatus = () => {}; inst.setVariableValues = () => {}; inst.warns = []; inst.log = (lvl, m) => { if (lvl === 'warn' || lvl === 'error') inst.warns.push(m); }; // no real timers: the test drives the polls itself inst.startPolling = () => { inst.pollingStarted = true; }; inst.startPlayerPolling = () => {}; inst.startSmoothTick = () => {}; inst.refreshCuesIfDue = async () => {}; return inst; } async function pollFor(inst, seconds) { for (let i = 0; i < seconds; i++) { now += 1000; await inst.pollPlayer(); } } let passed = 0; async function test(name, fn) { await fn(); passed++; console.log(` ok ${name}`); } (async () => { await test('a rate-limited login is not retried every poll', async () => { mode = 'limited'; logins = 0; const inst = fresh(); await pollFor(inst, 300); // five minutes of 1s polls assert.ok(logins <= 6, `signed in ${logins} times in five rate-limited minutes`); assert.ok(inst.warns.length <= 6, `logged ${inst.warns.length} warnings (one per poll would be 300)`); }); await test('a locked account says so and holds off', async () => { mode = 'locked'; logins = 0; const inst = fresh(); await pollFor(inst, 120); assert.ok(logins <= 4, `signed in ${logins} times against a locked account`); assert.ok(inst.warns.some((w) => /USER_LOCKED/.test(w)), 'the lock is named in the log'); }); await test('it recovers once Resi lets it in, and keeps one session', async () => { mode = 'limited'; logins = 0; const inst = fresh(); await pollFor(inst, 30); mode = 'ok'; await pollFor(inst, 330); // past the longest hold-off const after = logins; playerCalls = 0; await pollFor(inst, 30); assert.strictEqual(logins, after, 'a good session is not signed in again'); assert.ok(playerCalls >= 29, `polls the decoder normally once in (${playerCalls} in 30s)`); }); await test('a login without decoder access is not signed in every poll', async () => { mode = 'noaccess'; logins = 0; playerCalls = 0; const inst = fresh(); await pollFor(inst, 120); assert.ok(logins <= 3, `signed in ${logins} times in two minutes for a permission problem`); assert.ok(playerCalls <= 10, `asked for decoders ${playerCalls} times (a poll each would be 120)`); assert.ok(inst.warns.some((w) => /ask a Resi admin/.test(w)), 'says what to do about it'); }); // Seen live with 0.1.3: a locked account made init() throw, Companion restarted the // module every few seconds, and every restart signed in again from scratch. await test('init survives a locked account, and recovers once it is unlocked', async () => { mode = 'locked'; logins = 0; const inst = fresh(); inst.config.defaultPlayerHardwareId = 'DEC1'; inst.venues = []; inst.playersByVenueUuid = {}; await inst.init(inst.config, true, inst.secrets); // must not throw assert.ok(inst.pollingStarted, 'polling starts even though Resi refused'); for (let i = 0; i < 60; i++) { now += 10000; await inst.pollOnce(false); } // ten minutes of account polls // 0s, 15s, 45s, 105s, 225s, 465s: six. The restart loop did about one every 6s. assert.ok(logins <= 6, `signed in ${logins} times in ten locked minutes`); mode = 'ok'; now += 300000; await inst.pollOnce(false); assert.strictEqual(inst.selectedVenueUuid, 'V1', 'picks up the configured decoder by itself'); }); console.log(`\n${passed} tests passed`); })().catch((e) => { console.error(e); process.exit(1); });