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,111 @@
|
||||
/**
|
||||
* Tests for hoover/model.js modelSet() — the WS data-streaming entry point.
|
||||
*
|
||||
* modelSet() bypasses the fetch cycle: it assigns directly to the reactive
|
||||
* model, clears loading unconditionally (schema defaults mean model.data is
|
||||
* never null), clears error, and never sets refreshing.
|
||||
*
|
||||
* model.js imports only reactivity.js — DOM-free at import, so the tests
|
||||
* run under plain node (same pattern as test-auth-model.js).
|
||||
*
|
||||
* Run with `node tests/test-model-set.js`.
|
||||
*/
|
||||
|
||||
import { modelRegister, modelFetch, 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)}`);
|
||||
}
|
||||
|
||||
/* ── Tests ─────────────────────────────────────────────────── */
|
||||
|
||||
test('modelSet assigns data and clears loading unconditionally', () => {
|
||||
modelRegister('firewall', {
|
||||
subsystem: 'firewall',
|
||||
defaultData: SUBSYSTEMS.firewall.defaults,
|
||||
fetch: async () => ({}),
|
||||
});
|
||||
const m = getModel('firewall');
|
||||
assertEq(m.loading, true, 'registering model is loading');
|
||||
assertEq(m.data, SUBSYSTEMS.firewall.defaults, 'schema defaults pre-populated');
|
||||
assertEq(m.refreshing, false, 'not refreshing at rest');
|
||||
|
||||
modelSet('firewall', { zones: { public: {} }, pending: { pending: [] } });
|
||||
|
||||
assertEq(m.loading, false, 'real data ends the initial load');
|
||||
assertEq(m.refreshing, false, 'modelSet never sets refreshing');
|
||||
assertEq(m.error, null, 'modelSet clears error');
|
||||
assertDeep(m.data.zones?.public, {}, 'data assigned to the reactive model');
|
||||
});
|
||||
|
||||
test('modelSet is a no-op for an unregistered name', () => {
|
||||
assertEq(typeof modelSet('no-such-model', { x: 1 }), 'undefined', 'no throw, no return');
|
||||
});
|
||||
|
||||
test('modelSet clears a fetch-set error and replaces error-state data', async () => {
|
||||
modelRegister('dnsmasq', {
|
||||
subsystem: 'dnsmasq',
|
||||
defaultData: SUBSYSTEMS.dnsmasq.defaults,
|
||||
fetch: async () => { throw new Error('boom'); },
|
||||
});
|
||||
const m = getModel('dnsmasq');
|
||||
await modelFetch('dnsmasq');
|
||||
assertEq(m.error, 'boom', 'fetch failure sets error');
|
||||
assertEq(m.loading, false, 'fetch failure clears loading');
|
||||
assertEq(m.data, SUBSYSTEMS.dnsmasq.defaults, 'failed fetch keeps schema defaults');
|
||||
|
||||
const delta = { leases: [{ mac: 'aa:bb', ip: '10.0.0.9' }] };
|
||||
modelSet('dnsmasq', delta);
|
||||
assertEq(m.error, null, 'subsequent real data clears the error');
|
||||
assertEq(m.data.leases?.[0]?.mac, 'aa:bb', 'delta replaces default data');
|
||||
});
|
||||
|
||||
test('modelSet works repeatedly without flag corruption', () => {
|
||||
modelRegister('system', {
|
||||
subsystem: 'system',
|
||||
defaultData: SUBSYSTEMS.system.defaults,
|
||||
fetch: async () => ({}),
|
||||
});
|
||||
const m = getModel('system');
|
||||
modelSet('system', { load: { load1: 0.1 } });
|
||||
modelSet('system', { load: { load1: 0.2 } });
|
||||
assertEq(m.loading, false, 'still not loading');
|
||||
assertEq(m.refreshing, false, 'still not refreshing');
|
||||
assertEq(m.error, null, 'no error');
|
||||
assertEq(m.data.load?.load1, 0.2, 'latest delta wins');
|
||||
});
|
||||
|
||||
/* ── 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;
|
||||
})();
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Tests for the 3-second HTTP-fallback contract (Phase 3e).
|
||||
*
|
||||
* app.js cannot be imported under node (it imports every page and touches
|
||||
* the DOM), so this covers the model-layer contract the deferred fetch
|
||||
* decides on:
|
||||
*
|
||||
* - a model registered with schema defaults keeps `loading: true`
|
||||
* (data is never null — that's why the guard is `if (model.loading)`)
|
||||
* - `modelSet()` or a completed `modelFetch()` clears `loading`
|
||||
* - the `if (model.loading) modelFetch(name)` decision fires ONLY for
|
||||
* still-loading models — a WS-delivered snapshot suppresses the HTTP
|
||||
* fallback for that model
|
||||
*
|
||||
* Uses a fake setTimeout queue and recording fetch stubs (no timers, no
|
||||
* network) — same pattern as test-auth-model.js.
|
||||
*
|
||||
* Run with `node tests/test-reconnect-fallback.js`.
|
||||
*/
|
||||
|
||||
import { modelRegister, modelFetch, 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 ─────────────────────────────────────────────────── */
|
||||
|
||||
/** Fake setTimeout queue — captures scheduled fallbacks, never runs them. */
|
||||
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; };
|
||||
|
||||
/** Drain pending timers in order, like a real 3s elapse. */
|
||||
async function runTimers() {
|
||||
while (_timers.length) {
|
||||
const t = _timers.shift();
|
||||
if (t) await t.fn();
|
||||
}
|
||||
}
|
||||
|
||||
/** Register one state-backed model with recording fetch. */
|
||||
function registerModel(name) {
|
||||
const calls = { count: 0 };
|
||||
modelRegister(name, {
|
||||
subsystem: name,
|
||||
defaultData: SUBSYSTEMS[name].defaults,
|
||||
fetch: async () => {
|
||||
calls.count++;
|
||||
return { fetched: true, name };
|
||||
},
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
/* ── Mirrors app.js fetchInitialData() decision logic ───────── */
|
||||
|
||||
/** Schedule the per-model 3s fallback timers (as app.js does). */
|
||||
function scheduleFallbacks(names) {
|
||||
for (const name of names) {
|
||||
setTimeout(() => {
|
||||
const model = getModel(name);
|
||||
if (model.loading) {
|
||||
modelFetch(name);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tests ─────────────────────────────────────────────────── */
|
||||
|
||||
test('schema-defaulted model: loading stays true until modelSet or fetch completes', () => {
|
||||
registerModel('firewall');
|
||||
const m = getModel('firewall');
|
||||
assertEq(m.loading, true, 'fresh model is loading');
|
||||
assertEq(m.data, SUBSYSTEMS.firewall.defaults, 'data is schema defaults (never null)');
|
||||
assertEq(m.error, null, 'no error yet');
|
||||
|
||||
modelSet('firewall', { zones: { public: {} } });
|
||||
assertEq(m.loading, false, 'modelSet clears loading');
|
||||
|
||||
registerModel('dnsmasq');
|
||||
const d = getModel('dnsmasq');
|
||||
assertEq(d.loading, true, 'a different fresh model is still loading');
|
||||
});
|
||||
|
||||
test('3s fallback fetches only models still loading (snapshot suppresses HTTP)', async () => {
|
||||
const firewallCalls = registerModel('firewall');
|
||||
const dnsmasqCalls = registerModel('dnsmasq');
|
||||
|
||||
scheduleFallbacks(['firewall', 'dnsmasq']);
|
||||
// The WS snapshot arrived first for firewall only.
|
||||
modelSet('firewall', { zones: { internal: {} } });
|
||||
|
||||
await runTimers();
|
||||
|
||||
assertEq(firewallCalls.count, 0, 'snapshot-delivered model: no HTTP fallback');
|
||||
assertEq(dnsmasqCalls.count, 1, 'still-loading model: HTTP fallback fired');
|
||||
|
||||
const fw = getModel('firewall');
|
||||
assertEq(fw.loading, false, 'firewall not loading');
|
||||
assertDeep(fw.data.zones?.internal, {}, 'firewall keeps the snapshot data, not fetch output');
|
||||
|
||||
const dm = getModel('dnsmasq');
|
||||
assertEq(dm.loading, false, 'completed fetch clears loading');
|
||||
assertEq(dm.data.fetched, true, 'dnsmasq got the fallback data');
|
||||
});
|
||||
|
||||
test('a re-fired decision never double-fetches a settled model', async () => {
|
||||
const calls = registerModel('acme');
|
||||
scheduleFallbacks(['acme']);
|
||||
await runTimers();
|
||||
assertEq(calls.count, 1, 'first fallback fetch');
|
||||
|
||||
// A later decision pass (e.g. reconnect path) must not re-fetch.
|
||||
const model = getModel('acme');
|
||||
if (model.loading) modelFetch('acme');
|
||||
await runTimers();
|
||||
assertEq(calls.count, 1, 'no double fetch once settled');
|
||||
});
|
||||
|
||||
test('fallback fetch failure lands in model.error, self-heals on next data', async () => {
|
||||
modelRegister('wireguard', {
|
||||
subsystem: 'wireguard',
|
||||
defaultData: SUBSYSTEMS.wireguard.defaults,
|
||||
fetch: async () => { throw new Error('state not populated yet'); },
|
||||
});
|
||||
const m = getModel('wireguard');
|
||||
|
||||
setTimeout(() => { if (m.loading) modelFetch('wireguard'); }, 3000);
|
||||
await runTimers();
|
||||
|
||||
assertEq(m.error, 'state not populated yet', 'failure sets model.error');
|
||||
assertEq(m.loading, false, 'failure still clears loading (finally)');
|
||||
assertEq(m.data, SUBSYSTEMS.wireguard.defaults, 'schema defaults preserved on failure');
|
||||
|
||||
modelSet('wireguard', { up: false });
|
||||
assertEq(m.error, null, 'next real data clears the error');
|
||||
assertEq(m.data.up, false, 'real data lands');
|
||||
});
|
||||
|
||||
/* ── 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;
|
||||
})();
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -40,6 +40,11 @@ def _ne(func, **kw):
|
||||
return _patch(f"webui.api.network.{func}", **kw)
|
||||
|
||||
|
||||
def _st(func, **kw):
|
||||
"""Patch daemon.client.{func} in the status blueprint namespace."""
|
||||
return _patch(f"webui.api.status.{func}", **kw)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from flask import Flask
|
||||
@@ -883,3 +888,55 @@ class TestNetworkApplyAll:
|
||||
mock_post.side_effect = RuntimeError("apply failed")
|
||||
resp = client.post("/api/network/apply")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Status
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def status_client():
|
||||
from flask import Flask
|
||||
|
||||
from webui.api.status import bp as status_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(status_bp, url_prefix="/api/status")
|
||||
return app.test_client()
|
||||
|
||||
|
||||
class TestStatusRefresh:
|
||||
def test_filtered_subsystems_passed_through(self, status_client):
|
||||
"""The subsystem body is forwarded to the daemon POST endpoint."""
|
||||
from daemon.iface import POST_STATUS_REFRESH
|
||||
|
||||
with _st("post") as mock_post:
|
||||
mock_post.return_value = {"firewall": {"zones": {}}}
|
||||
resp = status_client.post(
|
||||
"/api/status/refresh", json={"subsystems": ["firewall"]}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"] == {"firewall": {"zones": {}}}
|
||||
mock_post.assert_called_once_with(
|
||||
POST_STATUS_REFRESH, {"subsystems": ["firewall"]}
|
||||
)
|
||||
|
||||
def test_empty_body_forwards_empty_dict(self, status_client):
|
||||
"""An empty body becomes {} (daemon-side 'all subsystems' default)."""
|
||||
from daemon.iface import POST_STATUS_REFRESH
|
||||
|
||||
with _st("post") as mock_post:
|
||||
mock_post.return_value = {}
|
||||
resp = status_client.post("/api/status/refresh")
|
||||
assert resp.status_code == 200
|
||||
mock_post.assert_called_once_with(POST_STATUS_REFRESH, {})
|
||||
|
||||
@_st("post")
|
||||
def test_runtime_error(self, mock_post, status_client):
|
||||
mock_post.side_effect = RuntimeError("no daemon")
|
||||
resp = status_client.post("/api/status/refresh", json={})
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Tests for broadcast_versions per-subsystem contract (daemon.server).
|
||||
|
||||
broadcast_versions(subsystem) sends exactly one data-carrying message for
|
||||
its subsystem — no legacy `updated` field — and skips the broadcast
|
||||
entirely when the subsystem's state is None (collector failed).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import daemon.server as server
|
||||
|
||||
|
||||
class TestBroadcastVersionsPerSubsystem:
|
||||
def _ws(self):
|
||||
ws = AsyncMock()
|
||||
ws.send_str = AsyncMock()
|
||||
server._ws_subscribers.add(ws)
|
||||
return ws
|
||||
|
||||
def test_only_target_subsystem_sent(self):
|
||||
"""Each subscriber gets one versions message carrying that subsystem."""
|
||||
store = MagicMock()
|
||||
store.get.side_effect = lambda name: {"v": name}
|
||||
ws = self._ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
asyncio.run(server.broadcast_versions("firewall"))
|
||||
ws.send_str.assert_awaited_once()
|
||||
msg = json.loads(ws.send_str.call_args[0][0])
|
||||
assert msg == {
|
||||
"type": "versions",
|
||||
"subsystem": "firewall",
|
||||
"data": {"v": "firewall"},
|
||||
}
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
def test_data_per_subsystem_not_shared(self):
|
||||
"""The data payload is that subsystem's state, not another's."""
|
||||
store = MagicMock()
|
||||
store.get.side_effect = lambda name: {"name": name}
|
||||
ws = self._ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
asyncio.run(server.broadcast_versions("dnsmasq"))
|
||||
asyncio.run(server.broadcast_versions("acme"))
|
||||
msgs = [json.loads(c[0][0]) for c in ws.send_str.call_args_list]
|
||||
assert [(m["subsystem"], m["data"]) for m in msgs] == [
|
||||
("dnsmasq", {"name": "dnsmasq"}),
|
||||
("acme", {"name": "acme"}),
|
||||
]
|
||||
# No legacy diff field in any message.
|
||||
for m in msgs:
|
||||
assert "updated" not in m
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
def test_none_state_produces_no_message(self):
|
||||
"""A None payload (failed collection) is skipped — no clobber."""
|
||||
store = MagicMock()
|
||||
store.get.return_value = None
|
||||
ws = self._ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
asyncio.run(server.broadcast_versions("firewall"))
|
||||
ws.send_str.assert_not_awaited()
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
def test_no_bump_called(self):
|
||||
"""broadcast_versions never bumps — callers own the version counter."""
|
||||
store = MagicMock()
|
||||
store.get.return_value = {"a": 1}
|
||||
store.bump = MagicMock()
|
||||
ws = self._ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
asyncio.run(server.broadcast_versions("firewall"))
|
||||
store.bump.assert_not_called()
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
def test_dead_subscriber_removed(self):
|
||||
"""A failing subscriber is pruned and healthy ones still receive data."""
|
||||
store = MagicMock()
|
||||
store.get.return_value = {"a": 1}
|
||||
dead = AsyncMock()
|
||||
dead.send_str = AsyncMock(side_effect=Exception("broken"))
|
||||
healthy = self._ws()
|
||||
server._ws_subscribers.add(dead)
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
asyncio.run(server.broadcast_versions("firewall"))
|
||||
assert dead not in server._ws_subscribers
|
||||
healthy.send_str.assert_awaited_once()
|
||||
finally:
|
||||
server._ws_subscribers.discard(dead)
|
||||
server._ws_subscribers.discard(healthy)
|
||||
+146
-2
@@ -1,11 +1,11 @@
|
||||
"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary)."""
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from daemon.handlers import firewall as daemonfirewall
|
||||
from daemon.server import NotFoundError
|
||||
from daemon.server import ConflictError, NotFoundError
|
||||
from lib import firewall
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -467,6 +467,150 @@ class TestDaemonConfigApply:
|
||||
assert result["applied_zones"] == ["public"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Management-lockout guard: default zone must keep https or ssh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDaemonMgmtLockoutGuard:
|
||||
ZONES_OUT = "public\ninternal"
|
||||
|
||||
@patch("daemon.handlers.firewall._default_zone", return_value="public")
|
||||
def test_set_zone_services_blocks_default_zone(self, mock_dz):
|
||||
with (
|
||||
patch(
|
||||
"daemon.handlers.firewall.run", return_value=self.ZONES_OUT
|
||||
) as mock_run,
|
||||
pytest.raises(ConflictError) as exc,
|
||||
):
|
||||
daemonfirewall.set_zone_services(
|
||||
None, {"zone": "public", "services": ["http"]}
|
||||
)
|
||||
assert "https and ssh" in str(exc.value)
|
||||
# Guard fires before any mutation: only the zone-existence check ran.
|
||||
assert mock_run.call_args_list == [
|
||||
call(["firewall-cmd", "--get-zones"], sudo=True),
|
||||
]
|
||||
|
||||
@patch("daemon.handlers.firewall._default_zone", return_value="public")
|
||||
def test_set_zone_services_force_bypasses_guard(self, mock_dz):
|
||||
with (
|
||||
patch("daemon.handlers.firewall.run", return_value=self.ZONES_OUT),
|
||||
patch.object(
|
||||
daemonfirewall, "_parse_zone_output", return_value={"services": []}
|
||||
),
|
||||
patch.object(daemonfirewall, "_reload"),
|
||||
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
|
||||
patch.object(daemonfirewall, "_save_config") as mock_save,
|
||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
result = daemonfirewall.set_zone_services(
|
||||
None, {"zone": "public", "services": ["http"], "force": True}
|
||||
)
|
||||
assert result == {"zone": "public", "services": ["http"]}
|
||||
cfg = mock_save.call_args[0][0]
|
||||
assert cfg["zones"]["public"]["services"] == ["http"]
|
||||
|
||||
@patch("daemon.handlers.firewall._default_zone", return_value="internal")
|
||||
def test_set_zone_services_non_default_zone_allowed(self, mock_dz):
|
||||
with (
|
||||
patch("daemon.handlers.firewall.run", return_value=self.ZONES_OUT),
|
||||
patch.object(
|
||||
daemonfirewall, "_parse_zone_output", return_value={"services": []}
|
||||
),
|
||||
patch.object(daemonfirewall, "_reload"),
|
||||
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
|
||||
patch.object(daemonfirewall, "_save_config"),
|
||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
result = daemonfirewall.set_zone_services(
|
||||
None, {"zone": "public", "services": []}
|
||||
)
|
||||
assert result == {"zone": "public", "services": []}
|
||||
|
||||
def test_would_remove_mgmt_keeps_https(self):
|
||||
assert daemonfirewall._would_remove_mgmt("public", ["http", "https"]) is False
|
||||
assert daemonfirewall._would_remove_mgmt("public", ["ssh"]) is False
|
||||
|
||||
def test_would_remove_mgmt_fails_closed_on_error(self):
|
||||
with patch(
|
||||
"daemon.handlers.firewall._default_zone", side_effect=RuntimeError("boom")
|
||||
):
|
||||
assert daemonfirewall._would_remove_mgmt("public", ["http"]) is True
|
||||
|
||||
@patch("daemon.handlers.firewall._default_zone", return_value="default-zone")
|
||||
def test_would_remove_mgmt_other_zone(self, mock_dz):
|
||||
assert daemonfirewall._would_remove_mgmt("public", ["http"]) is False
|
||||
|
||||
@patch(
|
||||
"lib.firewall.get_config",
|
||||
return_value={
|
||||
"zones": {"public": {"services": ["http"], "interfaces": ["eth0"]}}
|
||||
},
|
||||
create=True,
|
||||
)
|
||||
@patch("daemon.handlers.firewall._default_zone", return_value="public")
|
||||
def test_config_apply_blocks_lockout_before_backup(self, mock_dz, mock_cfg):
|
||||
with (
|
||||
patch("daemon.handlers.firewall._save_backup") as mock_backup,
|
||||
pytest.raises(ConflictError) as exc,
|
||||
):
|
||||
daemonfirewall._config_apply()
|
||||
assert "https and ssh" in str(exc.value)
|
||||
mock_backup.assert_not_called()
|
||||
|
||||
@patch(
|
||||
"lib.firewall.get_config",
|
||||
return_value={
|
||||
"zones": {
|
||||
"public": {
|
||||
"target": "DEFAULT",
|
||||
"interfaces": ["eth0"],
|
||||
"services": ["http"],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
create=True,
|
||||
)
|
||||
@patch(
|
||||
"daemon.handlers.firewall.run",
|
||||
return_value="public\ninternal\ntarget: default\ninterfaces: \nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n",
|
||||
)
|
||||
@patch("daemon.handlers.firewall._default_zone", return_value="public")
|
||||
def test_config_apply_force_bypasses_guard(self, mock_dz, mock_run, mock_cfg):
|
||||
with (
|
||||
patch(
|
||||
"daemon.handlers.firewall._save_backup", return_value="/tmp/rules.json"
|
||||
),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_state",
|
||||
return_value={"zones": {"public": {}}},
|
||||
),
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
):
|
||||
result = daemonfirewall._config_apply(force=True)
|
||||
assert result["applied_zones"] == ["public"]
|
||||
|
||||
@patch(
|
||||
"daemon.handlers.firewall._config_apply",
|
||||
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
|
||||
)
|
||||
@patch("daemon.handlers.firewall.bus")
|
||||
def test_config_apply_handler_force_propagation(self, mock_bus, mock_apply):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
with patch("daemon.handlers.firewall.refresh_state"):
|
||||
daemonfirewall.config_apply(None, None)
|
||||
mock_apply.assert_called_once_with(force=False)
|
||||
mock_apply.reset_mock()
|
||||
daemonfirewall.config_apply(None, {"force": True})
|
||||
mock_apply.assert_called_once_with(force=True)
|
||||
|
||||
|
||||
class TestDaemonConfigPending:
|
||||
@patch("lib.state.state")
|
||||
def test_returns_pending(self, mock_st):
|
||||
|
||||
+17
-9
@@ -14,6 +14,11 @@ class TestPollIntervals:
|
||||
assert _POLL_INTERVALS["wireguard"] == 10
|
||||
assert _POLL_INTERVALS["dnsmasq"] == 10
|
||||
assert _POLL_INTERVALS["networkd"] == 10
|
||||
# Phase 5: real-time system metrics poll at 1s.
|
||||
assert _POLL_INTERVALS["system"] == 1
|
||||
# nginx/acme derive from config files; poll for drift self-heal.
|
||||
assert _POLL_INTERVALS["nginx"] == 60
|
||||
assert _POLL_INTERVALS["acme"] == 300
|
||||
|
||||
def test_env_override(self):
|
||||
"""VACUUM_WALL_POLL_INTERVALS env var can override values."""
|
||||
@@ -33,28 +38,31 @@ class TestPollIntervals:
|
||||
|
||||
class TestBroadcastTick:
|
||||
def test_sends_tick_message(self):
|
||||
from daemon.server import _ws_subscribers, broadcast_tick
|
||||
import daemon.server as server
|
||||
|
||||
mock_ws = AsyncMock()
|
||||
mock_ws.send_str = AsyncMock()
|
||||
_ws_subscribers.add(mock_ws)
|
||||
server._ws_subscribers.add(mock_ws)
|
||||
try:
|
||||
asyncio.run(broadcast_tick(["firewall", "wireguard"]))
|
||||
with patch.object(server.state_store, "get", return_value={"up": True}):
|
||||
asyncio.run(server.broadcast_tick("firewall"))
|
||||
mock_ws.send_str.assert_called_once()
|
||||
sent = json.loads(mock_ws.send_str.call_args[0][0])
|
||||
assert sent["type"] == "tick"
|
||||
assert sent["subsystems"] == ["firewall", "wireguard"]
|
||||
assert sent["subsystem"] == "firewall"
|
||||
assert sent["data"] == {"up": True}
|
||||
finally:
|
||||
_ws_subscribers.discard(mock_ws)
|
||||
server._ws_subscribers.discard(mock_ws)
|
||||
|
||||
def test_prunes_dead_subscribers(self):
|
||||
from daemon.server import _ws_subscribers, broadcast_tick
|
||||
import daemon.server as server
|
||||
|
||||
mock_ws = AsyncMock()
|
||||
mock_ws.send_str = AsyncMock(side_effect=Exception("broken"))
|
||||
_ws_subscribers.add(mock_ws)
|
||||
asyncio.run(broadcast_tick(["firewall"]))
|
||||
assert mock_ws not in _ws_subscribers
|
||||
server._ws_subscribers.add(mock_ws)
|
||||
with patch.object(server.state_store, "get", return_value={"up": True}):
|
||||
asyncio.run(server.broadcast_tick("firewall"))
|
||||
assert mock_ws not in server._ws_subscribers
|
||||
|
||||
|
||||
class TestPollTasks:
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests for refresh_state / refresh_status WS broadcasting (daemon.server).
|
||||
|
||||
refresh_state() and refresh_status() re-collect state and broadcast a
|
||||
data-carrying versions message for every (requested) subsystem so all
|
||||
viewers stay in sync.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import daemon.server as server
|
||||
|
||||
|
||||
def _run_and_drain(fn):
|
||||
"""Run *fn* inside a running event loop (required for broadcast tasks),
|
||||
then drain the fire-and-forget broadcast tasks."""
|
||||
|
||||
async def drive():
|
||||
fn()
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0)
|
||||
if not server._ws_tasks:
|
||||
break
|
||||
for task in list(server._ws_tasks):
|
||||
with suppress(Exception):
|
||||
await task
|
||||
|
||||
asyncio.run(drive())
|
||||
|
||||
|
||||
def _new_ws():
|
||||
ws = AsyncMock()
|
||||
ws.send_str = AsyncMock()
|
||||
server._ws_subscribers.add(ws)
|
||||
return ws
|
||||
|
||||
|
||||
def _messages(ws):
|
||||
return [json.loads(c[0][0]) for c in ws.send_str.call_args_list]
|
||||
|
||||
|
||||
class TestRefreshStateBroadcast:
|
||||
def test_broadcasts_each_requested_subsystem(self):
|
||||
"""refresh_state(["firewall","dnsmasq"]) broadcasts both, and bumps."""
|
||||
store = MagicMock()
|
||||
store.get.side_effect = lambda name: {"s": name}
|
||||
ws = _new_ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
_run_and_drain(lambda: server.refresh_state(["firewall", "dnsmasq"]))
|
||||
store.populate.assert_called_once_with(["firewall", "dnsmasq"])
|
||||
store.bump.assert_any_call("firewall")
|
||||
store.bump.assert_any_call("dnsmasq")
|
||||
msgs = _messages(ws)
|
||||
assert sorted(m["subsystem"] for m in msgs) == ["dnsmasq", "firewall"]
|
||||
assert all(m["type"] == "versions" for m in msgs)
|
||||
for m in msgs:
|
||||
assert "updated" not in m
|
||||
assert m["data"] == {"s": m["subsystem"]}
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
def test_failed_subsystem_skipped_others_buzz(self):
|
||||
"""A subsystem whose collection failed (None) is not broadcast."""
|
||||
store = MagicMock()
|
||||
store.get.side_effect = lambda name: {"s": name} if name != "acme" else None
|
||||
ws = _new_ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
_run_and_drain(lambda: server.refresh_state(["firewall", "acme"]))
|
||||
subs = sorted(m["subsystem"] for m in _messages(ws))
|
||||
assert subs == ["firewall"]
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
def test_no_subsystems_arg_broadcasts_all(self):
|
||||
"""refresh_state() with no filter targets every subsystem."""
|
||||
from lib.state import State
|
||||
|
||||
store = State()
|
||||
for name in State.SUBSYSTEMS:
|
||||
store.set(name, {"k": name})
|
||||
ws = _new_ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
_run_and_drain(lambda: server.refresh_state())
|
||||
subs = sorted(m["subsystem"] for m in _messages(ws))
|
||||
assert subs == sorted(State.SUBSYSTEMS)
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
|
||||
class TestRefreshStatusBroadcast:
|
||||
def test_filtered_response_and_broadcast(self):
|
||||
"""POST /status/refresh replies only with the requested subsystems
|
||||
and broadcasts each of them."""
|
||||
store = MagicMock()
|
||||
store.get.side_effect = lambda name: {"s": name}
|
||||
ws = _new_ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
|
||||
async def drive():
|
||||
request = MagicMock()
|
||||
request.json = AsyncMock(return_value={"subsystems": ["firewall"]})
|
||||
response = await server.refresh_status(request)
|
||||
await asyncio.sleep(0.01)
|
||||
return response
|
||||
|
||||
response = asyncio.run(drive())
|
||||
|
||||
body = json.loads(response.body)
|
||||
assert body["ok"] is True
|
||||
assert set(body["data"]) == {"firewall"}
|
||||
store.bump.assert_not_called()
|
||||
subs = sorted(m["subsystem"] for m in _messages(ws))
|
||||
assert subs == ["firewall"]
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
def test_no_body_returns_all_subsystems(self):
|
||||
store = MagicMock()
|
||||
store.get.side_effect = lambda name: {"s": name}
|
||||
store.SUBSYSTEMS = ["firewall", "dnsmasq"]
|
||||
ws = _new_ws()
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
|
||||
async def drive():
|
||||
request = MagicMock()
|
||||
request.json = AsyncMock(return_value=None)
|
||||
response = await server.refresh_status(request)
|
||||
await asyncio.sleep(0.01)
|
||||
return response
|
||||
|
||||
response = asyncio.run(drive())
|
||||
|
||||
body = json.loads(response.body)
|
||||
assert body["ok"] is True
|
||||
assert set(body["data"]) == {"firewall", "dnsmasq"}
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Tests that collector outputs match the lib.schema TypedDict shapes.
|
||||
|
||||
Each collector's return value is asserted against its TypedDict's required
|
||||
keys at runtime (subprocess/shell calls mocked — no system services). The
|
||||
TypedDicts in lib/schema.py are the authoritative state-store contract;
|
||||
these tests catch drift between the schemas and the collectors.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import lib.state
|
||||
from lib import schema
|
||||
|
||||
|
||||
def _missing(required_keys: frozenset, data: dict) -> set[str]:
|
||||
return set(required_keys) - set(data)
|
||||
|
||||
|
||||
class TestCollectorShapesMatchSchema:
|
||||
def test_firewall_state(self):
|
||||
with patch.object(lib.state, "run") as mock_run:
|
||||
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-active-zones" in args:
|
||||
return "public\n eth0"
|
||||
if "--get-services" in args:
|
||||
return "ssh http"
|
||||
if "--list-all-zones" in args:
|
||||
return (
|
||||
"public\n"
|
||||
" target: default\n"
|
||||
" interfaces: eth0\n"
|
||||
" services: \n"
|
||||
" ports: \n"
|
||||
" protocols: \n"
|
||||
" forward-ports: \n"
|
||||
" masquerade: no\n"
|
||||
" rich rules: \n"
|
||||
)
|
||||
if "ip" in args[0]:
|
||||
if "link" in args:
|
||||
return (
|
||||
"1: lo: <LOOPBACK,UP> mtu 65536\n"
|
||||
"2: eth0: <BROADCAST,UP> mtu 1500 link/ether aa:bb\n"
|
||||
)
|
||||
if "addr" in args:
|
||||
return "2: eth0 inet 192.168.1.1/24\n"
|
||||
return ""
|
||||
|
||||
mock_run.side_effect = run_side
|
||||
result = lib.state._collect_firewall()
|
||||
|
||||
assert not _missing(schema.FirewallState.__required_keys__, result)
|
||||
for iface in result["interfaces"]:
|
||||
for k in schema.FirewallInterface.__required_keys__:
|
||||
assert k in iface, f"FirewallInterface missing {k}"
|
||||
|
||||
def test_dnsmasq_state(self):
|
||||
with patch.object(lib.state, "run_proc") as mock_proc:
|
||||
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
||||
result = lib.state._collect_dnsmasq()
|
||||
|
||||
assert not _missing(schema.DnsmasqState.__required_keys__, result)
|
||||
for k in schema.DnsmasqStatus.__required_keys__:
|
||||
assert k in result["status"], f"DnsmasqStatus missing {k}"
|
||||
|
||||
def test_nginx_state(self):
|
||||
result = lib.state._collect_nginx()
|
||||
assert not _missing(schema.NginxState.__required_keys__, result)
|
||||
assert "pending_changes" in result["status"]
|
||||
|
||||
def test_acme_state(self):
|
||||
with (
|
||||
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
|
||||
patch("lib.acme.list_certs", return_value=[]),
|
||||
patch.object(
|
||||
lib.state,
|
||||
"_parse_account_conf",
|
||||
return_value={"registered": False, "email": "", "ca": ""},
|
||||
),
|
||||
):
|
||||
result = lib.state._collect_acme()
|
||||
|
||||
assert not _missing(schema.AcmeState.__required_keys__, result)
|
||||
|
||||
def test_wireguard_state(self):
|
||||
with patch.object(lib.state, "run_proc") as mock_proc:
|
||||
mock_proc.return_value = Mock(stdout="", returncode=1)
|
||||
result = lib.state._collect_wireguard()
|
||||
|
||||
assert not _missing(schema.WgState.__required_keys__, result)
|
||||
for k in schema.WgStatus.__required_keys__:
|
||||
assert k in result["status"], f"WgStatus missing {k}"
|
||||
assert "classes" in result["status"]
|
||||
|
||||
def test_networkd_state(self):
|
||||
networkctl = {
|
||||
"Interfaces": [
|
||||
{
|
||||
"Name": "eth0",
|
||||
"Type": "ether",
|
||||
"OperationalState": "routable",
|
||||
"HardwareAddress": [1, 2, 3, 4, 5, 6],
|
||||
"Addresses": [
|
||||
{"Address": [192, 168, 30, 50], "Family": 2, "PrefixLength": 24}
|
||||
],
|
||||
"Routes": [
|
||||
{
|
||||
"Family": 2,
|
||||
"Destination": [0, 0, 0, 0],
|
||||
"DestinationPrefixLength": 0,
|
||||
"Gateway": [192, 168, 30, 1],
|
||||
}
|
||||
],
|
||||
"DNS": [{"Address": [1, 1, 1, 1], "Family": 2}],
|
||||
}
|
||||
]
|
||||
}
|
||||
with patch.object(lib.state, "run", return_value=json.dumps(networkctl)):
|
||||
result = lib.state._collect_networkd()
|
||||
|
||||
assert not _missing(schema.NetworkdState.__required_keys__, result)
|
||||
assert "eth0" in result["interfaces"]
|
||||
entry = result["interfaces"]["eth0"]
|
||||
for k in schema.NetworkdInterface.__required_keys__:
|
||||
assert k in entry, f"NetworkdInterface missing {k}"
|
||||
assert entry["gateway"] == "192.168.30.1"
|
||||
assert entry["addresses"] == ["192.168.30.50/24"]
|
||||
|
||||
def test_system_state(self):
|
||||
"""Reads /proc and /sys directly — no mocking needed on Linux."""
|
||||
result = lib.state._collect_system()
|
||||
assert not _missing(schema.SystemState.__required_keys__, result)
|
||||
for k in schema.CpuLoad.__required_keys__:
|
||||
assert k in result["load"], f"CpuLoad missing {k}"
|
||||
for k in schema.MemoryStats.__required_keys__:
|
||||
assert k in result["memory"], f"MemoryStats missing {k}"
|
||||
for k in schema.SwapStats.__required_keys__:
|
||||
assert k in result["swap"], f"SwapStats missing {k}"
|
||||
|
||||
def test_volatile_system_registered(self):
|
||||
"""Phase 5: system metrics are volatile (tick, not version bumps)."""
|
||||
from lib.state import _VOLATILE
|
||||
|
||||
expected = frozenset({"load", "memory", "swap", "traffic"})
|
||||
assert _VOLATILE.get("system") == expected
|
||||
@@ -28,6 +28,25 @@ class TestState:
|
||||
assert state is not None
|
||||
assert isinstance(state, State)
|
||||
|
||||
def test_get_snapshot_empty(self):
|
||||
"""Fresh store: snapshot lists every subsystem, all None."""
|
||||
s = State()
|
||||
snap = s.get_snapshot()
|
||||
assert set(snap) == set(s.SUBSYSTEMS)
|
||||
assert all(v is None for v in snap.values())
|
||||
|
||||
def test_get_snapshot_reflects_set_and_none(self):
|
||||
"""Snapshot carries set data; failed collections stay None."""
|
||||
s = State()
|
||||
s.set("firewall", {"zones": {}})
|
||||
s.set("system", {"load": {"load1": 0.0}})
|
||||
s.set("dnsmasq", None)
|
||||
snap = s.get_snapshot()
|
||||
assert snap["firewall"] == {"zones": {}}
|
||||
assert snap["system"] == {"load": {"load1": 0.0}}
|
||||
assert snap["dnsmasq"] is None
|
||||
assert snap["acme"] is None
|
||||
|
||||
|
||||
class TestCollectAll:
|
||||
@patch("lib.state.run")
|
||||
@@ -37,6 +56,8 @@ class TestCollectAll:
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-active-zones" in args:
|
||||
return "public\n eth0"
|
||||
if "--get-default-zone" in args:
|
||||
return "public\n"
|
||||
if "--get-services" in args:
|
||||
return "ssh http"
|
||||
if "ip" in args[0]:
|
||||
@@ -60,6 +81,8 @@ class TestCollectAll:
|
||||
result = _collect_firewall()
|
||||
assert isinstance(result, dict)
|
||||
assert "active_zones" in result
|
||||
assert "default_zone" in result
|
||||
assert result["default_zone"] == "public"
|
||||
assert "interfaces" in result
|
||||
assert "timestamp" in result
|
||||
|
||||
@@ -71,6 +94,8 @@ class TestCollectAll:
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-active-zones" in args:
|
||||
return "public\n eth0\ninternal\n eth0.100"
|
||||
if "--get-default-zone" in args:
|
||||
return "public\n"
|
||||
if "--get-services" in args:
|
||||
return "ssh http"
|
||||
if "ip" in args[0]:
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for WS delta structure (daemon.server._poll_loop + broadcasts).
|
||||
|
||||
After the push-stream migration the poll loop drives per-subsystem deltas:
|
||||
a structural diff bumps the version and broadcasts {type: versions,
|
||||
subsystem, data}; a volatile-only diff broadcasts {type: tick, subsystem,
|
||||
data}. No legacy `updated` dict / `subsystems` array is emitted.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import daemon.server as server
|
||||
from lib.state import State
|
||||
|
||||
|
||||
def _zero_offset_subsystem(interval: int) -> str:
|
||||
"""Find a subsystem name whose md5 offset is 0 so the loop starts at once."""
|
||||
for i in range(100_000):
|
||||
name = f"sub{i}"
|
||||
offset = int(hashlib.md5(name.encode()).hexdigest(), 16) % interval
|
||||
if offset == 0:
|
||||
return name
|
||||
raise AssertionError("could not find zero-offset name")
|
||||
|
||||
|
||||
def _run_one_poll_iteration(poll_result):
|
||||
"""Run _poll_loop for a single iteration and return the broadcast mocks."""
|
||||
namespaced = _zero_offset_subsystem(60)
|
||||
|
||||
async def drive():
|
||||
store = MagicMock()
|
||||
store.poll.return_value = poll_result
|
||||
store.bump = MagicMock()
|
||||
store.get.return_value = {"value": 1}
|
||||
bv = AsyncMock()
|
||||
bt = AsyncMock()
|
||||
task = None
|
||||
with (
|
||||
patch.object(server, "state_store", store),
|
||||
patch.object(server, "broadcast_versions", bv),
|
||||
patch.object(server, "broadcast_tick", bt),
|
||||
patch.object(server, "blacklist_expired"),
|
||||
):
|
||||
task = asyncio.create_task(server._poll_loop(namespaced, 60))
|
||||
await asyncio.sleep(0.02) # let one full iteration run
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
return store, bv, bt
|
||||
|
||||
store, bv, bt = asyncio.run(drive())
|
||||
return store, bv, bt
|
||||
|
||||
|
||||
class TestPollLoopDeltas:
|
||||
def test_structural_change_broadcasts_versions(self):
|
||||
store, bv, bt = _run_one_poll_iteration((True, False))
|
||||
store.bump.assert_called_once_with(_zero_offset_subsystem(60))
|
||||
bv.assert_awaited_once()
|
||||
bt.assert_not_awaited()
|
||||
|
||||
def test_volatile_change_broadcasts_tick(self):
|
||||
store, bv, bt = _run_one_poll_iteration((False, True))
|
||||
store.bump.assert_not_called()
|
||||
bv.assert_not_awaited()
|
||||
bt.assert_awaited_once()
|
||||
|
||||
def test_no_change_no_broadcast(self):
|
||||
store, bv, bt = _run_one_poll_iteration((False, False))
|
||||
store.bump.assert_not_called()
|
||||
bv.assert_not_awaited()
|
||||
bt.assert_not_awaited()
|
||||
|
||||
|
||||
class TestDeltaMessageShape:
|
||||
def test_versions_message_carries_subsystem_and_data(self):
|
||||
"""broadcast_versions emits {type, subsystem, data} — no `updated`."""
|
||||
store = State()
|
||||
store.set("firewall", {"zones": {"public": {}}})
|
||||
ws = AsyncMock()
|
||||
ws.send_str = AsyncMock()
|
||||
server._ws_subscribers.add(ws)
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
asyncio.run(server.broadcast_versions("firewall"))
|
||||
ws.send_str.assert_awaited_once()
|
||||
msg = json.loads(ws.send_str.call_args[0][0])
|
||||
assert msg["type"] == "versions"
|
||||
assert msg["subsystem"] == "firewall"
|
||||
assert msg["data"] == {"zones": {"public": {}}}
|
||||
assert "updated" not in msg
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
|
||||
def test_tick_message_carries_subsystem_and_data(self):
|
||||
store = State()
|
||||
store.set("system", {"load": {"load1": 1.0}})
|
||||
ws = AsyncMock()
|
||||
ws.send_str = AsyncMock()
|
||||
server._ws_subscribers.add(ws)
|
||||
try:
|
||||
with patch.object(server, "state_store", store):
|
||||
asyncio.run(server.broadcast_tick("system"))
|
||||
ws.send_str.assert_awaited_once()
|
||||
msg = json.loads(ws.send_str.call_args[0][0])
|
||||
assert msg["type"] == "tick"
|
||||
assert msg["subsystem"] == "system"
|
||||
assert msg["data"] == {"load": {"load1": 1.0}}
|
||||
assert "subsystems" not in msg
|
||||
finally:
|
||||
server._ws_subscribers.discard(ws)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests for the WS connect snapshot (daemon.server._handle_ws).
|
||||
|
||||
After the push-stream migration, a successful WS handshake sends a full
|
||||
state snapshot ({type: snapshot, data: {subsystem: state|null, ...}})
|
||||
instead of the retired {type: init, versions: ...} message.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from lib.state import State
|
||||
|
||||
|
||||
@pytest.fixture(autouse=False)
|
||||
def db_reset():
|
||||
"""Isolated in-memory DB so a builtin admin exists for token minting.
|
||||
|
||||
Mirrors the autouse _db_reset fixture in tests/test_auth.py (the DB
|
||||
singleton must be reset and pointed at SQLite :memory: before each test).
|
||||
"""
|
||||
import os
|
||||
|
||||
from lib.db import get_db, reset_db_for_test
|
||||
|
||||
reset_db_for_test()
|
||||
old_backend = os.environ.pop("VACUUM_WALL_DB_BACKEND", None)
|
||||
old_path = os.environ.pop("VACUUM_WALL_DB_PATH", None)
|
||||
old_seed = os.environ.pop("VACUUM_WALL_SEED_BUILTIN_ADMIN", None)
|
||||
|
||||
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
|
||||
os.environ["VACUUM_WALL_DB_PATH"] = ":memory:"
|
||||
|
||||
get_db() # triggers builtin-admin seed on the empty :memory: DB
|
||||
yield
|
||||
reset_db_for_test()
|
||||
if old_backend is not None:
|
||||
os.environ["VACUUM_WALL_DB_BACKEND"] = old_backend
|
||||
if old_path is not None:
|
||||
os.environ["VACUUM_WALL_DB_PATH"] = old_path
|
||||
if old_seed is not None:
|
||||
os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = old_seed
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def access_token(db_reset):
|
||||
"""Mint a real access token for the seeded builtin admin."""
|
||||
from lib.auth import generate_tokens
|
||||
|
||||
tokens = generate_tokens("admin", {"firewall": "rw"})
|
||||
return tokens["access_token"]
|
||||
|
||||
|
||||
class TestWsSnapshot:
|
||||
def test_snapshot_sent_on_auth_connect(self, access_token):
|
||||
"""A valid JWT subprotocol yields a full snapshot after auth."""
|
||||
import asyncio
|
||||
|
||||
import daemon.server as server
|
||||
|
||||
store = State()
|
||||
store.set("firewall", {"zones": {"public": {}}})
|
||||
store.set("system", {"load": {"load1": 0.1}})
|
||||
# Remaining subsystems stay None (not populated).
|
||||
|
||||
ws = AsyncMock()
|
||||
ws.prepare = AsyncMock()
|
||||
ws.send_json = AsyncMock()
|
||||
|
||||
request = MagicMock()
|
||||
request.headers = {"Sec-WebSocket-Protocol": access_token}
|
||||
|
||||
with (
|
||||
patch("aiohttp.web.WebSocketResponse", return_value=ws),
|
||||
patch.object(server, "state_store", store),
|
||||
):
|
||||
asyncio.run(server._handle_ws(request))
|
||||
|
||||
ws.send_json.assert_awaited_once()
|
||||
payload = ws.send_json.call_args[0][0]
|
||||
assert payload["type"] == "snapshot"
|
||||
data = payload["data"]
|
||||
# Every subsystem key is present (push-stream: no `init`/`versions` shape).
|
||||
for name in State.SUBSYSTEMS:
|
||||
assert name in data
|
||||
assert data["firewall"] == {"zones": {"public": {}}}
|
||||
assert data["system"] == {"load": {"load1": 0.1}}
|
||||
# Unpopulated subsystems are present but None (partial snapshot).
|
||||
assert data["dnsmasq"] is None
|
||||
assert data["wireguard"] is None
|
||||
|
||||
def test_no_snapshot_without_token(self):
|
||||
"""Missing token -> 401 JSON, no WS is opened, no snapshot sent."""
|
||||
import asyncio
|
||||
|
||||
import daemon.server as server
|
||||
|
||||
ws = AsyncMock()
|
||||
request = MagicMock()
|
||||
request.headers = {}
|
||||
|
||||
with patch("aiohttp.web.WebSocketResponse") as mock_ctor:
|
||||
result = asyncio.run(server._handle_ws(request))
|
||||
|
||||
assert result.status == 401
|
||||
mock_ctor.assert_not_called()
|
||||
ws.send_json.assert_not_awaited()
|
||||
|
||||
def test_no_snapshot_on_invalid_token(self, access_token):
|
||||
"""A token that fails validation -> 401, no snapshot sent."""
|
||||
import asyncio
|
||||
|
||||
import daemon.server as server
|
||||
|
||||
ws = AsyncMock()
|
||||
request = MagicMock()
|
||||
request.headers = {"Sec-WebSocket-Protocol": access_token}
|
||||
|
||||
with (
|
||||
patch("aiohttp.web.WebSocketResponse") as mock_ctor,
|
||||
patch("lib.auth.validate_token", return_value=None),
|
||||
):
|
||||
result = asyncio.run(server._handle_ws(request))
|
||||
|
||||
assert result.status == 401
|
||||
mock_ctor.assert_not_called()
|
||||
ws.send_json.assert_not_awaited()
|
||||
Reference in New Issue
Block a user