screentinker/server/test/ota-check.test.js
screentinker 1ebdb1f7a9
feat(ota): self-update kill switch — global, per-device, and MDM auto-detect (#166)
Lets an operator (or an MDM) own updates instead of the app self-installing, which
on managed panels shows a self-install confirm dialog over customer content
(#155). Three layered controls:

- GLOBAL (server): config.otaEnabled from OTA_ENABLED (default on). When off,
  /api/update/check returns update_available:false, reason:ota_disabled_global —
  the whole instance stops offering updates.
- PER-DEVICE (server + dashboard): new devices.ota_enabled column (default 1). When
  0, that device is never offered an update (reason:ota_disabled_device). A
  "Self-update (OTA)" toggle in the device settings flips it via PUT /api/devices/:id.
- AUTO-DETECT (Android): UpdateChecker stands down entirely when a foreign device
  owner (an MDM/DPC) manages the panel — detected via getActiveAdmins() + not being
  device owner ourselves. Pure client-side, errs safe, needs no server change.

The two server gates are enforced server-side so they cover EVERY client version,
not just ones with the client-side stand-down. When OTA is off the device still
reports its version (dashboard sees state); the MDM/operator owns the actual update.

For an MDM-managed fleet (e.g. Pivot/MAXHUB), turn OTA off and let the MDM push the
APK — the install-dialog race disappears from every angle.

Tests: +2 (per-device gate + a real OTA_ENABLED=false server for the global gate);
full server suite 393 pass; Android compiles.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:38:58 -05:00

103 lines
5.5 KiB
JavaScript

'use strict';
// #144 — HTTP integration: the real /api/update/check endpoint with the breaker wired.
// Proves end-to-end behavior + the device_id passthrough/keying. Rapid requests stay
// within the 60s rate window, so THRESHOLD(3) trips on the 4th. Unique PORT 3991.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const PORT = 3991;
const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-ota-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-ota-' + crypto.randomBytes(4).toString('hex') + '.log');
let proc, LATEST;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const check = async (version, deviceId) => {
const q = `version=${encodeURIComponent(version)}` + (deviceId ? `&device_id=${encodeURIComponent(deviceId)}` : '');
const r = await fetch(`${BASE}/api/update/check?${q}`);
return r.json();
};
before(async () => {
// the breaker only reports update_available when an APK actually exists — give the
// test server a dummy one (resolveApkPath checks DATA_DIR/ScreenTinker.apk).
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(path.join(DATA_DIR, 'ScreenTinker.apk'), Buffer.alloc(1024, 1));
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, stdio: ['ignore', logFd, logFd] });
let up = false;
for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await sleep(250); }
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
LATEST = (await check('0.0.1')).latest_version; // an ancient version reads back the server's latest
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
test('a device already on latest gets no offer (up-to-date)', async () => {
const r = await check(LATEST);
assert.equal(r.update_available, false);
assert.equal(r.reason, 'up-to-date');
});
test('(a) phantom version (superseded old-core prerelease) -> instant no-offer over HTTP', async () => {
const r = await check('1.9.1-beta4');
assert.equal(r.update_available, false);
assert.equal(r.reason, 'superseded-prerelease');
});
test('(b/f) legacy client (no device_id) looping the same version trips the version-keyed breaker', async () => {
const v = '1.6.0'; // fresh offerable older version, no device_id
const results = [];
for (let i = 0; i < 5; i++) results.push(await check(v)); // rapid, within the 60s window
assert.ok(results.slice(0, 3).every(r => r.update_available === true), 'first 3 offered');
assert.equal(results[3].update_available, false, '4th trips');
assert.equal(results[3].reason, 'rate-backoff');
assert.ok(results[3].retry_after_seconds >= 1, 'response carries retry_after_seconds');
});
test('(e) device_id looping is throttled per-device; another device on the same version is unaffected', async () => {
const v = '1.5.0';
for (let i = 0; i < 3; i++) await check(v, 'devA');
const aTrip = await check(v, 'devA'); // devA 4th -> trips
assert.equal(aTrip.update_available, false, 'devA throttled');
const bOk = await check(v, 'devB'); // devB first check -> offered
assert.equal(bOk.update_available, true, 'devB (same version, different device) unaffected');
});
// #155/#161 self-update kill switch
test('per-device OTA off (devices.ota_enabled=0) -> never offered (reason ota_disabled_device); an enabled device still is', async () => {
const Database = require('better-sqlite3');
const db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'), { timeout: 5000 });
db.prepare('INSERT INTO devices (id, ota_enabled) VALUES (?, 0)').run('ota-off-dev');
db.prepare('INSERT INTO devices (id, ota_enabled) VALUES (?, 1)').run('ota-on-dev');
db.close();
const off = await check('1.4.0', 'ota-off-dev');
assert.equal(off.update_available, false, 'OTA-disabled device is not offered an update');
assert.equal(off.reason, 'ota_disabled_device');
const on = await check('1.4.1', 'ota-on-dev');
assert.equal(on.update_available, true, 'OTA-enabled device is still offered');
});
test('global OTA off (OTA_ENABLED=false) -> no device is offered (reason ota_disabled_global)', async () => {
const P2 = 3992;
const DD2 = path.join(os.tmpdir(), 'st-ota2-' + crypto.randomBytes(4).toString('hex'));
fs.mkdirSync(DD2, { recursive: true });
fs.writeFileSync(path.join(DD2, 'ScreenTinker.apk'), Buffer.alloc(1024, 1));
const p2 = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR: DD2, SELF_HOSTED: 'true', PORT: String(P2), NODE_ENV: 'test', OTA_ENABLED: 'false' }, stdio: 'ignore' });
try {
let up = false;
for (let i = 0; i < 80; i++) { try { const r = await fetch(`http://127.0.0.1:${P2}/api/status`); if (r.ok) { up = true; break; } } catch { /* */ } await sleep(250); }
assert.ok(up, 'OTA_ENABLED=false server booted');
const r = await (await fetch(`http://127.0.0.1:${P2}/api/update/check?version=1.0.0`)).json();
assert.equal(r.update_available, false, 'global-off: no update offered');
assert.equal(r.reason, 'ota_disabled_global');
} finally {
try { p2.kill('SIGKILL'); } catch { /* */ }
}
});