Files
pyMC_Repeater/tests/test_glass_handler.py
2026-04-17 23:51:04 +01:00

472 lines
15 KiB
Python

import asyncio
import importlib.util
import json
import time
from pathlib import Path
import pytest
_MODULE_PATH = Path(__file__).resolve().parents[1] / "repeater" / "data_acquisition" / "glass_handler.py"
_SPEC = importlib.util.spec_from_file_location("repeater_glass_handler", _MODULE_PATH)
_MODULE = importlib.util.module_from_spec(_SPEC)
assert _SPEC and _SPEC.loader
_SPEC.loader.exec_module(_MODULE)
GlassHandler = _MODULE.GlassHandler
class _DummyIdentity:
@staticmethod
def get_public_key():
return bytes.fromhex("ab" * 32)
class _DummyConfigManager:
def __init__(self):
self.calls = []
def update_and_save(self, updates, live_update=True, live_update_sections=None):
self.calls.append(
{
"updates": updates,
"live_update": live_update,
"live_update_sections": live_update_sections,
}
)
return {"success": True, "saved": True, "live_updated": True}
@staticmethod
def save_to_file():
return True
@staticmethod
def live_update_daemon(_sections):
return True
class _DummyDaemon:
def __init__(self):
self.local_identity = _DummyIdentity()
self.repeater_handler = type("RH", (), {"start_time": time.time() - 60})()
@staticmethod
def get_stats():
return {
"rx_count": 11,
"forwarded_count": 7,
"dropped_count": 2,
"flood_dup_count": 3,
"direct_dup_count": 1,
"sent_flood_count": 5,
"sent_direct_count": 2,
"utilization_percent": 4.2,
"noise_floor_dbm": -111.5,
"uptime_seconds": 60,
}
@staticmethod
async def send_advert():
return True
class _DummyMqttClient:
def __init__(self):
self.published = []
def publish(self, topic, message, qos=0, retain=False):
self.published.append(
{
"topic": topic,
"message": message,
"qos": qos,
"retain": retain,
}
)
class _DummyPahoClient:
def __init__(self):
self.username = None
self.password = None
self.tls_set_kwargs = None
self.tls_insecure = None
self.connected = None
self.loop_started = False
self.loop_stopped = False
self.disconnected = False
self.on_connect = None
self.on_disconnect = None
def username_pw_set(self, username, password):
self.username = username
self.password = password
def tls_set(self, **kwargs):
self.tls_set_kwargs = kwargs
def tls_insecure_set(self, value):
self.tls_insecure = value
def connect_async(self, host, port, keepalive):
self.connected = (host, port, keepalive)
def loop_start(self):
self.loop_started = True
def loop_stop(self):
self.loop_stopped = True
def disconnect(self):
self.disconnected = True
class _DummyPahoModule:
def __init__(self, client):
self._client = client
def Client(self):
return self._client
class _DummyHttpResponse:
def __init__(self, payload):
self._payload = json.dumps(payload).encode("utf-8")
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return self._payload
class _DummySslContext:
def __init__(self):
self.cert_chain = None
def load_cert_chain(self, certfile, keyfile):
self.cert_chain = (certfile, keyfile)
def _make_config():
return {
"repeater": {
"node_name": "mesh-repeater-01",
"mode": "forward",
"location": "51.5074,-0.1278",
"identity_key": "PRIVATE-KEY",
},
"radio": {
"frequency": 869618000,
"spreading_factor": 8,
"bandwidth": 62500,
"tx_power": 14,
},
"glass": {
"enabled": False,
"base_url": "http://localhost:8080",
"inform_interval_seconds": 30,
"request_timeout_seconds": 10,
"verify_tls": True,
"api_token": "",
"cert_store_dir": "/tmp/pymc-glass-test",
"mqtt_password": "super-secret",
},
}
def test_compute_config_hash_has_expected_format():
config = _make_config()
config["repeater"]["identity_key"] = b"\x01" * 32
digest = GlassHandler._compute_config_hash(config)
assert digest.startswith("sha256:")
assert len(digest) == 71
def test_build_inform_payload_contains_expected_fields():
config = _make_config()
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
asyncio.run(handler._queue_command_result("cmd-1", "success", "ok"))
payload = asyncio.run(handler._build_inform_payload())
assert payload["type"] == "inform"
assert payload["version"] == 1
assert payload["node_name"] == "mesh-repeater-01"
assert payload["pubkey"].startswith("0x")
assert payload["config_hash"].startswith("sha256:")
assert payload["location"] == "51.907400,-0.157800"
assert payload["radio"]["frequency"] == 869618000
assert payload["counters"]["duplicates"] == 4
assert payload["settings"]["repeater"]["location"] == "51.9074,-0.1570"
assert payload["settings"]["repeater"]["identity_key"] == "<redacted>"
assert payload["settings"]["glass"]["mqtt_password"] == "<redacted>"
assert payload["command_results"][0]["command_id"] == "cmd-1"
def test_execute_set_mode_command_updates_config():
config = _make_config()
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
ok, message = asyncio.run(handler._execute_command_action("set_mode", {"mode": "monitor"}))
assert ok is True
assert "Config patched" in message
assert manager.calls
assert manager.calls[-1]["updates"]["repeater"]["mode"] == "monitor"
def test_handle_command_response_queues_result():
config = _make_config()
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
asyncio.run(
handler._handle_command_response(
{
"type": "command",
"command_id": "cmd-42",
"action": "run_diagnostic",
"params": {},
}
)
)
assert handler._pending_command_results
queued = handler._pending_command_results[-1]
assert queued["command_id"] == "cmd-42"
assert queued["status"] == "success"
def test_publish_telemetry_packet_envelope_to_expected_topic():
config = _make_config()
config["glass"]["enabled"] = True
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
handler.mqtt_enabled = True
handler._mqtt_ready = True
handler._mqtt_client = _DummyMqttClient()
handler.publish_telemetry(
"packet",
{
"timestamp": 1760000000,
"packet_hash": "ABCDEF123456",
"rssi": -80.5,
},
)
assert len(handler._mqtt_client.published) == 1
published = handler._mqtt_client.published[0]
assert published["topic"] == "glass/mesh-repeater-01/packet"
payload = json.loads(published["message"])
assert payload["version"] == 1
assert payload["type"] == "packet"
assert payload["node_name"] == "mesh-repeater-01"
assert payload["topic"] == "glass/mesh-repeater-01/packet"
assert payload["payload"]["packet_hash"] == "ABCDEF123456"
assert published["qos"] == 0
assert published["retain"] is False
def test_publish_telemetry_event_uses_event_topic_suffix():
config = _make_config()
config["glass"]["enabled"] = True
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
handler.mqtt_enabled = True
handler._mqtt_ready = True
handler._mqtt_client = _DummyMqttClient()
handler.publish_telemetry(
"noise_floor",
{
"timestamp": "2026-04-15T12:30:45Z",
"noise_floor_dbm": -112.3,
},
)
assert len(handler._mqtt_client.published) == 1
published = handler._mqtt_client.published[0]
assert published["topic"] == "glass/mesh-repeater-01/event/noise_floor"
payload = json.loads(published["message"])
assert payload["type"] == "event"
assert payload["event_name"] == "noise_floor"
assert payload["topic"] == "glass/mesh-repeater-01/event/noise_floor"
def test_apply_config_update_glass_managed_updates_runtime_and_file(tmp_path):
config = _make_config()
config["glass"]["enabled"] = True
config["glass"]["cert_store_dir"] = str(tmp_path)
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
handler._sync_mqtt_publisher = lambda: None
ok, message = handler._apply_config_update(
{
"glass_managed": {
"mqtt_enabled": True,
"mqtt_broker_host": "emqx",
"mqtt_broker_port": 1883,
"mqtt_base_topic": "glass/fleet",
"mqtt_tls_enabled": True,
}
},
merge_mode="patch",
)
assert ok is True
assert "Managed settings updated" in message
managed_path = tmp_path / "managed.json"
assert managed_path.exists()
managed = json.loads(managed_path.read_text(encoding="utf-8"))
assert managed["mqtt_enabled"] is True
assert managed["mqtt_broker_host"] == "emqx"
assert managed["mqtt_broker_port"] == 1883
assert managed["mqtt_base_topic"] == "glass/fleet"
assert managed["mqtt_tls_enabled"] is True
assert handler.mqtt_enabled is True
assert handler.mqtt_broker_host == "emqx"
assert handler.mqtt_broker_port == 1883
assert handler.mqtt_base_topic == "glass/fleet"
assert handler.mqtt_tls_enabled is True
def test_sync_mqtt_publisher_restarts_when_signature_changes(monkeypatch):
config = _make_config()
config["glass"]["enabled"] = True
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
handler.enabled = True
handler.mqtt_enabled = True
handler._mqtt_client = object()
handler._mqtt_runtime_signature = ("old-host", 1883, "glass", None, None)
calls = []
def _fake_close():
calls.append("close")
handler._mqtt_client = None
handler._mqtt_runtime_signature = None
def _fake_init():
calls.append("init")
monkeypatch.setattr(_MODULE, "mqtt", object())
monkeypatch.setattr(handler, "_close_mqtt_publisher", _fake_close)
monkeypatch.setattr(handler, "_init_mqtt_publisher", _fake_init)
handler.mqtt_broker_host = "new-host"
handler._sync_mqtt_publisher()
assert calls == ["close", "init"]
def test_init_mqtt_publisher_uses_mtls_cert_material(tmp_path, monkeypatch):
cert_path = tmp_path / "glass-client.crt"
key_path = tmp_path / "glass-client.key"
ca_path = tmp_path / "glass-ca.crt"
cert_path.write_text("CERT", encoding="utf-8")
key_path.write_text("KEY", encoding="utf-8")
ca_path.write_text("CA", encoding="utf-8")
config = _make_config()
config["glass"]["enabled"] = True
config["glass"]["verify_tls"] = True
config["glass"]["client_cert_path"] = str(cert_path)
config["glass"]["client_key_path"] = str(key_path)
config["glass"]["ca_cert_path"] = str(ca_path)
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
handler.enabled = True
handler.mqtt_enabled = True
handler.mqtt_tls_enabled = True
handler.mqtt_broker_host = "emqx"
handler.mqtt_broker_port = 8883
handler.mqtt_base_topic = "glass"
fake_client = _DummyPahoClient()
monkeypatch.setattr(_MODULE, "mqtt", _DummyPahoModule(fake_client))
handler._init_mqtt_publisher()
assert fake_client.tls_set_kwargs is not None
assert fake_client.tls_set_kwargs["ca_certs"] == str(ca_path)
assert fake_client.tls_set_kwargs["certfile"] == str(cert_path)
assert fake_client.tls_set_kwargs["keyfile"] == str(key_path)
assert fake_client.tls_set_kwargs["cert_reqs"] == _MODULE.ssl.CERT_REQUIRED
assert fake_client.connected == ("emqx", 8883, 60)
assert fake_client.loop_started is True
assert handler._mqtt_client is fake_client
assert handler._mqtt_runtime_signature == handler._current_mqtt_signature()
def test_post_inform_sync_uses_configured_ca_and_client_cert_chain(tmp_path, monkeypatch):
cert_path = tmp_path / "glass-client.crt"
key_path = tmp_path / "glass-client.key"
ca_path = tmp_path / "glass-ca.crt"
cert_path.write_text("CERT", encoding="utf-8")
key_path.write_text("KEY", encoding="utf-8")
ca_path.write_text("CA", encoding="utf-8")
config = _make_config()
config["glass"]["enabled"] = True
config["glass"]["base_url"] = "https://glass.example"
config["glass"]["verify_tls"] = True
config["glass"]["client_cert_path"] = str(cert_path)
config["glass"]["client_key_path"] = str(key_path)
config["glass"]["ca_cert_path"] = str(ca_path)
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
calls = {}
fake_context = _DummySslContext()
def _fake_create_default_context(cafile=None):
calls["cafile"] = cafile
return fake_context
def _fake_urlopen(req, timeout=None, context=None):
calls["timeout"] = timeout
calls["context"] = context
assert req.full_url == "https://glass.example/inform"
return _DummyHttpResponse({"type": "noop", "interval": 30})
monkeypatch.setattr(_MODULE.ssl, "create_default_context", _fake_create_default_context)
monkeypatch.setattr(_MODULE.request, "urlopen", _fake_urlopen)
response = handler._post_inform_sync({"type": "inform"})
assert response["type"] == "noop"
assert calls["cafile"] == str(ca_path)
assert calls["context"] is fake_context
assert fake_context.cert_chain == (str(cert_path), str(key_path))
def test_build_ssl_context_raises_when_client_key_missing(tmp_path):
cert_path = tmp_path / "glass-client.crt"
cert_path.write_text("CERT", encoding="utf-8")
config = _make_config()
config["glass"]["enabled"] = True
config["glass"]["base_url"] = "https://glass.example"
config["glass"]["verify_tls"] = False
config["glass"]["client_cert_path"] = str(cert_path)
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
with pytest.raises(RuntimeError, match="client_key_path"):
handler._build_ssl_context("https://glass.example/inform")