Files
vacuum-wall/tests/test-ws-handler.js
T
mteehan 332d14e37d ws: migrate push stream to data streaming
- daemon: send full snapshot on connect; versions/tick now carry the
  full state of one subsystem (subsystem + data); no legacy
  updated/subsystems payloads; refresh_state and POST /status/refresh
  broadcast per-subsystem versions with data
- client: modelSet() patches models in place; onMessage/topic refresh
  retired; 3s initial-load fallback via new POST /api/status/refresh
- schema: lib/schema.py TypedDicts + hoover/schema.js defaults +
  docs/state-model.md as single source of truth for state shapes
- system: poll at 1s, volatile metrics registered, dashboard uses a
  dedicated system model (status model removed)
- firewall: refuse to strip both https and ssh from the default zone
  (409, force override via UI confirm); set_zone_services persists
  services to the declarative config; collector exposes default_zone
- UI: pages migrate to flat state shapes; post-mutation modelFetch
  refreshes removed (WS delta covers it)
- tests: ws snapshot/delta/broadcast, refresh-state, schema types,
  model-set/js ws handler and reconnect fallback
2026-08-20 01:38:00 +00:00

221 lines
8.6 KiB
JavaScript

/**
* 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;
})();