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
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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;
|
||||
})();
|
||||
Reference in New Issue
Block a user