/** * Tests for hoover/websocket.js handleMessage() — WS data streaming. * * handleMessage is driven through a real connect() against a stubbed * globalThis.WebSocket: we record the constructed instance and call its * onmessage handler with serialized daemon→client messages, then assert the * reactive model state. Covers the snapshot fast path, per-subsystem deltas * (including the networkd→network mapping), null-payload guards, and that * retired/legacy message types are ignored (no model mutation, no throw). * * websocket.js pulls in model.js → reactivity.js and auth_model.js * (DOM-free at import), so it runs under plain node with stubbed globals. * * Run with `node tests/test-ws-handler.js`. */ import { connect } from '../webui/static/hoover/websocket.js'; import { modelRegister, getModel, modelSet } from '../webui/static/hoover/model.js'; import { SUBSYSTEMS } from '../webui/static/hoover/schema.js'; let passed = 0; let failed = 0; const tests = []; function test(name, fn) { tests.push({ name, fn }); } function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); } function assertEq(a, b, msg) { if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); } /** Deep equality for objects/arrays (assertEq is reference-based). */ function assertDeep(a, b, msg) { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); } /* ── Stubs ─────────────────────────────────────────────────── */ function makeStorage(initial = {}) { const m = new Map(Object.entries(initial)); return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), keys: () => [...m.keys()], }; } // Stub the TTL refresh timer so the node process never waits on it. const _timers = []; globalThis.setTimeout = (fn, ms) => { _timers.push({ fn, ms }); return _timers.length; }; globalThis.clearTimeout = (id) => { if (id && _timers[id - 1]) _timers[id - 1] = undefined; }; globalThis.location = { protocol: 'http:', host: '127.0.0.1:9090' }; globalThis.sessionStorage = makeStorage({ 'vw:access': 'tok-abc.def.ghi' }); globalThis.document = { location: { hash: '#/dashboard' } }; globalThis.window = { dispatchEvent: () => {}, addEventListener: () => {} }; /** Records each constructed WebSocket so tests can drive onmessage. */ class FakeWebSocket { static instances = []; constructor(url, protocols) { this.url = url; this.protocols = protocols; this.readyState = 1; this.onopen = null; this.onclose = null; this.onerror = null; this.onmessage = null; FakeWebSocket.instances.push(this); } close() { this.readyState = 3; } send() {} } globalThis.WebSocket = FakeWebSocket; /** Register the auth + state models the WS handler depends on. */ function setupModels() { modelRegister('auth', { subsystem: 'auth', fetch: async () => ({}) }); modelSet('auth', { token: 'tok-abc.def.ghi', user: { username: 'admin' } }); for (const [name, subsystem] of [ ['firewall', 'firewall'], ['dnsmasq', 'dnsmasq'], ['nginx', 'nginx'], ['acme', 'acme'], ['wireguard', 'wireguard'], ['network', 'networkd'], ['system', 'system'], ]) { modelRegister(name, { subsystem, defaultData: SUBSYSTEMS[subsystem].defaults, fetch: async () => ({}), }); } } /** Establish a fresh WS connection and return the recorded instance. */ function freshConnect() { const prev = FakeWebSocket.instances.at(-1); if (prev && prev.readyState <= 1) { prev.onclose = null; prev.close(); } connect(); return FakeWebSocket.instances.at(-1); } /** Feed one daemon→client message through the recorded instance. */ function emit(inst, msg) { inst.onmessage({ data: JSON.stringify(msg) }); } const SNAPSHOT = { type: 'snapshot', data: { firewall: { zones: { public: {} } }, dnsmasq: { leases: [] }, nginx: null, // collector failed → must be skipped acme: { certs: [] }, wireguard: { up: true }, networkd: { interfaces: { eth0: {} } }, system: { load: { load1: 0.5 } }, }, }; /* ── Tests ─────────────────────────────────────────────────── */ test('connect() sends the raw JWT as the Sec-WebSocket-Protocol subprotocol', () => { setupModels(); const inst = freshConnect(); assert(inst, 'a WS instance was constructed'); assertEq(inst.url, 'ws://127.0.0.1:9090/ws', 'WS URL from origin'); assert(Array.isArray(inst.protocols), 'subprotocols passed'); assertEq(inst.protocols[0], 'tok-abc.def.ghi', 'bare JWT (no Bearer prefix)'); }); test('snapshot fast path sets every non-null model; null entries are skipped', () => { setupModels(); const inst = freshConnect(); emit(inst, SNAPSHOT); assertDeep(getModel('firewall').data.zones?.public, {}, 'firewall patched'); assertDeep(getModel('dnsmasq').data.leases, [], 'dnsmasq patched'); assertDeep(getModel('acme').data.certs, [], 'acme patched'); assertEq(getModel('wireguard').data.up, true, 'wireguard patched'); assertDeep(getModel('network').data.interfaces?.eth0, {}, 'networkd mapped → network'); assertEq(getModel('system').data.load?.load1, 0.5, 'system patched'); // nginx data was null — modelSet was skipped entirely. const nginx = getModel('nginx'); assertDeep(nginx.data, SUBSYSTEMS.nginx.defaults, 'null entry keeps schema defaults'); assertEq(nginx.loading, true, 'null entry never clears loading'); // Non-null models had loading cleared by modelSet. assertEq(getModel('firewall').loading, false, 'loading cleared on real data'); }); test('versions delta patches the mapped subsystem model', () => { setupModels(); const inst = freshConnect(); const before = JSON.stringify(getModel('firewall').data); emit(inst, { type: 'versions', subsystem: 'firewall', data: { zones: { dmz: {} } } }); assert(getModel('firewall').data.zones?.dmz !== undefined, 'firewall delta applied'); assertEq(JSON.stringify(getModel('dnsmasq').data), JSON.stringify(SUBSYSTEMS.dnsmasq.defaults), 'other models untouched'); }); test('networkd delta maps to the network model', () => { setupModels(); const inst = freshConnect(); emit(inst, { type: 'versions', subsystem: 'networkd', data: { interfaces: { lo: {} } } }); assertDeep(getModel('network').data.interfaces?.lo, {}, 'networkd → network'); }); test('a null payload delta never overwrites good data (defense in depth)', () => { setupModels(); const inst = freshConnect(); emit(inst, SNAPSHOT); // networkd has data const kept = getModel('network').data; emit(inst, { type: 'tick', subsystem: 'networkd', data: null }); assertDeep(getModel('network').data, kept, 'null data left model untouched'); emit(inst, { type: 'versions', subsystem: 'firewall', data: null }); assertDeep(getModel('firewall').data.zones?.public, {}, 'null firewall data left model untouched'); }); test('retired/legacy message types are ignored (no mutation, no throw)', () => { setupModels(); const inst = freshConnect(); emit(inst, SNAPSHOT); const before = {}; for (const n of ['firewall', 'dnsmasq', 'nginx', 'acme', 'wireguard', 'network', 'system']) { before[n] = JSON.stringify(getModel(n).data) + '|' + getModel(n).loading; } const legacy = [ { type: 'versions', updated: { firewall: 1 } }, // legacy dict form { type: 'tick', subsystems: ['firewall', 'wireguard'] }, // legacy array form { type: 'refresh', topic: 'firewall' }, { type: 'notify', topic: 'firewall' }, { type: 'status', topic: 'firewall' }, { type: 'unknown' }, ]; for (const msg of legacy) emit(inst, msg); for (const n of Object.keys(before)) { const now = JSON.stringify(getModel(n).data) + '|' + getModel(n).loading; assertEq(now, before[n], `model ${n} unchanged by legacy message`); } }); /* ── Runner ────────────────────────────────────────────────── */ (async () => { for (const { name, fn } of tests) { try { await fn(); console.log(` \u2713 ${name}`); passed++; } catch (e) { console.error(` \u2717 ${name}: ${e.message}`); failed++; } } console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`); process.exitCode = failed ? 1 : 0; })();