159 lines
6.5 KiB
JavaScript
159 lines
6.5 KiB
JavaScript
// 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); });
|