screentinker/server/test/operator-block.test.js
ScreenTinker 97d489223f fix(#146) D: operator block — close the device_id-less gap + dashboard toggle
- Enforcement (deviceSocket): resolve identity ONCE via the SNAT-safe chain and check
  blocked against the RESOLVED device_id (device_id directly OR fingerprint->device_id),
  so a blocked device that reconnects WITHOUT a device_id is still caught — the old
  "if (device_id)" gate let a device_id-less reconnect slip past. Still the first gate,
  before flap/throttle/DB/playlist. Nulling the token still does NOT block (it
  re-provisions) — the blocked column is the lever.
- Dashboard toggle: POST /api/devices/:id/{block,unblock} (write-gated + workspace-scoped
  via checkDeviceOwnership) writes devices.blocked; takes effect on the device's NEXT
  register with no restart. api.js + a Block/Unblock button in device-detail.js.
- Outage procedure documented in-code: direct SQLite
  "UPDATE devices SET blocked = 1 WHERE id = <id>" works with the dashboard down.

Tests: blocked refused at handshake with no playlist build; device_id-less reconnect
with a mapped fingerprint still refused; unblock effective on next register, no restart.
Suite 262/262.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:28:04 -05:00

73 lines
3.5 KiB
JavaScript

'use strict';
// #146 hardening (Item D) — operator block kill switch. Enforced at the device:register
// handshake BEFORE throttle/DB/playlist. #146 fix: resolve identity via the fallback
// chain so a blocked device that reconnects WITHOUT a device_id (but with a mapped
// fingerprint) is STILL caught (the old `if (device_id)` gate let it slip). Unblock takes
// effect on the next register with NO restart. In-process.
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-block-' + crypto.randomBytes(4).toString('hex'));
process.env.SELF_HOSTED = 'true';
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { Server } = require('socket.io');
const ioClient = require('socket.io-client');
const { db } = require('../db/database');
const setupDeviceSocket = require('../ws/deviceSocket');
let httpServer, io, base;
before(async () => {
db.pragma('foreign_keys = OFF');
db.prepare("INSERT INTO devices (id, device_token, status, blocked) VALUES ('blk-dev', 'tok', 'offline', 1)").run();
db.prepare("INSERT OR REPLACE INTO device_fingerprints (fingerprint, device_id) VALUES ('fp-blk', 'blk-dev')").run();
db.pragma('foreign_keys = ON');
httpServer = http.createServer();
io = new Server(httpServer);
setupDeviceSocket(io);
await new Promise((r) => httpServer.listen(0, r));
base = `http://127.0.0.1:${httpServer.address().port}`;
});
after(() => { try { io.close(); } catch { /* */ } try { httpServer.close(); } catch { /* */ } });
const connect = () => ioClient(`${base}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
// register(payload) -> { authError, registered, gotPlaylist }
function register(payload) {
return new Promise((resolve) => {
const s = connect();
let done = false, gotPlaylist = false;
const fin = (r) => { if (done) return; done = true; try { s.close(); } catch { /* */ } resolve({ ...r, gotPlaylist }); };
s.on('device:playlist-update', () => { gotPlaylist = true; });
s.on('connect', () => s.emit('device:register', payload));
s.on('device:auth-error', (e) => fin({ authError: e && e.error }));
s.on('device:registered', () => setTimeout(() => fin({ registered: true }), 100));
setTimeout(() => fin({ timeout: true }), 2000);
});
}
test('a blocked device is refused at handshake, cheaply (no playlist build)', async () => {
const r = await register({ device_id: 'blk-dev', device_token: 'tok' });
assert.equal(r.authError, 'Device blocked', 'refused with the block error');
assert.ok(!r.registered, 'never registered');
assert.ok(!r.gotPlaylist, 'no playlist was built — short-circuited before heavy work');
});
test('a blocked device reconnecting WITHOUT device_id but with a mapped fingerprint is still refused', async () => {
const r = await register({ fingerprint: 'fp-blk' }); // no device_id — resolves via device_fingerprints
assert.equal(r.authError, 'Device blocked', 'caught via the fingerprint->device_id identity chain');
assert.ok(!r.registered);
});
test('unblock takes effect on the NEXT register, no restart', async () => {
db.prepare("UPDATE devices SET blocked = 0 WHERE id = 'blk-dev'").run();
const r = await register({ device_id: 'blk-dev', device_token: 'tok' });
assert.ok(r.registered, 'registers once unblocked — same running server, no restart');
assert.ok(!r.authError);
});