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,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)
|
||||
Reference in New Issue
Block a user