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:
2026-08-20 01:38:00 +00:00
parent 9c9f92ad04
commit 332d14e37d
45 changed files with 2819 additions and 496 deletions
+111
View File
@@ -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;
})();
+175
View File
@@ -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;
})();
+220
View File
@@ -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;
})();
+57
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+144
View File
@@ -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)
+147
View File
@@ -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
+25
View File
@@ -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]:
+114
View File
@@ -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)
+127
View File
@@ -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()