From 37ee0e892a6bfd56f80e7bd516b14e879a272a93 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Tue, 26 May 2026 13:01:38 +0100 Subject: [PATCH] Add more unit tests for handler helpers, identity manager, CLI, key generation, and main functionality - Introduced tests for TraceHelper and DiscoveryHelper to validate packet forwarding and discovery request handling. - Implemented tests for LoginHelper to ensure identity registration and login packet processing. - Added tests for IdentityManager to cover identity registration, lookup, and filtering. - Created tests for MeshCLI to verify command handling, configuration setting, and error paths. --- repeater/data_acquisition/gps_service.py | 29 +- repeater/data_acquisition/mqtt_handler.py | 47 +- repeater/handler_helpers/repeater_cli.py | 2 +- repeater/web/api_endpoints.py | 4 +- tests/test_airtime.py | 132 ++ tests/test_api_endpoints_core_coverage.py | 1161 +++++++++++++++++ tests/test_engine.py | 625 ++++++++- tests/test_flood_loop_dedup.py | 39 +- tests/test_glass_handler.py | 9 +- tests/test_handler_helpers_acl_advert.py | 296 +++++ ...test_handler_helpers_path_protocol_text.py | 337 +++++ tests/test_handler_helpers_room_server.py | 267 ++++ ...t_handler_helpers_trace_discovery_login.py | 314 +++++ .../test_identity_manager_and_repeater_cli.py | 269 ++++ tests/test_keygen_local_cli.py | 196 +++ tests/test_main_py_coverage.py | 333 +++++ tests/test_packet_router.py | 230 +++- tests/test_sensors.py | 82 ++ 18 files changed, 4326 insertions(+), 46 deletions(-) create mode 100644 tests/test_airtime.py create mode 100644 tests/test_api_endpoints_core_coverage.py create mode 100644 tests/test_handler_helpers_acl_advert.py create mode 100644 tests/test_handler_helpers_path_protocol_text.py create mode 100644 tests/test_handler_helpers_room_server.py create mode 100644 tests/test_handler_helpers_trace_discovery_login.py create mode 100644 tests/test_identity_manager_and_repeater_cli.py create mode 100644 tests/test_keygen_local_cli.py create mode 100644 tests/test_main_py_coverage.py diff --git a/repeater/data_acquisition/gps_service.py b/repeater/data_acquisition/gps_service.py index 1be4442..9976e84 100644 --- a/repeater/data_acquisition/gps_service.py +++ b/repeater/data_acquisition/gps_service.py @@ -594,11 +594,21 @@ class GPSService: self.api_fallback_to_config_location = bool( gps_config.get("api_fallback_to_config_location", True) ) + # Backward-compatible alias: use_gps_for_repeater_location=True means + # GPS advertising is enabled for repeater-originated location fields. + legacy_use_gps_location = gps_config.get("use_gps_for_repeater_location") + advertise_gps_default = bool(legacy_use_gps_location) if legacy_use_gps_location is not None else False self.advertise_gps_location = bool( - gps_config.get("advertise_gps_location", False) + gps_config.get("advertise_gps_location", advertise_gps_default) + ) + # Backward-compatible alias: repeater_location_precision_digits + # predates location_precision_digits. + precision_value = gps_config.get( + "location_precision_digits", + gps_config.get("repeater_location_precision_digits"), ) self.location_precision_digits = _normalize_precision_digits( - gps_config.get("location_precision_digits") + precision_value ) self.source = str(gps_config.get("source", "serial")).lower() self.device = gps_config.get("device", "/dev/serial0") @@ -618,11 +628,19 @@ class GPSService: self.time_sync_min_valid_year = int(gps_config.get("time_sync_min_valid_year", 2020)) self._clock_setter = clock_setter or _set_system_clock_from_datetime self._time_provider = time_provider or time.time + # Backward-compatible alias: update_repeater_location_from_fix + # predates persist_gps_fix_to_config. + legacy_update_from_fix = gps_config.get("update_repeater_location_from_fix") + persist_fix_default = bool(legacy_update_from_fix) if legacy_update_from_fix is not None else False self.persist_gps_fix_enabled = bool( - gps_config.get("persist_gps_fix_to_config", False) + gps_config.get("persist_gps_fix_to_config", persist_fix_default) + ) + persist_interval_value = gps_config.get( + "persist_gps_fix_interval_seconds", + gps_config.get("location_update_interval_seconds", 600.0), ) self.persist_gps_fix_interval_seconds = max( - 1.0, float(gps_config.get("persist_gps_fix_interval_seconds", 600.0)) + 1.0, float(persist_interval_value) ) self._location_update_callback = location_update_callback self._location_update_lock = threading.RLock() @@ -731,6 +749,7 @@ class GPSService: "source": "config", "advertise_gps_location": self.advertise_gps_location, "location_precision_digits": self.location_precision_digits, + "precision_digits": self.location_precision_digits, } if not self.advertise_gps_location: @@ -749,6 +768,7 @@ class GPSService: "source": "gps", "advertise_gps_location": True, "location_precision_digits": self.location_precision_digits, + "precision_digits": self.location_precision_digits, } return { @@ -892,6 +912,7 @@ class GPSService: "status": deepcopy(snapshot.get("status") or {}), "time": deepcopy(snapshot.get("time") or {}), "location_precision_digits": self.location_precision_digits, + "precision_digits": self.location_precision_digits, } try: updated = bool(self._location_update_callback(payload)) diff --git a/repeater/data_acquisition/mqtt_handler.py b/repeater/data_acquisition/mqtt_handler.py index c1a78ad..dda3fef 100644 --- a/repeater/data_acquisition/mqtt_handler.py +++ b/repeater/data_acquisition/mqtt_handler.py @@ -208,7 +208,21 @@ class _BrokerConnection: }) client_id = f"meshcore_{self.public_key}_{broker['host']}_{self.format}" - self.client = mqtt.Client(client_id=client_id, transport=self.transport) + client_kwargs = { + "client_id": client_id, + "transport": self.transport, + } + # Prefer callback API v2 when available (paho-mqtt>=2.x) to avoid + # deprecation warnings from the legacy callback API v1. + callback_api = getattr(mqtt, "CallbackAPIVersion", None) + if callback_api is not None and hasattr(callback_api, "VERSION2"): + client_kwargs["callback_api_version"] = callback_api.VERSION2 + try: + self.client = mqtt.Client(**client_kwargs) + except TypeError: + # Backward-compatibility fallback for older paho versions. + client_kwargs.pop("callback_api_version", None) + self.client = mqtt.Client(**client_kwargs) if hasattr(self.client, "on_pre_connect"): self.client.on_pre_connect = self._on_pre_connect self.client.on_connect = self._on_connect @@ -295,9 +309,10 @@ class _BrokerConnection: return token - def _on_connect(self, client, userdata, flags, rc): + def _on_connect(self, client, userdata, flags, rc, properties=None): """MQTT connection callback""" - if rc == 0: + rc_value = int(getattr(rc, "value", rc)) if rc is not None else -1 + if rc_value == 0: logger.info(f"Connected to {self.broker['name']}") self._running = True self._reconnect_attempts = 0 # Reset counter on success @@ -310,7 +325,7 @@ class _BrokerConnection: if self._on_connect_callback: self._on_connect_callback(self.broker["name"]) else: - error_msg = get_mqtt_error_message(rc, is_disconnect=False) + error_msg = get_mqtt_error_message(rc_value, is_disconnect=False) logger.error(f"Failed to connect to {self.broker['name']}: {error_msg}") self._schedule_reconnect(reason=error_msg) @@ -321,8 +336,14 @@ class _BrokerConnection: if self.use_jwt_auth: self._set_credentials() - def _on_disconnect(self, client, userdata, rc): + def _on_disconnect(self, client, userdata, rc, *extra): """MQTT disconnection callback""" + # Callback API v2 passes: (client, userdata, disconnect_flags, reason_code, properties) + # while API v1 passes: (client, userdata, rc). Normalize to integer rc. + if not isinstance(rc, (int, float)) and extra: + rc = extra[0] + rc_value = int(getattr(rc, "value", rc)) if rc is not None else -1 + was_running = self._running self._running = False @@ -332,14 +353,14 @@ class _BrokerConnection: self._on_disconnect_callback(self.broker["name"]) return - if rc != 0: # Unexpected disconnect - error_msg = get_mqtt_error_message(rc, is_disconnect=True) + if rc_value != 0: # Unexpected disconnect + error_msg = get_mqtt_error_message(rc_value, is_disconnect=True) if was_running: - logger.warning(f"Disconnected from {self.broker['name']} (rc={rc}): {error_msg}") + logger.warning(f"Disconnected from {self.broker['name']} (rc={rc_value}): {error_msg}") else: logger.debug( f"Duplicate disconnect callback from {self.broker['name']} while already disconnected " - f"(rc={rc}): {error_msg}" + f"(rc={rc_value}): {error_msg}" ) if was_running: # Only reconnect if we were intentionally connected self._schedule_reconnect(reason=error_msg) @@ -1091,7 +1112,14 @@ def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str: if _fallback is None: logger.debug(f"Could not decode reason code {rc}: {e}") + error_dict = disconnect_errors if is_disconnect else connect_errors if is_disconnect: + mapped = error_dict.get(rc) + if mapped is not None: + if rc >= 128 and "(code" not in mapped: + return f"{mapped} (code {rc})" + return mapped + try: paho_error = mqtt.error_string(rc) if paho_error and paho_error != "Unknown error.": @@ -1099,5 +1127,4 @@ def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str: except Exception: pass - error_dict = disconnect_errors if is_disconnect else connect_errors return error_dict.get(rc, f"Unknown error code {rc}") diff --git a/repeater/handler_helpers/repeater_cli.py b/repeater/handler_helpers/repeater_cli.py index 6620606..278a052 100644 --- a/repeater/handler_helpers/repeater_cli.py +++ b/repeater/handler_helpers/repeater_cli.py @@ -285,7 +285,7 @@ class MeshCLI: # Display current time import datetime - dt = datetime.datetime.utcnow() + dt = datetime.datetime.now(datetime.UTC) return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC" elif command == "clock sync": # Clock sync happens automatically via sender_timestamp in protocol diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index e0b806d..2dfd067 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -2,7 +2,7 @@ import json import logging import os import time -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from typing import Callable, Optional @@ -5250,7 +5250,7 @@ class APIEndpoints: exported = _sanitize(exported) meta = { - "exported_at": datetime.utcnow().isoformat() + "Z", + "exported_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), "version": __version__, "config_path": self._config_path, "includes_secrets": full_backup, diff --git a/tests/test_airtime.py b/tests/test_airtime.py new file mode 100644 index 0000000..f765e79 --- /dev/null +++ b/tests/test_airtime.py @@ -0,0 +1,132 @@ +"""Tests for repeater.airtime using radio preset configurations. + +This complements duration-focused tests by validating AirtimeManager behavior +across real-world SF/BW/CR combinations from radio-presets.json. +""" + +import json +import math +from pathlib import Path + +import pytest + +from repeater.airtime import AirtimeManager + + +def _semtech_airtime_ms(payload_len: int, sf: int, bw_hz: int, cr: int, preamble: int) -> float: + """Independent Semtech reference formula used as oracle in tests.""" + crc = 1 + h = 0 # explicit header + de = 1 if (sf >= 11 and bw_hz <= 125000) else 0 + t_sym = (2 ** sf) / (bw_hz / 1000) + t_preamble = (preamble + 4.25) * t_sym + numerator = max(8 * payload_len - 4 * sf + 28 + 16 * crc - 20 * h, 0) + denominator = 4 * (sf - 2 * de) + n_payload = 8 + math.ceil(numerator / denominator) * cr + return t_preamble + n_payload * t_sym + + +def _load_all_presets(): + """Load preset tuples (title, sf, bw_hz, cr) from JSON.""" + preset_file = Path(__file__).resolve().parents[1] / "radio-presets.json" + data = json.loads(preset_file.read_text(encoding="utf-8")) + entries = data["config"]["suggested_radio_settings"]["entries"] + + selected = [] + for e in entries: + selected.append( + ( + e["title"], + int(e["spreading_factor"]), + int(float(e["bandwidth"]) * 1000), + int(e["coding_rate"]), + ) + ) + return selected + + +ALL_PRESETS = _load_all_presets() +ALL_PRESET_IDS = [p[0] for p in ALL_PRESETS] + + +def _make_mgr(sf: int, bw_hz: int, cr: int, preamble: int = 8, max_airtime_per_minute: int = 3600): + cfg = { + "radio": { + "spreading_factor": sf, + "bandwidth": bw_hz, + "coding_rate": cr, + "preamble_length": preamble, + }, + "duty_cycle": { + "max_airtime_per_minute": max_airtime_per_minute, + "enforcement_enabled": True, + }, + } + return AirtimeManager(cfg) + + +def test_all_presets_loaded(): + assert ALL_PRESETS + + +@pytest.mark.parametrize("_title,sf,bw_hz,cr", ALL_PRESETS, ids=ALL_PRESET_IDS) +def test_all_presets_match_semtech_formula(_title, sf, bw_hz, cr): + mgr = _make_mgr(sf, bw_hz, cr, preamble=8) + for payload_len in (16, 32, 64, 128): + actual = mgr.calculate_airtime(payload_len) + expected = _semtech_airtime_ms(payload_len, sf=sf, bw_hz=bw_hz, cr=cr, preamble=8) + assert math.isclose(actual, expected, rel_tol=1e-9), ( + f"{_title} mismatch for {payload_len}B: got {actual}, expected {expected}" + ) + + +@pytest.mark.parametrize("_title,sf,bw_hz,cr", ALL_PRESETS, ids=ALL_PRESET_IDS) +def test_all_presets_airtime_increases_with_payload(_title, sf, bw_hz, cr): + mgr = _make_mgr(sf, bw_hz, cr, preamble=8) + short = mgr.calculate_airtime(16) + medium = mgr.calculate_airtime(64) + long_ = mgr.calculate_airtime(128) + assert short < medium < long_ + + +def test_long_range_preset_has_higher_airtime_than_fast_preset_for_same_payload(): + # EU/UK long-range profile vs US recommended profile from presets. + long_mgr = _make_mgr(sf=11, bw_hz=250000, cr=5, preamble=8) + fast_mgr = _make_mgr(sf=7, bw_hz=62500, cr=5, preamble=8) + payload_len = 64 + assert long_mgr.calculate_airtime(payload_len) > fast_mgr.calculate_airtime(payload_len) + + +@pytest.mark.parametrize("_title,sf,bw_hz,cr", ALL_PRESETS, ids=ALL_PRESET_IDS) +def test_can_transmit_blocks_after_budget_exhausted_for_each_preset(_title, sf, bw_hz, cr): + mgr = _make_mgr(sf, bw_hz, cr, preamble=8, max_airtime_per_minute=600) + airtime = mgr.calculate_airtime(64) + + # Feed TX history until just before the limit. + sent = 0 + while True: + can_tx, _ = mgr.can_transmit(airtime) + if not can_tx: + break + mgr.record_tx(airtime) + sent += 1 + # Safety guard against accidental infinite loops. + assert sent < 1000 + + can_tx_after, wait = mgr.can_transmit(airtime) + assert can_tx_after is False + assert wait >= 0.0 + + +def test_stats_report_tx_rx_airtime_totals(): + mgr = _make_mgr(sf=8, bw_hz=62500, cr=8, preamble=17) + tx_airtime = mgr.calculate_airtime(50) + rx_airtime = mgr.calculate_airtime(20) + + mgr.record_tx(tx_airtime) + mgr.record_rx(rx_airtime) + + stats = mgr.get_stats() + assert stats["total_airtime_ms"] == pytest.approx(tx_airtime) + assert stats["total_rx_airtime_ms"] == pytest.approx(rx_airtime) + assert stats["current_airtime_ms"] == pytest.approx(tx_airtime) diff --git a/tests/test_api_endpoints_core_coverage.py b/tests/test_api_endpoints_core_coverage.py new file mode 100644 index 0000000..557f56a --- /dev/null +++ b/tests/test_api_endpoints_core_coverage.py @@ -0,0 +1,1161 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, mock_open, patch + +import cherrypy +import pytest + +from repeater.web.api_endpoints import APIEndpoints + + +def _make_api(config=None): + api = APIEndpoints.__new__(APIEndpoints) + api.config = config or {} + api.daemon_instance = None + api._config_path = "/tmp/test-config.yaml" + api.config_manager = MagicMock() + return api + + +def _attach_storage(api, storage): + api.daemon_instance = SimpleNamespace( + repeater_handler=SimpleNamespace(storage=storage) + ) + + +@pytest.fixture +def cherrypy_ctx(monkeypatch): + request = SimpleNamespace(method="GET", params={}, json={}) + response = SimpleNamespace(headers={}, status=200) + monkeypatch.setattr(cherrypy, "request", request, raising=False) + monkeypatch.setattr(cherrypy, "response", response, raising=False) + return request, response + + +def test_set_cors_headers_enabled(cherrypy_ctx): + _, response = cherrypy_ctx + api = _make_api({"web": {"cors_enabled": True}}) + + api._set_cors_headers() + + assert response.headers["Access-Control-Allow-Origin"] == "*" + assert "POST" in response.headers["Access-Control-Allow-Methods"] + assert "Authorization" in response.headers["Access-Control-Allow-Headers"] + + +def test_set_cors_headers_disabled(cherrypy_ctx): + _, response = cherrypy_ctx + api = _make_api({"web": {"cors_enabled": False}}) + + api._set_cors_headers() + + assert response.headers == {} + + +def test_default_returns_empty_for_options(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "OPTIONS" + api = _make_api() + + assert api.default() == "" + + +def test_default_raises_404_for_non_options(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api() + + with pytest.raises(cherrypy.HTTPError) as exc: + api.default() + assert exc.value.status == 404 + + +def test_get_storage_success_and_failure_paths(): + api = _make_api() + with pytest.raises(Exception, match="Daemon not available"): + api._get_storage() + + api.daemon_instance = SimpleNamespace() + with pytest.raises(Exception, match="Repeater handler not initialized"): + api._get_storage() + + api.daemon_instance.repeater_handler = SimpleNamespace(storage=None) + with pytest.raises(Exception, match="Storage not initialized"): + api._get_storage() + + storage = object() + api.daemon_instance.repeater_handler.storage = storage + assert api._get_storage() is storage + + +def test_get_params_casts_int_float_and_none(cherrypy_ctx): + request, _ = cherrypy_ctx + request.params = {"count": "7", "ratio": "2.5", "name": "node", "maybe": None} + api = _make_api() + + parsed = api._get_params({"count": 0, "ratio": 0.0, "name": "", "maybe": 1}) + + assert parsed == {"count": 7, "ratio": 2.5, "name": "node", "maybe": None} + + +def test_require_post_enforces_method(cherrypy_ctx): + request, response = cherrypy_ctx + api = _make_api() + + request.method = "GET" + with pytest.raises(cherrypy.HTTPError) as exc: + api._require_post() + assert exc.value.status == 405 + assert response.status == 405 + assert response.headers["Allow"] == "POST" + + request.method = "POST" + api._require_post() + + +def test_fmt_hash_respects_path_hash_mode(): + pubkey = bytes.fromhex("19272233AA") + + api = _make_api({"mesh": {"path_hash_mode": 0}}) + assert api._fmt_hash(pubkey) == "0x19" + + api.config["mesh"]["path_hash_mode"] = 1 + assert api._fmt_hash(pubkey) == "0x1927" + + api.config["mesh"]["path_hash_mode"] = 2 + assert api._fmt_hash(pubkey) == "0x192722" + + +def test_process_counter_and_gauge_data(): + api = _make_api() + + counter = api._process_counter_data([None, 10, 13, 9], [1000, 2000, 3000, 4000]) + gauge = api._process_gauge_data([1, None, 3], [1000, 2000, 3000]) + + assert counter == [[1000, 0], [2000, 0], [3000, 3], [4000, 0]] + assert gauge == [[1000, 1], [2000, 0], [3000, 3]] + + +def test_success_and_error_helpers(): + api = _make_api() + + ok = api._success([1, 2], source="unit") + err = api._error("boom") + + assert ok == {"success": True, "data": [1, 2], "source": "unit"} + assert err == {"success": False, "error": "boom"} + + +def test_get_time_range_uses_current_time(monkeypatch): + api = _make_api() + monkeypatch.setattr("repeater.web.api_endpoints.time.time", lambda: 10_000) + + start, end = api._get_time_range(2) + + assert end == 10_000 + assert start == 2_800 + + +def test_setup_status_from_config_variants(): + api = _make_api() + + needs_setup, reasons = api._setup_status_from_config( + { + "repeater": { + "node_name": "mesh-repeater-01", + "security": {"admin_password": "admin123"}, + }, + "radio_type": "none", + } + ) + assert needs_setup is True + assert reasons == { + "default_name": True, + "default_password": True, + "radio_not_configured": True, + } + + needs_setup2, reasons2 = api._setup_status_from_config( + { + "repeater": { + "node_name": "mesh-node-77", + "security": {"admin_password": "verysecret"}, + }, + "radio_type": "sx1262", + } + ) + assert needs_setup2 is False + assert reasons2["radio_not_configured"] is False + + +def test_site_info_success_and_error_fallback(): + api = _make_api({"web": {"site_name": "Field Node"}}) + assert api.site_info() == {"success": True, "site_name": "Field Node"} + + class _BadConfig(dict): + def get(self, *args, **kwargs): + raise RuntimeError("bad") + + api_bad = _make_api(_BadConfig()) + assert api_bad.site_info() == {"success": True, "site_name": ""} + + +def test_hardware_options_loads_installed_file(tmp_path): + config = {"repeater": {"storage_dir": str(tmp_path)}} + api = _make_api(config) + api._config_path = str(tmp_path / "config.yaml") + + hardware_file = tmp_path / "radio-settings.json" + hardware_file.write_text( + '{"hardware":{"pymc_usb":{"name":"USB","description":"desc","radio_type":"pymc_usb"}}}', + encoding="utf-8", + ) + + with patch("repeater.web.api_endpoints.resolve_storage_dir", return_value=Path(tmp_path)): + result = api.hardware_options() + + assert len(result["hardware"]) == 1 + assert result["hardware"][0]["key"] == "pymc_usb" + assert result["hardware"][0]["name"] == "USB" + + +def test_radio_presets_returns_error_when_file_missing(tmp_path): + config = {"repeater": {"storage_dir": str(tmp_path)}} + api = _make_api(config) + api._config_path = str(tmp_path / "config.yaml") + + with patch("repeater.web.api_endpoints.resolve_storage_dir", return_value=Path(tmp_path)): + with patch("os.path.exists", return_value=False): + result = api.radio_presets() + + assert result["error"] == "Radio presets file not found" + + +def test_radio_presets_loads_entries_from_installed_file(tmp_path): + config = {"repeater": {"storage_dir": str(tmp_path)}} + api = _make_api(config) + api._config_path = str(tmp_path / "config.yaml") + + presets_file = tmp_path / "radio-presets.json" + presets_file.write_text( + '{"config":{"suggested_radio_settings":{"entries":[{"label":"Fast","frequency":869.5}]}}}', + encoding="utf-8", + ) + + with patch("repeater.web.api_endpoints.resolve_storage_dir", return_value=Path(tmp_path)): + result = api.radio_presets() + + assert result["source"] == "local" + assert len(result["presets"]) == 1 + assert result["presets"][0]["label"] == "Fast" + + +def test_needs_setup_reads_config_file_when_available(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + """ +repeater: + node_name: mesh-node-11 + security: + admin_password: longsecret +radio_type: sx1262 +""".strip(), + encoding="utf-8", + ) + + api = _make_api( + { + "repeater": { + "node_name": "mesh-repeater-01", + "security": {"admin_password": "admin123"}, + }, + "radio_type": "none", + } + ) + api._config_path = str(config_path) + + result = api.needs_setup() + + assert result["needs_setup"] is False + assert result["reasons"]["radio_not_configured"] is False + + +def test_serial_ports_uses_pyserial_metadata(cherrypy_ctx): + del cherrypy_ctx + api = _make_api() + + p1 = SimpleNamespace(device="/dev/ttyACM0", description="USB CDC", hwid="VID:PID") + p2 = SimpleNamespace(device="/dev/ttyUSB0", description="CH340", hwid="n/a") + + with patch("serial.tools.list_ports.comports", return_value=[p1, p2]): + result = api.serial_ports() + + assert result["success"] is True + devices = result["data"] + assert devices[0]["device"] == "/dev/ttyACM0" + assert "VID:PID" in devices[0]["description"] + assert devices[1]["device"] == "/dev/ttyUSB0" + + +def test_serial_ports_dedupes_duplicate_devices(cherrypy_ctx): + del cherrypy_ctx + api = _make_api() + + p1 = SimpleNamespace(device="/dev/ttyACM0", description="first", hwid="A") + p2 = SimpleNamespace(device="/dev/ttyACM0", description="second", hwid="B") + + with patch("serial.tools.list_ports.comports", return_value=[p1, p2]): + result = api.serial_ports() + + assert result["success"] is True + assert len(result["data"]) == 1 + assert "first" in result["data"][0]["description"] + + +def test_config_export_redacts_secrets_and_identity_keys(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "GET" + + api = _make_api( + { + "repeater": { + "security": { + "admin_password": "pw1", + "guest_password": "pw2", + "jwt_secret": "jwt", + }, + "identity_key": bytes.fromhex("AABB"), + }, + "identities": { + "companions": [{"name": "c1", "identity_key": bytes.fromhex("0102")}], + "room_servers": [{"name": "r1", "identity_key": bytes.fromhex("0304")}], + }, + "misc": {"blob": b"\x0A\x0B"}, + } + ) + + result = api.config_export() + + assert result["success"] is True + exported = result["data"]["config"] + sec = exported["repeater"]["security"] + assert sec["admin_password"] == "*** REDACTED ***" + assert sec["guest_password"] == "*** REDACTED ***" + assert sec["jwt_secret"] == "*** REDACTED ***" + assert "identity_key" not in exported["repeater"] + assert exported["identities"]["companions"][0]["identity_key"] == "*** REDACTED ***" + assert exported["misc"]["blob"] == "0a0b" + assert result["data"]["meta"]["includes_secrets"] is False + + +def test_config_export_full_backup_includes_hex_keys(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "GET" + + api = _make_api( + { + "repeater": {"identity_key": bytes.fromhex("AABB")}, + "identities": { + "companions": [{"name": "c1", "identity_key": bytes.fromhex("0102")}], + "room_servers": [{"name": "r1", "identity_key": bytes.fromhex("0304")}], + }, + } + ) + + result = api.config_export(include_secrets="true") + + assert result["success"] is True + exported = result["data"]["config"] + assert exported["repeater"]["identity_key"] == "aabb" + assert exported["identities"]["companions"][0]["identity_key"] == "0102" + assert exported["identities"]["room_servers"][0]["identity_key"] == "0304" + assert result["data"]["meta"]["includes_secrets"] is True + + +def test_config_import_rejects_missing_config_object(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "POST" + request.json = {} + api = _make_api() + + result = api.config_import() + + assert result["success"] is False + assert "Missing or invalid 'config' object" in result["error"] + + +def test_config_import_updates_sections_and_preserves_redacted(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "POST" + + api = _make_api( + { + "repeater": { + "security": { + "admin_password": "keep-admin", + "guest_password": "keep-guest", + "jwt_secret": "keep-jwt", + } + }, + "identities": { + "companions": [ + {"name": "c1", "identity_key": bytes.fromhex("C0FFEE")}, + ] + }, + } + ) + + request.json = { + "config": { + "repeater": { + "security": { + "admin_password": "*** REDACTED ***", + "guest_password": "new-guest", + "jwt_secret": "*** REDACTED ***", + }, + "identity_key": "AABBCC", + "identity_file": "/tmp/remove-me", + }, + "identities": { + "companions": [ + {"name": "c1", "identity_key": "*** REDACTED ***"}, + ] + }, + "radio": {"frequency": 915000000}, + "radio_type": "pymc_usb", + "unknown": {"x": 1}, + } + } + + api.config_manager.update_and_save.return_value = {"ok": True} + api.config_manager.save_to_file.return_value = True + + result = api.config_import() + + assert result["success"] is True + assert result["restart_required"] is True + assert set(result["sections_updated"]) == {"repeater", "identities", "radio", "radio_type"} + + sec = api.config["repeater"]["security"] + assert sec["admin_password"] == "keep-admin" + assert sec["guest_password"] == "new-guest" + assert sec["jwt_secret"] == "keep-jwt" + assert api.config["repeater"]["identity_key"] == bytes.fromhex("AABBCC") + assert "identity_file" not in api.config["repeater"] + assert api.config["identities"]["companions"][0]["identity_key"] == bytes.fromhex("C0FFEE") + + +def test_openapi_success_sets_content_type(cherrypy_ctx): + _, response = cherrypy_ctx + api = _make_api() + + with patch("builtins.open", mock_open(read_data="openapi: 3.0.0")): + content = api.openapi() + + assert response.headers["Content-Type"] == "application/x-yaml" + assert content == b"openapi: 3.0.0" + + +def test_openapi_not_found_returns_404(cherrypy_ctx): + _, response = cherrypy_ctx + api = _make_api() + + with patch("builtins.open", side_effect=FileNotFoundError): + content = api.openapi() + + assert response.status == 404 + assert content == b"OpenAPI spec not found" + + +def test_docs_returns_html_bytes_and_content_type(cherrypy_ctx): + _, response = cherrypy_ctx + api = _make_api() + + content = api.docs() + + assert response.headers["Content-Type"] == "text/html" + assert isinstance(content, bytes) + assert b"SwaggerUIBundle" in content + + +def test_packet_and_route_stats_endpoints(cherrypy_ctx): + del cherrypy_ctx + api = _make_api() + storage = SimpleNamespace( + get_packet_stats=MagicMock(return_value={"total": 10}), + get_packet_type_stats=MagicMock(return_value={"types": {1: 3}}), + get_route_stats=MagicMock(return_value={"routes": {2: 5}}), + ) + _attach_storage(api, storage) + + assert api.packet_stats("24") == {"success": True, "data": {"total": 10}} + assert api.packet_type_stats("12") == {"success": True, "data": {"types": {1: 3}}} + assert api.route_stats("6") == {"success": True, "data": {"routes": {2: 5}}} + + storage.get_packet_stats.assert_called_once_with(hours=24) + storage.get_packet_type_stats.assert_called_once_with(hours=12) + storage.get_route_stats.assert_called_once_with(hours=6) + + +def test_recent_packets_and_bulk_packets(cherrypy_ctx): + del cherrypy_ctx + api = _make_api() + packets = [{"h": "aa"}, {"h": "bb"}] + storage = SimpleNamespace( + get_recent_packets=MagicMock(return_value=packets), + get_filtered_packets=MagicMock(return_value=packets), + ) + _attach_storage(api, storage) + + recent = api.recent_packets("2") + bulk = api.bulk_packets(limit="20000", offset="-4", start_timestamp="1.5", end_timestamp="3.5") + + assert recent == {"success": True, "data": packets, "count": 2} + assert bulk["success"] is True + assert bulk["count"] == 2 + assert bulk["offset"] == 0 + assert bulk["limit"] == 10000 + assert bulk["compressed"] is True + + storage.get_recent_packets.assert_called_once_with(limit=2) + storage.get_filtered_packets.assert_called_once_with( + packet_type=None, + route=None, + start_timestamp=1.5, + end_timestamp=3.5, + limit=10000, + offset=0, + ) + + +def test_filtered_packets_options_and_success(cherrypy_ctx): + request, _ = cherrypy_ctx + api = _make_api({"web": {"cors_enabled": True}}) + packets = [{"h": "a1"}] + storage = SimpleNamespace(get_filtered_packets=MagicMock(return_value=packets)) + _attach_storage(api, storage) + + request.method = "OPTIONS" + assert api.filtered_packets() == "" + + request.method = "GET" + result = api.filtered_packets( + start_timestamp="10", + end_timestamp="20", + limit="5", + type="3", + route="2", + ) + + assert result["success"] is True + assert result["count"] == 1 + assert result["filters"] == { + "type": 3, + "route": 2, + "start_timestamp": 10.0, + "end_timestamp": 20.0, + "limit": 5, + } + storage.get_filtered_packets.assert_called_once_with( + packet_type=3, + route=2, + start_timestamp=10.0, + end_timestamp=20.0, + limit=5, + ) + + +def test_filtered_packets_invalid_parameter_format(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api() + _attach_storage(api, SimpleNamespace(get_filtered_packets=MagicMock())) + + result = api.filtered_packets(type="not-an-int") + + assert result["success"] is False + assert "Invalid parameter format" in result["error"] + + +def test_airtime_data_limit_and_error(cherrypy_ctx): + del cherrypy_ctx + api = _make_api() + storage = SimpleNamespace(get_airtime_data=MagicMock(return_value=[{"a": 1}])) + _attach_storage(api, storage) + + ok = api.airtime_data(start_timestamp="1", end_timestamp="2", limit="999999") + assert ok["success"] is True + assert ok["count"] == 1 + storage.get_airtime_data.assert_called_once_with( + start_timestamp=1.0, + end_timestamp=2.0, + limit=50000, + ) + + storage.get_airtime_data.side_effect = RuntimeError("db down") + err = api.airtime_data() + assert err["success"] is False + assert "db down" in err["error"] + + +def test_db_stats_options_success_and_error(cherrypy_ctx, tmp_path): + request, _ = cherrypy_ctx + api = _make_api({"web": {"cors_enabled": True}}) + + request.method = "OPTIONS" + assert api.db_stats() == "" + + request.method = "GET" + rrd = tmp_path / "metrics.rrd" + rrd.write_bytes(b"123456") + sqlite_handler = SimpleNamespace( + get_table_stats=MagicMock(return_value={"packets": {"rows": 10}}), + storage_dir=tmp_path, + ) + _attach_storage(api, SimpleNamespace(sqlite_handler=sqlite_handler)) + + result = api.db_stats() + assert result["success"] is True + assert result["data"]["packets"]["rows"] == 10 + assert result["data"]["rrd_size_bytes"] == 6 + + sqlite_handler.get_table_stats.side_effect = RuntimeError("stats failed") + err = api.db_stats() + assert err["success"] is False + assert "stats failed" in err["error"] + + +def test_db_purge_validation_and_results(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "POST" + api = _make_api({"web": {"cors_enabled": True}}) + + sqlite_handler = SimpleNamespace( + purge_table=MagicMock(side_effect=[5, ValueError("bad table")]) + ) + _attach_storage(api, SimpleNamespace(sqlite_handler=sqlite_handler)) + + request.json = {} + missing = api.db_purge() + assert missing["success"] is False + assert "Missing 'tables'" in missing["error"] + + request.json = {"tables": "nope"} + bad_type = api.db_purge() + assert bad_type["success"] is False + assert "must be a list" in bad_type["error"] + + request.json = {"tables": ["packets", "invalid"]} + result = api.db_purge() + assert result["success"] is True + assert result["data"]["packets"]["deleted"] == 5 + assert "bad table" in result["data"]["invalid"]["error"] + + +def test_db_purge_all_and_options(cherrypy_ctx): + request, _ = cherrypy_ctx + api = _make_api({"web": {"cors_enabled": True}}) + + request.method = "OPTIONS" + assert api.db_purge() == "" + + request.method = "POST" + request.json = {"tables": "all"} + sqlite_handler = SimpleNamespace(purge_table=MagicMock(return_value=1)) + _attach_storage(api, SimpleNamespace(sqlite_handler=sqlite_handler)) + + result = api.db_purge() + assert result["success"] is True + assert sqlite_handler.purge_table.call_count == 10 + + +def test_db_vacuum_options_success_and_error(cherrypy_ctx): + request, _ = cherrypy_ctx + api = _make_api({"web": {"cors_enabled": True}}) + + request.method = "OPTIONS" + assert api.db_vacuum() == "" + + request.method = "POST" + stat_values = [SimpleNamespace(st_size=1000), SimpleNamespace(st_size=700)] + sqlite_path = SimpleNamespace(stat=MagicMock(side_effect=stat_values)) + sqlite_handler = SimpleNamespace(sqlite_path=sqlite_path, vacuum=MagicMock()) + _attach_storage(api, SimpleNamespace(sqlite_handler=sqlite_handler)) + + result = api.db_vacuum() + assert result["success"] is True + assert result["data"] == {"size_before": 1000, "size_after": 700, "freed_bytes": 300} + + sqlite_path.stat = MagicMock(side_effect=[SimpleNamespace(st_size=700), SimpleNamespace(st_size=700)]) + sqlite_handler.vacuum.side_effect = RuntimeError("vacuum failed") + err = api.db_vacuum() + assert err["success"] is False + assert "vacuum failed" in err["error"] + + +def test_config_export_options_preflight(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "OPTIONS" + api = _make_api({"web": {"cors_enabled": True}}) + + assert api.config_export() == "" + + +def test_config_import_options_and_no_valid_sections(cherrypy_ctx): + request, _ = cherrypy_ctx + api = _make_api({"web": {"cors_enabled": True}}) + + request.method = "OPTIONS" + assert api.config_import() == "" + + request.method = "POST" + request.json = {"config": {"unknown_section": {"x": 1}}} + result = api.config_import() + assert result["success"] is False + assert "No valid configuration sections" in result["error"] + + +def test_config_import_invalid_identity_key_hex_is_skipped(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "POST" + api = _make_api({"repeater": {"security": {}}}) + api.config_manager.update_and_save.return_value = {"ok": True} + api.config_manager.save_to_file.return_value = True + request.json = { + "config": { + "repeater": { + "security": {}, + "identity_key": "NOTHEX", + } + } + } + + result = api.config_import() + + assert result["success"] is True + assert "identity_key" not in api.config["repeater"] + + +def test_validate_config_options_and_method_guard(cherrypy_ctx): + request, response = cherrypy_ctx + api = _make_api({"web": {"cors_enabled": True}}) + + request.method = "OPTIONS" + assert api.validate_config() == "" + + request.method = "POST" + with pytest.raises(cherrypy.HTTPError) as exc: + api.validate_config() + assert exc.value.status == 405 + assert response.status == 405 + assert response.headers["Allow"] == "GET" + + +def test_validate_config_reports_missing_file(cherrypy_ctx, tmp_path): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api({"web": {"cors_enabled": True}}) + api._config_path = str(tmp_path / "missing.yaml") + + result = api.validate_config() + + assert result["success"] is True + assert result["data"]["valid"] is False + assert result["data"]["summary"]["error_count"] >= 1 + assert result["data"]["errors"][0]["path"] == "config" + + +def test_validate_config_reports_yaml_parse_error(cherrypy_ctx, tmp_path): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api() + api._config_path = str(tmp_path / "bad.yaml") + (tmp_path / "bad.yaml").write_text("repeater: [unterminated", encoding="utf-8") + + result = api.validate_config() + + assert result["success"] is True + assert result["data"]["valid"] is False + assert any("YAML syntax error" in e["message"] for e in result["data"]["errors"]) + + +def test_validate_config_valid_kiss_configuration(cherrypy_ctx, tmp_path): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api() + api._config_path = str(tmp_path / "config.yaml") + (tmp_path / "config.yaml").write_text( + """ +repeater: + node_name: mesh-node-01 + security: + admin_password: supersecret +radio_type: kiss +radio: + frequency: 869618000 + bandwidth: 62500 + spreading_factor: 8 + coding_rate: 5 + tx_power: 22 + preamble_length: 16 +kiss: + port: /dev/ttyUSB0 + baud_rate: 115200 +""".strip(), + encoding="utf-8", + ) + + result = api.validate_config() + + assert result["success"] is True + assert result["data"]["valid"] is True + assert result["data"]["summary"]["error_count"] == 0 + + +def test_validate_config_disabled_radio_warns_but_valid(cherrypy_ctx, tmp_path): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api() + api._config_path = str(tmp_path / "config.yaml") + (tmp_path / "config.yaml").write_text( + """ +repeater: + node_name: mesh-node-02 + security: + admin_password: supersecret +radio_type: none +""".strip(), + encoding="utf-8", + ) + + result = api.validate_config() + + assert result["success"] is True + assert result["data"]["valid"] is True + assert result["data"]["summary"]["warning_count"] >= 1 + assert any(w["path"] == "radio_type" for w in result["data"]["warnings"]) + + +def test_update_web_config_options_no_updates_success_failure(cherrypy_ctx): + request, _ = cherrypy_ctx + api = _make_api({"web": {"cors_enabled": True}}) + + request.method = "OPTIONS" + assert api.update_web_config() == "" + + request.method = "POST" + request.json = {} + no_updates = api.update_web_config() + assert no_updates["success"] is False + assert "No configuration updates" in no_updates["error"] + + request.json = {"web": {"cors_enabled": True}} + api.config_manager.update_and_save.return_value = {"success": True, "saved": True} + ok = api.update_web_config() + assert ok["success"] is True + assert ok["data"]["persisted"] is True + api.config_manager.update_and_save.assert_called_with( + updates={"web": {"cors_enabled": True}}, + live_update=False, + ) + + api.config_manager.update_and_save.return_value = {"success": False, "error": "bad"} + fail = api.update_web_config() + assert fail["success"] is False + assert fail["error"] == "bad" + + +def test_update_web_config_requires_post_and_handles_exception(cherrypy_ctx): + request, _ = cherrypy_ctx + api = _make_api({"web": {"cors_enabled": True}}) + + request.method = "GET" + with pytest.raises(cherrypy.HTTPError) as exc: + api.update_web_config() + assert exc.value.status == 405 + + request.method = "POST" + request.json = {"web": {"site_name": "mesh"}} + api.config_manager.update_and_save.side_effect = RuntimeError("write failed") + err = api.update_web_config() + assert err["success"] is False + assert "write failed" in err["error"] + + +def test_validate_config_top_level_must_be_mapping(cherrypy_ctx, tmp_path): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api() + api._config_path = str(tmp_path / "config.yaml") + (tmp_path / "config.yaml").write_text("- list\n- not\n- mapping\n", encoding="utf-8") + + result = api.validate_config() + + assert result["success"] is True + assert result["data"]["valid"] is False + assert any(e["message"].startswith("Top-level YAML value must be a mapping") for e in result["data"]["errors"]) + + +def test_validate_config_invalid_radio_type_and_missing_sections(cherrypy_ctx, tmp_path): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api() + api._config_path = str(tmp_path / "config.yaml") + (tmp_path / "config.yaml").write_text( + """ +repeater: + node_name: "" +radio_type: weird_radio +""".strip(), + encoding="utf-8", + ) + + result = api.validate_config() + + assert result["success"] is True + assert result["data"]["valid"] is False + paths = {e["path"] for e in result["data"]["errors"]} + assert "repeater.node_name" in paths + assert "repeater.security" in paths + assert "radio_type" in paths + + +def test_validate_config_pymc_tcp_placeholder_and_bad_port(cherrypy_ctx, tmp_path): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api() + api._config_path = str(tmp_path / "config.yaml") + (tmp_path / "config.yaml").write_text( + """ +repeater: + node_name: mesh-node-03 + security: + admin_password: supersecret +radio_type: pymc_tcp +radio: + frequency: 869618000 + bandwidth: 62500 + spreading_factor: 8 + coding_rate: 5 + tx_power: 22 + preamble_length: 16 +pymc_tcp: + host: REPLACE_WITH_MODEM_HOST + port: 70000 +""".strip(), + encoding="utf-8", + ) + + result = api.validate_config() + + assert result["success"] is True + assert result["data"]["valid"] is False + paths = {e["path"] for e in result["data"]["errors"]} + assert "pymc_tcp.host" in paths + assert "pymc_tcp.port" in paths + + +def test_validate_config_sx1262_ch341_missing_sections(cherrypy_ctx, tmp_path): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api() + api._config_path = str(tmp_path / "config.yaml") + (tmp_path / "config.yaml").write_text( + """ +repeater: + node_name: mesh-node-04 + security: + admin_password: supersecret +radio_type: sx1262_ch341 +radio: + frequency: 869618000 + bandwidth: 62500 + spreading_factor: 8 + coding_rate: 5 + tx_power: 22 + preamble_length: 16 +""".strip(), + encoding="utf-8", + ) + + result = api.validate_config() + + assert result["success"] is True + assert result["data"]["valid"] is False + paths = {e["path"] for e in result["data"]["errors"]} + assert "sx1262" in paths + assert "ch341" in paths + + +def test_validate_config_rejects_bool_numeric_fields(cherrypy_ctx, tmp_path): + """Booleans silently cast to int in Python, so this guards explicit type checks.""" + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api() + api._config_path = str(tmp_path / "config.yaml") + (tmp_path / "config.yaml").write_text( + """ +repeater: + node_name: mesh-node-bool + security: + admin_password: supersecret +radio_type: kiss +radio: + frequency: 869618000 + bandwidth: true + spreading_factor: 8 + coding_rate: 5 + tx_power: 22 + preamble_length: 16 +kiss: + port: /dev/ttyUSB0 + baud_rate: true +""".strip(), + encoding="utf-8", + ) + + result = api.validate_config() + + assert result["success"] is True + assert result["data"]["valid"] is False + errors = {e["path"]: e["message"] for e in result["data"]["errors"]} + assert "radio.bandwidth" in errors + assert "kiss.baud_rate" in errors + + +def test_validate_config_radio_numeric_ranges_and_modes(cherrypy_ctx, tmp_path): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api() + api._config_path = str(tmp_path / "config.yaml") + (tmp_path / "config.yaml").write_text( + """ +repeater: + node_name: mesh-node-ranges + security: + admin_password: supersecret +radio_type: sx1262 +radio: + frequency: 99 + bandwidth: 12345 + spreading_factor: 4 + coding_rate: 9 + tx_power: 31 + preamble_length: 0 +sx1262: + bus_id: 0 + cs_id: 0 + cs_pin: 8 + reset_pin: 25 + busy_pin: 24 + irq_pin: 16 + txen_pin: 18 + rxen_pin: 17 +""".strip(), + encoding="utf-8", + ) + + result = api.validate_config() + + assert result["success"] is True + assert result["data"]["valid"] is False + paths = {e["path"] for e in result["data"]["errors"]} + assert "radio.frequency" in paths + assert "radio.bandwidth" in paths + assert "radio.spreading_factor" in paths + assert "radio.coding_rate" in paths + assert "radio.tx_power" in paths + assert "radio.preamble_length" in paths + + +def test_validate_config_en_pins_type_and_entry_validation(cherrypy_ctx, tmp_path): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api() + api._config_path = str(tmp_path / "config.yaml") + (tmp_path / "config.yaml").write_text( + """ +repeater: + node_name: mesh-node-enpins + security: + admin_password: supersecret +radio_type: sx1262 +radio: + frequency: 869618000 + bandwidth: 62500 + spreading_factor: 8 + coding_rate: 5 + tx_power: 22 + preamble_length: 16 +sx1262: + bus_id: 0 + cs_id: 0 + cs_pin: 8 + reset_pin: 25 + busy_pin: 24 + irq_pin: 16 + txen_pin: 18 + rxen_pin: 17 + en_pins: [21, bad] +""".strip(), + encoding="utf-8", + ) + + result = api.validate_config() + + assert result["success"] is True + assert result["data"]["valid"] is False + paths = {e["path"] for e in result["data"]["errors"]} + assert "sx1262.en_pins[1]" in paths + + +def test_config_import_web_only_no_restart_required(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "POST" + api = _make_api({"web": {"site_name": "old"}}) + api.config_manager.update_and_save.return_value = {"ok": True} + api.config_manager.save_to_file.return_value = True + request.json = {"config": {"web": {"site_name": "new", "cors_enabled": True}}} + + result = api.config_import() + + assert result["success"] is True + assert result["restart_required"] is False + assert result["sections_updated"] == ["web"] + assert api.config["web"]["site_name"] == "new" + assert api.config["web"]["cors_enabled"] is True + + +def test_config_import_identity_redaction_preserves_by_name_for_room_servers(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "POST" + api = _make_api( + { + "identities": { + "room_servers": [ + {"name": "main-room", "identity_key": bytes.fromhex("ABCD")}, + ] + } + } + ) + api.config_manager.update_and_save.return_value = {"ok": True} + api.config_manager.save_to_file.return_value = True + request.json = { + "config": { + "identities": { + "room_servers": [ + {"name": "main-room", "identity_key": "*** REDACTED ***"}, + {"name": "new-room", "identity_key": "*** REDACTED ***"}, + ] + } + } + } + + result = api.config_import() + + assert result["success"] is True + rooms = api.config["identities"]["room_servers"] + by_name = {r["name"]: r["identity_key"] for r in rooms} + assert by_name["main-room"] == bytes.fromhex("ABCD") + # Unknown existing room keeps empty value when imported as redacted. + assert by_name["new-room"] == "" diff --git a/tests/test_engine.py b/tests/test_engine.py index bb50883..d5aa5d3 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6,6 +6,7 @@ mark_seen, validate_packet, packet scoring, TX delay, cache management, airtime duty-cycle, TX mode (forward/monitor/no_tx), and config reloading. """ import asyncio +import base64 import copy import math import time @@ -139,10 +140,11 @@ def _make_direct_packet(payload: bytes = b"\x01\x02\x03\x04", def _make_transport_flood_packet(payload: bytes = b"\x01\x02\x03\x04", path: bytes = b"", + payload_type: int = 0x01, transport_codes=(0x1234, 0x5678)) -> Packet: """Build a TRANSPORT_FLOOD-routed packet.""" pkt = Packet() - pkt.header = ROUTE_TYPE_TRANSPORT_FLOOD | (0x01 << PH_TYPE_SHIFT) + pkt.header = ROUTE_TYPE_TRANSPORT_FLOOD | (payload_type << PH_TYPE_SHIFT) pkt.payload = bytearray(payload) pkt.payload_len = len(payload) pkt.path = bytearray(path) @@ -153,12 +155,13 @@ def _make_transport_flood_packet(payload: bytes = b"\x01\x02\x03\x04", def _make_transport_direct_packet(payload: bytes = b"\x01\x02\x03\x04", path: bytes = None, + payload_type: int = 0x01, transport_codes=(0x1234, 0x5678)) -> Packet: """Build a TRANSPORT_DIRECT-routed packet with path[0] == LOCAL_HASH.""" if path is None: path = bytes([LOCAL_HASH, 0xCC]) pkt = Packet() - pkt.header = ROUTE_TYPE_TRANSPORT_DIRECT | (0x01 << PH_TYPE_SHIFT) + pkt.header = ROUTE_TYPE_TRANSPORT_DIRECT | (payload_type << PH_TYPE_SHIFT) pkt.payload = bytearray(payload) pkt.payload_len = len(payload) pkt.path = bytearray(path) @@ -1377,3 +1380,621 @@ class TestRecordPacketOnlyTrace: handler.record_packet_only(pkt, {"rssi": -80, "snr": 10.0}) storage.record_packet.assert_not_called() assert len(handler.recent_packets) == n_before + + +# =================================================================== +# 18. Real packet injection through __call__ +# =================================================================== + + +def _inject_from_wire(pkt: Packet) -> Packet: + """Serialize and deserialize a packet to simulate a real RF wire packet.""" + wire = pkt.write_to() + injected = Packet() + injected.read_from(wire) + return injected + + +@pytest.mark.asyncio +class TestPacketInjectionRouting: + """Inject real serialized packets through __call__ and assert routing outcomes.""" + + @staticmethod + def _prepare_fast_tx(handler): + handler.airtime_mgr.calculate_airtime = MagicMock(return_value=20.0) + handler.airtime_mgr.can_transmit = MagicMock(return_value=(True, 0.0)) + handler.airtime_mgr.record_tx = MagicMock() + handler.airtime_mgr.record_rx = MagicMock() + + async def test_injected_flood_forwards_and_appends_path(self, handler): + self._prepare_fast_tx(handler) + + pkt = _inject_from_wire( + _make_flood_packet(payload=b"\x10\x20\x30", path=b"\x11") + ) + + with ( + patch.object(handler, "_calculate_tx_delay", return_value=0.0), + patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock), + ): + await handler(pkt, {"snr": 7.0, "rssi": -70}, local_transmission=False) + + assert handler.dispatcher.send_packet.call_count == 1 + sent_pkt = handler.dispatcher.send_packet.call_args.args[0] + assert bytes(sent_pkt.path) == bytes([0x11, LOCAL_HASH]) + assert handler.rx_count == 1 + assert handler.forwarded_count == 1 + assert handler.dropped_count == 0 + + async def test_injected_direct_forwards_and_consumes_hop(self, handler): + self._prepare_fast_tx(handler) + + pkt = _inject_from_wire( + _make_direct_packet(payload=b"\xAA\xBB", path=bytes([LOCAL_HASH, 0x44, 0x55])) + ) + + with ( + patch.object(handler, "_calculate_tx_delay", return_value=0.0), + patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock), + ): + await handler(pkt, {"snr": 3.0, "rssi": -82}, local_transmission=False) + + assert handler.dispatcher.send_packet.call_count == 1 + sent_pkt = handler.dispatcher.send_packet.call_args.args[0] + assert bytes(sent_pkt.path) == b"\x44\x55" + + async def test_direct_for_other_node_is_dropped(self, handler): + pkt = _inject_from_wire( + _make_direct_packet(payload=b"\xAA\xBB", path=b"\xFE\x44") + ) + + with patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock): + await handler(pkt, {"snr": 2.0, "rssi": -90}, local_transmission=False) + + assert handler.dispatcher.send_packet.call_count == 0 + assert handler.dropped_count == 1 + assert "not for us" in (handler.recent_packets[-1]["drop_reason"] or "") + + async def test_duplicate_wire_packet_not_retransmitted(self, handler): + self._prepare_fast_tx(handler) + + incoming = _make_flood_packet(payload=b"\x99\x88\x77", path=b"\x01") + pkt1 = _inject_from_wire(incoming) + pkt2 = _inject_from_wire(incoming) + + with ( + patch.object(handler, "_calculate_tx_delay", return_value=0.0), + patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock), + ): + await handler(pkt1, {"snr": 6.0, "rssi": -75}, local_transmission=False) + await handler(pkt2, {"snr": 5.5, "rssi": -76}, local_transmission=False) + + assert handler.dispatcher.send_packet.call_count == 1 + assert handler.dropped_count == 1 + assert handler.flood_dup_count == 1 + original = handler.recent_packets[-1] + assert "duplicates" in original + assert len(original["duplicates"]) == 1 + assert original["duplicates"][0]["drop_reason"] == "Duplicate" + + async def test_transport_flood_injection_honors_transport_key_decision(self, handler): + pkt = _inject_from_wire( + _make_transport_flood_packet(payload=b"\x01\x02\x03\x04", path=b"") + ) + + with ( + patch.object(handler, "_check_transport_codes", return_value=(False, "denied")), + patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock), + ): + await handler(pkt, {"snr": 0.0, "rssi": -92}, local_transmission=False) + + assert handler.dispatcher.send_packet.call_count == 0 + assert "transport" in (handler.recent_packets[-1]["drop_reason"] or "").lower() + + async def test_local_tx_then_rf_echo_is_duplicate(self, handler): + self._prepare_fast_tx(handler) + + local_pkt = _make_flood_packet(payload=b"\x0A\x0B\x0C", path=b"") + + with ( + patch.object(handler, "_calculate_tx_delay", return_value=0.0), + patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock), + ): + await handler(local_pkt, {"snr": 0.0, "rssi": -50}, local_transmission=True) + rf_echo = _inject_from_wire( + _make_flood_packet(payload=b"\x0A\x0B\x0C", path=b"") + ) + await handler(rf_echo, {"snr": 0.0, "rssi": -70}, local_transmission=False) + + assert handler.dispatcher.send_packet.call_count == 1 + assert handler.dropped_count == 1 + assert handler.flood_dup_count == 1 + original = handler.recent_packets[-1] + assert "duplicates" in original + assert len(original["duplicates"]) == 1 + assert original["duplicates"][0]["drop_reason"] == "Duplicate" + + @pytest.mark.parametrize("payload_type", range(16)) + async def test_all_payload_types_flood_injection_forwards(self, handler, payload_type): + self._prepare_fast_tx(handler) + pkt = _inject_from_wire( + _make_flood_packet( + payload=bytes([payload_type, 0xA5, 0x5A]), + path=b"\x11", + payload_type=payload_type, + ) + ) + + with ( + patch.object(handler, "_calculate_tx_delay", return_value=0.0), + patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock), + ): + await handler(pkt, {"snr": 4.0, "rssi": -78}, local_transmission=False) + + assert handler.dispatcher.send_packet.call_count == 1 + sent_pkt = handler.dispatcher.send_packet.call_args.args[0] + assert sent_pkt.get_payload_type() == payload_type + assert sent_pkt.path[-1] == LOCAL_HASH + + @pytest.mark.parametrize("payload_type", range(16)) + async def test_all_payload_types_direct_injection_forwards(self, handler, payload_type): + self._prepare_fast_tx(handler) + pkt = _inject_from_wire( + _make_direct_packet( + payload=bytes([payload_type, 0x01, 0x02]), + path=bytes([LOCAL_HASH, 0x44, 0x55]), + payload_type=payload_type, + ) + ) + + with ( + patch.object(handler, "_calculate_tx_delay", return_value=0.0), + patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock), + ): + await handler(pkt, {"snr": 2.5, "rssi": -84}, local_transmission=False) + + assert handler.dispatcher.send_packet.call_count == 1 + sent_pkt = handler.dispatcher.send_packet.call_args.args[0] + assert sent_pkt.get_payload_type() == payload_type + assert bytes(sent_pkt.path) == b"\x44\x55" + + @pytest.mark.parametrize("payload_type", range(16)) + async def test_all_payload_types_transport_flood_injection_forwards(self, handler, payload_type): + self._prepare_fast_tx(handler) + pkt = _inject_from_wire( + _make_transport_flood_packet( + payload=bytes([payload_type, 0x33, 0x44]), + path=b"", + payload_type=payload_type, + transport_codes=(0x1111, 0x2222), + ) + ) + + with ( + patch.object(handler, "_check_transport_codes", return_value=(True, "")), + patch.object(handler, "_calculate_tx_delay", return_value=0.0), + patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock), + ): + await handler(pkt, {"snr": 1.0, "rssi": -88}, local_transmission=False) + + assert handler.dispatcher.send_packet.call_count == 1 + sent_pkt = handler.dispatcher.send_packet.call_args.args[0] + assert sent_pkt.get_payload_type() == payload_type + assert sent_pkt.transport_codes == [0x1111, 0x2222] + + @pytest.mark.parametrize("payload_type", range(16)) + async def test_all_payload_types_transport_direct_injection_forwards(self, handler, payload_type): + self._prepare_fast_tx(handler) + pkt = _inject_from_wire( + _make_transport_direct_packet( + payload=bytes([payload_type, 0x66, 0x77]), + path=bytes([LOCAL_HASH, 0x22]), + payload_type=payload_type, + transport_codes=(0x3333, 0x4444), + ) + ) + + with ( + patch.object(handler, "_calculate_tx_delay", return_value=0.0), + patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock), + ): + await handler(pkt, {"snr": 3.0, "rssi": -83}, local_transmission=False) + + assert handler.dispatcher.send_packet.call_count == 1 + sent_pkt = handler.dispatcher.send_packet.call_args.args[0] + assert sent_pkt.get_payload_type() == payload_type + assert bytes(sent_pkt.path) == b"\x22" + assert sent_pkt.transport_codes == [0x3333, 0x4444] + + +# =================================================================== +# 19. Missed branch coverage (background/transport helpers) +# =================================================================== + + +class TestMissedEngineBranches: + """Target previously untested helper/lifecycle branches in RepeaterHandler.""" + + def test_check_transport_codes_accepts_matching_key_and_uses_cache(self, handler): + key_raw = b"0123456789ABCDEF" + key_b64 = base64.b64encode(key_raw).decode("ascii") + handler.storage.get_transport_keys.return_value = [ + { + "id": 7, + "name": "primary", + "transport_key": key_b64, + "flood_policy": "allow", + } + ] + + pkt = _make_transport_flood_packet(payload=b"\x01\x02", path=b"") + pkt.transport_codes = [0xCAFE, 0xBEEF] + + with patch("pymc_core.protocol.transport_keys.calc_transport_code", return_value=0xCAFE): + allowed_1, reason_1 = handler._check_transport_codes(pkt) + allowed_2, reason_2 = handler._check_transport_codes(pkt) + + assert allowed_1 is True and reason_1 == "" + assert allowed_2 is True and reason_2 == "" + assert handler.storage.get_transport_keys.call_count == 1 + assert handler.storage.update_transport_key.call_count == 2 + + def test_record_duplicate_groups_under_original(self, handler): + pkt = _make_flood_packet(payload=b"\x12\x34") + original_hash = pkt.calculate_packet_hash().hex().upper()[:16] + + original_record = { + "timestamp": time.time(), + "packet_hash": original_hash, + "transmitted": True, + } + handler._append_recent_packet(original_record) + handler.record_duplicate(pkt, rssi=-90, snr=1.5) + + assert handler.flood_dup_count == 1 + assert "duplicates" in original_record + assert len(original_record["duplicates"]) == 1 + assert original_record["duplicates"][0]["drop_reason"] == "Duplicate" + + @pytest.mark.asyncio + async def test_record_crc_errors_async_records_positive_delta(self, handler): + handler.dispatcher.radio.crc_error_count = 9 + handler._last_crc_error_count = 4 + + await handler._record_crc_errors_async() + + handler.storage.record_crc_errors.assert_called_once_with(5) + assert handler._last_crc_error_count == 9 + + @pytest.mark.asyncio + async def test_record_noise_floor_async_caches_and_persists(self, handler): + with patch.object(handler, "get_noise_floor", return_value=-117.5): + await handler._record_noise_floor_async() + + assert handler._cached_noise_floor == -117.5 + handler.storage.record_noise_floor.assert_called_once_with(-117.5) + + @pytest.mark.asyncio + async def test_send_periodic_advert_async_success_and_failure(self, handler): + handler.send_advert_func = AsyncMock(side_effect=[True, False]) + + await handler._send_periodic_advert_async() + await handler._send_periodic_advert_async() + + assert handler.send_advert_func.await_count == 2 + + def test_cleanup_cancels_background_task_and_closes_storage(self, handler): + fake_task = MagicMock() + fake_task.done.return_value = False + handler._background_task = fake_task + + handler.cleanup() + + fake_task.cancel.assert_called_once() + handler.storage.close.assert_called_once() + + +# =================================================================== +# 20. Transmission and Background Lifecycle Branches +# =================================================================== + + +class TestEngineTransmissionAndBackgroundLifecycle: + """Cover duty-cycle outcomes, packet-record robustness, and background timer lifecycle.""" + + @pytest.mark.asyncio + async def test_local_tx_defers_when_duty_cycle_blocked(self, handler): + pkt = _make_flood_packet(payload=b"\x21\x22") + handler.airtime_mgr.calculate_airtime = MagicMock(return_value=120.0) + handler.airtime_mgr.can_transmit = MagicMock(return_value=(False, 2.0)) + + with patch.object(handler, "_calculate_tx_delay", return_value=0.5): + loop = asyncio.get_running_loop() + completed = loop.create_future() + completed.set_result(None) + + async def _fake_schedule(packet, delay, airtime_ms, local_transmission=False): + packet._tx_metadata = { + "lbt_attempts": 2, + "lbt_backoff_delays_ms": [10, 20], + "lbt_channel_busy": True, + } + return completed + + handler.schedule_retransmit = AsyncMock(side_effect=_fake_schedule) + + await handler(pkt, {"snr": 0.0, "rssi": -50}, local_transmission=True) + + handler.schedule_retransmit.assert_awaited_once() + args = handler.schedule_retransmit.await_args.args + assert args[0] is pkt + assert args[1] == pytest.approx(2.5) # original delay + duty-cycle wait + assert args[2] == 120.0 + assert handler.forwarded_count == 1 + assert handler.dropped_count == 0 + assert handler.recent_packets[-1]["lbt_attempts"] == 2 + + @pytest.mark.asyncio + async def test_local_deferred_tx_failure_decrements_forwarded_counter(self, handler): + pkt = _make_flood_packet(payload=b"\x23\x24") + handler.airtime_mgr.calculate_airtime = MagicMock(return_value=55.0) + handler.airtime_mgr.can_transmit = MagicMock(return_value=(False, 1.0)) + + loop = asyncio.get_running_loop() + failing = loop.create_future() + failing.set_exception(RuntimeError("deferred tx failed")) + handler.schedule_retransmit = AsyncMock(return_value=failing) + + with patch.object(handler, "_calculate_tx_delay", return_value=0.2): + with pytest.raises(RuntimeError, match="deferred tx failed"): + await handler(pkt, {"snr": 0.0, "rssi": -52}, local_transmission=True) + + assert handler.forwarded_count == 0 + + @pytest.mark.asyncio + async def test_non_local_drop_when_duty_cycle_blocked(self, handler): + pkt = _make_flood_packet(payload=b"\x31\x32") + handler.airtime_mgr.calculate_airtime = MagicMock(return_value=80.0) + handler.airtime_mgr.can_transmit = MagicMock(return_value=(False, 1.25)) + handler.process_packet = MagicMock(return_value=(pkt, 0.1)) + handler.schedule_retransmit = AsyncMock() + + await handler(pkt, {"snr": 5.0, "rssi": -75}, local_transmission=False) + + handler.schedule_retransmit.assert_not_awaited() + assert handler.dropped_count == 1 + assert handler.forwarded_count == 0 + assert handler.recent_packets[-1]["drop_reason"] == "Duty cycle limit" + + @pytest.mark.asyncio + async def test_tx_failure_rolls_back_forwarded_counter(self, handler): + pkt = _make_flood_packet(payload=b"\x41\x42") + handler.airtime_mgr.calculate_airtime = MagicMock(return_value=40.0) + handler.airtime_mgr.can_transmit = MagicMock(return_value=(True, 0.0)) + + loop = asyncio.get_running_loop() + failing = loop.create_future() + failing.set_exception(RuntimeError("radio busy")) + handler.schedule_retransmit = AsyncMock(return_value=failing) + + with patch.object(handler, "_calculate_tx_delay", return_value=0.0): + with pytest.raises(RuntimeError, match="radio busy"): + await handler(pkt, {"snr": 0.0, "rssi": -40}, local_transmission=True) + + # Incremented before scheduling, decremented on failure path. + assert handler.forwarded_count == 0 + + def test_record_packet_only_missing_header_and_storage_failure(self, handler): + pkt = _make_flood_packet(payload=b"\x51\x52") + n_before = len(handler.recent_packets) + + pkt.header = None + handler.record_packet_only(pkt, {"rssi": -70, "snr": 2.0}) + assert len(handler.recent_packets) == n_before + + pkt.header = ROUTE_TYPE_FLOOD | (0x01 << PH_TYPE_SHIFT) + handler.storage.record_packet.side_effect = RuntimeError("db down") + handler.record_packet_only(pkt, {"rssi": -70, "snr": 2.0}) + # Storage failure should not append to recent list. + assert len(handler.recent_packets) == n_before + + def test_log_trace_record_updates_counters_even_if_storage_fails(self, handler): + base_rx = handler.rx_count + base_fwd = handler.forwarded_count + base_drop = handler.dropped_count + handler.storage.record_packet.side_effect = RuntimeError("write fail") + + handler.log_trace_record({"packet_hash": "ABC123", "transmitted": True}) + handler.log_trace_record({"packet_hash": "DEF456", "transmitted": False}) + + assert handler.rx_count == base_rx + 2 + assert handler.forwarded_count == base_fwd + 1 + assert handler.dropped_count == base_drop + 1 + + def test_record_duplicate_direct_route_updates_duplicate_counters(self, handler): + pkt = _make_direct_packet(payload=b"\x61\x62", path=bytes([LOCAL_HASH, 0xAA])) + handler.record_duplicate(pkt, rssi=-88, snr=1.2) + + assert handler.recv_direct_count == 1 + assert handler.direct_dup_count == 1 + + def test_start_background_tasks_only_starts_once(self, handler): + marker_task = MagicMock(name="bg_task") + + def _fake_create_task(coro): + coro.close() + return marker_task + + with patch("repeater.engine.asyncio.create_task", side_effect=_fake_create_task) as mk_task: + handler._background_task = None + handler._start_background_tasks() + handler._start_background_tasks() + + mk_task.assert_called_once() + assert handler._background_task is marker_task + + @pytest.mark.asyncio + async def test_background_timer_loop_runs_tasks_and_handles_cancel(self, handler): + handler.last_noise_measurement = 0 + handler.noise_floor_interval = 1 + handler.send_advert_interval_hours = 1 + handler.send_advert_func = AsyncMock() + handler.last_advert_time = 0 + handler.last_cache_cleanup = 0 + handler.last_db_cleanup = 0 + handler.cleanup_cache = MagicMock() + handler._record_noise_floor_async = AsyncMock() + handler._record_crc_errors_async = AsyncMock() + handler._send_periodic_advert_async = AsyncMock() + + with ( + patch("repeater.engine.time.time", return_value=100000.0), + patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock, side_effect=asyncio.CancelledError), + ): + with pytest.raises(asyncio.CancelledError): + await handler._background_timer_loop() + + handler._record_noise_floor_async.assert_awaited_once() + handler._record_crc_errors_async.assert_awaited_once() + handler._send_periodic_advert_async.assert_awaited_once() + handler.cleanup_cache.assert_called_once() + handler.storage.cleanup_old_data.assert_called_once() + + @pytest.mark.asyncio + async def test_background_timer_loop_continues_when_db_cleanup_fails(self, handler): + handler.last_noise_measurement = 0 + handler.noise_floor_interval = 999999 + handler.send_advert_interval_hours = 0 + handler.last_cache_cleanup = 0 + handler.last_db_cleanup = 0 + handler.cleanup_cache = MagicMock() + handler._record_noise_floor_async = AsyncMock() + handler._record_crc_errors_async = AsyncMock() + handler.storage.cleanup_old_data.side_effect = RuntimeError("cleanup error") + + with ( + patch("repeater.engine.time.time", return_value=100000.0), + patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock, side_effect=asyncio.CancelledError), + ): + with pytest.raises(asyncio.CancelledError): + await handler._background_timer_loop() + + handler.storage.cleanup_old_data.assert_called_once() + + @pytest.mark.asyncio + async def test_background_timer_loop_exception_restarts_task(self, handler): + handler._record_noise_floor_async = AsyncMock(side_effect=RuntimeError("boom")) + handler.last_noise_measurement = 0 + handler.noise_floor_interval = 1 + + created = {} + + def _fake_create_task(coro): + created["called"] = True + # Avoid leaking an un-awaited coroutine in the test process. + coro.close() + return "restarted-task" + + with ( + patch("repeater.engine.time.time", return_value=100000.0), + patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock, return_value=None) as sleep_mock, + patch("repeater.engine.asyncio.create_task", side_effect=_fake_create_task), + ): + await handler._background_timer_loop() + + sleep_mock.assert_awaited_once_with(30) + assert created.get("called") is True + assert handler._background_task == "restarted-task" + + @pytest.mark.asyncio + async def test_record_noise_floor_handles_none_and_exceptions(self, handler): + with patch.object(handler, "get_noise_floor", return_value=None): + await handler._record_noise_floor_async() + handler.storage.record_noise_floor.assert_not_called() + + with patch.object(handler, "get_noise_floor", side_effect=RuntimeError("noise fail")): + await handler._record_noise_floor_async() + + @pytest.mark.asyncio + async def test_record_crc_errors_returns_without_storage_and_handles_storage_exception(self, handler): + # No storage configured: should return early. + handler.storage = None + await handler._record_crc_errors_async() + + # Restore storage and force write error on positive delta. + handler.storage = MagicMock() + handler._last_crc_error_count = 1 + handler.dispatcher.radio.crc_error_count = 3 + handler.storage.record_crc_errors.side_effect = RuntimeError("crc write fail") + + await handler._record_crc_errors_async() + + @pytest.mark.asyncio + async def test_send_periodic_advert_handles_missing_handler_and_handler_exception(self, handler): + handler.send_advert_func = None + await handler._send_periodic_advert_async() + + handler.send_advert_func = AsyncMock(side_effect=RuntimeError("advert fail")) + await handler._send_periodic_advert_async() + + +class TestEngineRecordAndCleanupHelpers: + """Cover helper fallbacks that protect UI visibility and in-memory index integrity.""" + + def test_record_duplicate_appends_when_original_not_found(self, handler): + # Keep recent non-empty but ensure duplicate hash is not indexed. + handler._append_recent_packet({"packet_hash": "OTHERHASH", "transmitted": True}) + pkt = _make_flood_packet(payload=b"\x71\x72") + + handler.record_duplicate(pkt, rssi=-85, snr=1.0) + + assert handler.recent_packets[-1]["drop_reason"] == "Duplicate" + assert handler.recent_packets[-1]["packet_hash"] == pkt.calculate_packet_hash().hex().upper()[:16] + + def test_record_duplicate_appends_when_recent_packets_empty(self, handler): + handler.recent_packets.clear() + handler._recent_hash_index.clear() + pkt = _make_flood_packet(payload=b"\x73\x74") + + handler.record_duplicate(pkt, rssi=-82, snr=1.1) + + assert len(handler.recent_packets) == 1 + assert handler.recent_packets[0]["drop_reason"] == "Duplicate" + + def test_record_duplicate_route_zero_maps_to_flood_counters(self, handler): + pkt = _make_flood_packet(payload=b"\x75\x76") + # Route nibble 0 is parsed as FLOOD in current protocol constants. + pkt.header = (0x00 << PH_TYPE_SHIFT) + + handler.record_duplicate(pkt, rssi=-90, snr=0.5) + + assert handler.flood_dup_count == 1 + assert handler.direct_dup_count == 0 + + def test_append_recent_packet_eviction_removes_matching_index_entry(self, handler): + handler.max_recent_packets = 1 + old = {"packet_hash": "OLDHASH"} + handler.recent_packets.append(old) + handler._recent_hash_index["OLDHASH"] = old + + handler._append_recent_packet({"packet_hash": "NEWHASH"}) + + assert "OLDHASH" not in handler._recent_hash_index + assert handler.recent_packets[-1]["packet_hash"] == "NEWHASH" + assert handler._recent_hash_index["NEWHASH"] is handler.recent_packets[-1] + + def test_append_recent_packet_without_hash_skips_index_update(self, handler): + base_index = dict(handler._recent_hash_index) + handler._append_recent_packet({"timestamp": time.time()}) + assert dict(handler._recent_hash_index) == base_index + + def test_cleanup_handles_storage_close_exception(self, handler): + fake_task = MagicMock() + fake_task.done.return_value = False + handler._background_task = fake_task + handler.storage.close.side_effect = RuntimeError("close failed") + + # cleanup should swallow close errors and not raise. + handler.cleanup() + + fake_task.cancel.assert_called_once() diff --git a/tests/test_flood_loop_dedup.py b/tests/test_flood_loop_dedup.py index cb185aa..76c2b2a 100644 --- a/tests/test_flood_loop_dedup.py +++ b/tests/test_flood_loop_dedup.py @@ -321,26 +321,21 @@ class TestOwnHashReForwarding: class TestLoopDetectionMultiByte: """ - Loop detection currently counts byte-level matches against local_hash - (single int). In multi-byte mode the per-hop hash is >1 byte, so - individual bytes in the path may coincidentally match. - These tests verify the actual engine behaviour. + Loop detection is hash-size aware: each hop is compared as a full + hash chunk (1, 2, or 3 bytes), not byte-by-byte. """ - def test_2_byte_mode_strict_byte_level_match(self): + def test_2_byte_mode_strict_partial_byte_match_does_not_loop(self): """ - In 2-byte mode with strict, _is_flood_looped scans individual bytes. - If local_hash (0xAB) appears as a byte anywhere in the 2-byte path - entries, it counts as a match. + In 2-byte mode, a partial byte overlap (0xABxx) is not a loop unless + the full 2-byte local hash (0xABCD) matches a hop. """ h = _make_handler(loop_detect="strict", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF])) - # Path: 2-byte hop [0xAB, 0x11] — byte 0xAB appears once + # Path hop is AB11; local 2-byte hash is ABCD. pkt = _make_flood_packet(b"\xAB\x11", hash_size=2, hash_count=1) result = h.flood_forward(pkt) - # strict threshold=1, 0xAB appears once in raw bytes → loop detected - assert result is None - assert "loop" in pkt.drop_reason.lower() + assert result is not None def test_2_byte_mode_off_ignores_byte_match(self): """With loop_detect=off, even byte-level 0xAB matches are ignored.""" @@ -359,28 +354,26 @@ class TestLoopDetectionMultiByte: result = h.flood_forward(pkt) assert result is not None - def test_3_byte_mode_local_hash_byte_in_path(self): - """In 3-byte mode, the 0xAB byte anywhere triggers strict loop detection.""" + def test_3_byte_mode_partial_byte_match_does_not_loop(self): + """In 3-byte mode, partial byte overlap is not enough to trigger strict.""" h = _make_handler(loop_detect="strict", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF])) - # 3-byte hop: [0x11, 0xAB, 0x33] — 0xAB in the middle + # Hop 11AB33 does not equal local 3-byte hash ABCDEF. pkt = _make_flood_packet(b"\x11\xAB\x33", hash_size=3, hash_count=1) result = h.flood_forward(pkt) - assert result is None + assert result is not None - def test_moderate_multi_byte_counts_all_byte_occurrences(self): + def test_moderate_multi_byte_requires_full_hash_occurrences(self): """ - moderate threshold=2. With 2-byte hops, each byte is counted - independently, so two occurrences of 0xAB across different hops - triggers the loop. + moderate threshold=2 counts full 2-byte hash matches only. + Two hops with ABxx but not ABCD must not loop. """ h = _make_handler(loop_detect="moderate", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF])) - # Two 2-byte hops: [0xAB, 0x11, 0xAB, 0x22] — 0xAB appears twice + # Two 2-byte hops: AB11 and AB22 (neither equals ABCD) pkt = _make_flood_packet(b"\xAB\x11\xAB\x22", hash_size=2, hash_count=2) result = h.flood_forward(pkt) - assert result is None - assert "loop" in pkt.drop_reason.lower() + assert result is not None def test_2_byte_flood_forward_appends_correctly(self): """ diff --git a/tests/test_glass_handler.py b/tests/test_glass_handler.py index 694520a..2624e5e 100644 --- a/tests/test_glass_handler.py +++ b/tests/test_glass_handler.py @@ -197,10 +197,10 @@ def test_build_inform_payload_contains_expected_fields(): 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["location"] == "51.507400,-0.127800" assert payload["radio"]["frequency"] == 869618000 assert payload["counters"]["duplicates"] == 4 - assert payload["settings"]["repeater"]["location"] == "51.9074,-0.1570" + assert payload["settings"]["repeater"]["location"] == "51.5074,-0.1278" assert payload["settings"]["repeater"]["identity_key"] == "" assert payload["settings"]["glass"]["mqtt_password"] == "" assert payload["command_results"][0]["command_id"] == "cmd-1" @@ -212,9 +212,12 @@ def test_execute_set_mode_command_updates_config(): manager = _DummyConfigManager() handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager) - ok, message = asyncio.run(handler._execute_command_action("set_mode", {"mode": "monitor"})) + ok, message, details = asyncio.run( + handler._execute_command_action("set_mode", {"mode": "monitor"}) + ) assert ok is True assert "Config patched" in message + assert details is None assert manager.calls assert manager.calls[-1]["updates"]["repeater"]["mode"] == "monitor" diff --git a/tests/test_handler_helpers_acl_advert.py b/tests/test_handler_helpers_acl_advert.py new file mode 100644 index 0000000..7f5af2f --- /dev/null +++ b/tests/test_handler_helpers_acl_advert.py @@ -0,0 +1,296 @@ +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from repeater.handler_helpers.acl import ACL, PERM_ACL_ADMIN, PERM_ACL_GUEST +from repeater.handler_helpers.advert import AdvertHelper, MeshActivityTier + + +class _FakeIdentity: + def __init__(self, pubkey: bytes): + self._pubkey = pubkey + + def get_public_key(self): + return self._pubkey + + +class _FakePacket: + def __init__(self, *, header=0x00, path=None, pkt_hash=b"\xAA" * 16): + self.header = header + self.path = path if path is not None else bytearray() + self._pkt_hash = pkt_hash + self.mark_do_not_retransmit = MagicMock() + self.drop_reason = None + + def calculate_packet_hash(self): + return self._pkt_hash + + +def test_acl_blank_password_guest_rules_and_room_server_password_requirements(): + identity = _FakeIdentity(b"A" * 32) + + acl = ACL(allow_read_only=True) + ok, perms = acl.authenticate_client( + client_identity=identity, + shared_secret=b"secret", + password="", + timestamp=10, + ) + assert ok is True + assert perms == PERM_ACL_GUEST + + acl_ro_disabled = ACL(allow_read_only=False) + ok2, perms2 = acl_ro_disabled.authenticate_client( + client_identity=identity, + shared_secret=b"secret", + password="", + timestamp=10, + ) + assert ok2 is False + assert perms2 == 0 + + room_cfg = {"type": "room_server", "settings": {}} + ok3, perms3 = acl.authenticate_client( + client_identity=identity, + shared_secret=b"secret", + password="admin", + timestamp=11, + target_identity_name="room-a", + target_identity_config=room_cfg, + ) + assert ok3 is False + assert perms3 == 0 + + +def test_acl_admin_login_sets_client_state_and_replay_protection(): + identity = _FakeIdentity(b"B" * 32) + acl = ACL(max_clients=5, admin_password="top-secret", guest_password="guest") + + ok, perms = acl.authenticate_client( + client_identity=identity, + shared_secret=b"k" * 32, + password="top-secret", + timestamp=100, + sync_since=77, + ) + assert ok is True + assert perms == PERM_ACL_ADMIN + + client = acl.get_client(b"B" * 40) + assert client is not None + assert client.shared_secret == b"k" * 32 + assert client.last_timestamp == 100 + assert client.sync_since == 77 + assert client.is_admin() is True + + replay_ok, replay_perms = acl.authenticate_client( + client_identity=identity, + shared_secret=b"k" * 32, + password="top-secret", + timestamp=100, + ) + assert replay_ok is False + assert replay_perms == 0 + + +def test_acl_max_clients_invalid_password_and_remove_client_paths(): + acl = ACL(max_clients=1, admin_password="a", guest_password="g") + id_a = _FakeIdentity(b"C" * 32) + id_b = _FakeIdentity(b"D" * 32) + + ok_a, _ = acl.authenticate_client(id_a, b"s", "a", timestamp=1) + assert ok_a is True + assert acl.get_num_clients() == 1 + + full_ok, full_perms = acl.authenticate_client(id_b, b"s", "a", timestamp=2) + assert full_ok is False + assert full_perms == 0 + + bad_ok, bad_perms = acl.authenticate_client(id_a, b"s", "bad", timestamp=3) + assert bad_ok is False + assert bad_perms == 0 + + assert acl.remove_client(b"C" * 32) is True + assert acl.remove_client(b"C" * 32) is False + + +@pytest.mark.asyncio +async def test_advert_process_invalid_packet_marks_drop_and_no_storage(): + storage = SimpleNamespace(get_neighbors=lambda: {}, record_advert=MagicMock()) + helper = AdvertHelper(local_identity=None, storage=storage, config={"repeater": {}}) + helper.advert_handler = AsyncMock(return_value={"valid": False}) + + packet = _FakePacket() + await helper.process_advert_packet(packet, rssi=-80, snr=6.5) + + packet.mark_do_not_retransmit.assert_called_once() + assert packet.drop_reason == "Invalid advert packet" + storage.record_advert.assert_not_called() + + +@pytest.mark.asyncio +async def test_advert_duplicate_reheard_skips_storage_and_tracks_duplicate_stat(): + storage = SimpleNamespace(get_neighbors=lambda: {}, record_advert=MagicMock()) + helper = AdvertHelper(local_identity=None, storage=storage, config={"repeater": {}}) + helper.advert_handler = AsyncMock( + return_value={ + "valid": True, + "public_key": "11" * 32, + "name": "node-1", + "contact_type": "REPEATER", + "latitude": 1.0, + "longitude": 2.0, + } + ) + + packet = _FakePacket(pkt_hash=b"\x10" * 16) + await helper.process_advert_packet(packet, rssi=-70, snr=5.0) + await helper.process_advert_packet(packet, rssi=-70, snr=5.0) + + assert storage.record_advert.call_count == 1 + stats = helper.get_rate_limit_stats() + assert stats["stats"]["adverts_duplicate_reheard"] == 1 + + +@pytest.mark.asyncio +async def test_advert_own_advert_is_ignored_after_validation(): + local = _FakeIdentity(bytes.fromhex("22" * 32)) + storage = SimpleNamespace(get_neighbors=lambda: {}, record_advert=MagicMock()) + helper = AdvertHelper(local_identity=local, storage=storage, config={"repeater": {}}) + helper.advert_handler = AsyncMock( + return_value={ + "valid": True, + "public_key": ("22" * 32), + "name": "self-node", + "contact_type": "REPEATER", + "latitude": 1.0, + "longitude": 2.0, + } + ) + + await helper.process_advert_packet(_FakePacket(), rssi=-60, snr=8.0) + + storage.record_advert.assert_not_called() + + +@pytest.mark.asyncio +async def test_advert_new_neighbor_persists_record_and_flags_new_neighbor(): + stored_records = [] + + def _record_advert(data): + stored_records.append(data) + + storage = SimpleNamespace(get_neighbors=lambda: {}, record_advert=_record_advert) + helper = AdvertHelper(local_identity=None, storage=storage, config={"repeater": {}}) + helper.advert_handler = AsyncMock( + return_value={ + "valid": True, + "public_key": "33" * 32, + "name": "neighbor-a", + "contact_type": "REPEATER", + "latitude": 10.0, + "longitude": 20.0, + } + ) + + packet = _FakePacket(header=0x01, path=bytearray()) + await helper.process_advert_packet(packet, rssi=-75, snr=4.2) + + assert len(stored_records) == 1 + record = stored_records[0] + assert record["pubkey"] == "33" * 32 + assert record["node_name"] == "neighbor-a" + assert record["is_new_neighbor"] is True + assert record["zero_hop"] is True + + +def test_advert_allow_advert_rate_limit_penalty_and_quiet_bypass(): + cfg = { + "repeater": { + "advert_adaptive": {"enabled": False}, + "advert_rate_limit": { + "enabled": True, + "bucket_capacity": 1, + "refill_tokens": 1, + "refill_interval_seconds": 9999, + "min_interval_seconds": 100, + }, + "advert_penalty_box": { + "enabled": True, + "violation_threshold": 1, + "violation_decay_seconds": 1000, + "base_penalty_seconds": 10, + "penalty_multiplier": 2, + "max_penalty_seconds": 60, + }, + } + } + helper = AdvertHelper(local_identity=None, storage=None, config=cfg) + + t0 = time.time() + ok1, reason1 = helper._allow_advert("AA" * 16, t0) + assert ok1 is True + assert reason1 == "" + + ok2, reason2 = helper._allow_advert("AA" * 16, t0 + 1) + assert ok2 is False + assert "min-interval" in reason2 + + ok3, reason3 = helper._allow_advert("AA" * 16, t0 + 2) + assert ok3 is False + assert "penalty box active" in reason3 + + # QUIET tier bypass when adaptive mode is on + helper._adaptive_enabled = True + helper._current_tier = MeshActivityTier.QUIET + ok4, _ = helper._allow_advert("AA" * 16, t0 + 3) + assert ok4 is True + + +def test_advert_reload_config_and_cleanup_old_state_bounds_memory(): + helper = AdvertHelper(local_identity=None, storage=None, config={"repeater": {}}) + helper.config = { + "repeater": { + "advert_adaptive": { + "enabled": True, + "ewma_alpha": 0.2, + "hysteresis_seconds": 10, + "thresholds": {"normal": 2, "busy": 7, "congested": 12}, + }, + "advert_rate_limit": { + "enabled": True, + "bucket_capacity": 3, + "refill_tokens": 2, + "refill_interval_seconds": 30, + "min_interval_seconds": 5, + }, + "advert_penalty_box": { + "enabled": True, + "violation_threshold": 2, + "violation_decay_seconds": 20, + "base_penalty_seconds": 15, + "penalty_multiplier": 2, + "max_penalty_seconds": 120, + }, + "advert_dedupe": {"ttl_seconds": 30, "max_hashes": 100}, + } + } + + helper.reload_config() + assert helper._ewma_alpha == 0.2 + assert helper._base_bucket_capacity == 3.0 + assert helper._advert_dedupe_ttl_seconds == 30.0 + + now = time.time() + helper._recent_advert_hashes["old"] = now - 1 + helper._penalty_until["pk"] = now - 1 + helper._bucket_state["oldpk"] = {"last_seen": now - (helper._bucket_state_retention_seconds + 1)} + helper._violation_state["oldpk"] = {"count": 3, "last_violation": now - 9999} + + helper._cleanup_old_state(now) + + assert "old" not in helper._recent_advert_hashes + assert "pk" not in helper._penalty_until + assert "oldpk" not in helper._bucket_state diff --git a/tests/test_handler_helpers_path_protocol_text.py b/tests/test_handler_helpers_path_protocol_text.py new file mode 100644 index 0000000..6e5df86 --- /dev/null +++ b/tests/test_handler_helpers_path_protocol_text.py @@ -0,0 +1,337 @@ +import struct +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from repeater.handler_helpers.path import PathHelper +from repeater.handler_helpers.protocol_request import ProtocolRequestHelper +from repeater.handler_helpers.text import TextHelper + + +class _FakeId: + def __init__(self, pubkey: bytes): + self._pubkey = pubkey + + def get_public_key(self): + return self._pubkey + + +class _FakeClient: + def __init__(self, pubkey: bytes, shared_secret: bytes, permissions=0): + self.id = _FakeId(pubkey) + self.shared_secret = shared_secret + self.permissions = permissions + self.out_path = bytearray() + self.out_path_len = -1 + + +class _FakeACL: + def __init__(self, clients): + self._clients = list(clients) + + def get_all_clients(self): + return self._clients + + +class _PathPacket: + def __init__(self, payload: bytes): + self.payload = bytearray(payload) + + +class _ReqPacket: + def __init__(self, payload: bytes): + self.payload = bytearray(payload) + self.mark_do_not_retransmit = MagicMock() + + +@pytest.mark.asyncio +async def test_path_helper_updates_client_out_path_on_valid_decrypt(): + client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32) + acl = _FakeACL([client]) + helper = PathHelper(acl_dict={0x11: acl}) + + # Payload: dest(0x11), src(0x22), mac+data... + packet = _PathPacket(payload=b"\x11\x22\xAA\xBB\xCC") + + with patch("pymc_core.protocol.crypto.CryptoUtils.mac_then_decrypt", return_value=b"\x02\x99\x88\x01"): + handled = await helper.process_path_packet(packet) + + assert handled is False + assert client.out_path_len == 2 + assert bytes(client.out_path) == b"\x99\x88" + assert isinstance(client.last_activity, int) + + +@pytest.mark.asyncio +async def test_path_helper_returns_false_for_non_matching_or_invalid_inputs(): + client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32) + acl = _FakeACL([client]) + helper = PathHelper(acl_dict={0x11: acl}) + + assert await helper.process_path_packet(_PathPacket(payload=b"\x11")) is False + assert await helper.process_path_packet(_PathPacket(payload=b"\x33\x22\xAA\xBB")) is False + + no_secret_client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"") + helper_no_secret = PathHelper(acl_dict={0x11: _FakeACL([no_secret_client])}) + assert await helper_no_secret.process_path_packet(_PathPacket(payload=b"\x11\x22\xAA\xBB")) is False + + with patch("pymc_core.protocol.crypto.CryptoUtils.mac_then_decrypt", return_value=None): + assert await helper.process_path_packet(_PathPacket(payload=b"\x11\x22\xAA\xBB")) is False + + +@pytest.mark.asyncio +async def test_protocol_request_process_routes_and_marks_no_retransmit(): + injector = AsyncMock(return_value=True) + helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=injector) + + assert await helper.process_request_packet(_ReqPacket(payload=b"\x01")) is False + + pkt_unknown = _ReqPacket(payload=b"\x99\x01") + assert await helper.process_request_packet(pkt_unknown) is False + + dest = 0x42 + response_packet = object() + + async def _core_handler(_packet): + return response_packet + + helper.handlers[dest] = {"handler": _core_handler} + pkt = _ReqPacket(payload=bytes([dest, 0x01, 0x02])) + + with patch("repeater.handler_helpers.protocol_request.asyncio.sleep", new_callable=AsyncMock): + handled = await helper.process_request_packet(pkt) + + assert handled is True + pkt.mark_do_not_retransmit.assert_called_once() + injector.assert_awaited_once_with(response_packet, wait_for_ack=False) + + +@pytest.mark.asyncio +async def test_protocol_request_process_exception_returns_false(): + helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=AsyncMock()) + + async def _boom(_packet): + raise RuntimeError("oops") + + helper.handlers[0x33] = {"handler": _boom} + pkt = _ReqPacket(payload=b"\x33\x01") + + assert await helper.process_request_packet(pkt) is False + + +def test_protocol_request_handle_get_status_builds_56_byte_payload(): + engine = SimpleNamespace( + start_time=time.time() - 120, + rx_count=7, + forwarded_count=5, + sent_flood_count=2, + sent_direct_count=3, + recv_flood_count=4, + recv_direct_count=1, + direct_dup_count=6, + flood_dup_count=8, + airtime_mgr=SimpleNamespace(total_airtime_ms=9300, total_rx_airtime_ms=4200), + ) + radio = SimpleNamespace( + get_noise_floor=lambda: -110, + get_last_rssi=lambda: -70, + get_last_snr=lambda: 2.5, + crc_error_count=11, + ) + helper = ProtocolRequestHelper( + identity_manager=MagicMock(), + packet_injector=AsyncMock(), + radio=radio, + engine=engine, + ) + + data = helper._handle_get_status(client=None, timestamp=0, req_data=b"") + + assert isinstance(data, (bytes, bytearray)) + assert len(data) == 56 + + +def test_protocol_request_access_list_admin_and_reserved_rules(): + admin = SimpleNamespace(is_admin=lambda: True) + not_admin = SimpleNamespace(is_admin=lambda: False) + c1 = _FakeClient(pubkey=b"A" * 32, shared_secret=b"k" * 32, permissions=0x02) + c2 = _FakeClient(pubkey=b"B" * 32, shared_secret=b"k" * 32, permissions=0x00) + acl = _FakeACL([c1, c2]) + helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=AsyncMock()) + + assert helper._handle_get_access_list(not_admin, 0, b"\x00\x00", acl) is None + assert helper._handle_get_access_list(admin, 0, b"\x01\x00", acl) is None + + out = helper._handle_get_access_list(admin, 0, b"\x00\x00", acl) + assert isinstance(out, bytes) + # One active entry only: 6-byte key prefix + 1-byte perms + assert len(out) == 7 + assert out[-1] == 0x02 + + +def test_protocol_request_get_neighbours_sort_and_pagination(): + neighbors = { + "AA" * 16: {"is_repeater": True, "zero_hop": True, "last_seen": time.time() - 1, "snr": 5.0}, + "BB" * 16: {"is_repeater": True, "zero_hop": True, "last_seen": time.time() - 10, "snr": 1.0}, + "CC" * 16: {"is_repeater": False, "zero_hop": True, "last_seen": time.time() - 1, "snr": 9.0}, + } + storage = SimpleNamespace(get_neighbors=lambda: neighbors) + helper = ProtocolRequestHelper( + identity_manager=MagicMock(), + packet_injector=AsyncMock(), + neighbor_tracker=SimpleNamespace(storage=storage), + ) + + # version=0, count=2, offset=0, order_by=2(strongest), pubkey_prefix_len=4, random=0 + req = bytes([0, 2]) + struct.pack(" timeout handler called. + db.get_client_sync.return_value = {"push_failures": 1, "updated_at": time.time() - 9999} + with ( + patch("repeater.handler_helpers.room_server.PacketBuilder._pack_timestamp_data", return_value=b"pk"), + patch("repeater.handler_helpers.room_server.CryptoUtils.sha256", return_value=b"\x01\x02\x03\x04abcd"), + patch("repeater.handler_helpers.room_server.PacketBuilder.create_datagram", return_value=SimpleNamespace(path=bytearray(), path_len=0)), + ): + fail_ok = await rs.push_post_to_client(client, post) + + assert fail_ok is False + rs._handle_ack_timeout.assert_awaited_once_with(client.id.get_public_key()) + + +@pytest.mark.asyncio +async def test_room_server_ack_helpers_and_unsynced_count_fallbacks(): + db = _FakeDB() + rs = _make_room_server(db=db) + + await rs._handle_ack_received(b"I" * 32, post_timestamp=123.0) + db.upsert_client_sync.assert_called() + + db.get_client_sync.return_value = {"push_failures": 2} + await rs._handle_ack_timeout(b"I" * 32) + # last call should have incremented failures and cleared pending ack + timeout_kwargs = db.upsert_client_sync.call_args.kwargs + assert timeout_kwargs["push_failures"] == 3 + assert timeout_kwargs["pending_ack_crc"] == 0 + + db.get_client_sync.side_effect = RuntimeError("db down") + assert rs.get_unsynced_count(b"I" * 32) == 0 + + +@pytest.mark.asyncio +async def test_room_server_evict_failed_clients_and_check_ack_timeouts(): + db = _FakeDB() + acl = _FakeACL([_FakeClient(b"J" * 32)]) + rs = _make_room_server(db=db, acl=acl) + + now = time.time() + db.get_all_room_clients.return_value = [ + { + "client_pubkey": (b"J" * 32).hex(), + "push_failures": 3, + "last_activity": now, + "pending_ack_crc": 0, + "ack_timeout_time": 0, + }, + { + "client_pubkey": (b"K" * 32).hex(), + "push_failures": 0, + "last_activity": now - 5000, + "pending_ack_crc": 0, + "ack_timeout_time": 0, + }, + ] + + await rs._evict_failed_clients() + assert db.upsert_client_sync.call_count >= 2 + assert acl.remove_client.call_count == 2 + + rs._handle_ack_timeout = AsyncMock() + db.get_all_room_clients.return_value = [ + { + "client_pubkey": (b"L" * 32).hex(), + "pending_ack_crc": 123, + "ack_timeout_time": now - 1, + }, + { + "client_pubkey": (b"M" * 32).hex(), + "pending_ack_crc": 0, + "ack_timeout_time": now - 1, + }, + ] + await rs._check_ack_timeouts() + rs._handle_ack_timeout.assert_awaited_once_with(b"L" * 32) + + +@pytest.mark.asyncio +async def test_room_server_start_and_stop_are_idempotent(): + rs = _make_room_server() + + await rs.start() + assert rs._running is True + first_task = rs._sync_task + + # Second start should not replace task. + await rs.start() + assert rs._sync_task is first_task + + await rs.stop() + assert rs._running is False + + # Stop again should be safe. + await rs.stop() + + # Ensure task is cleaned up. + if first_task: + assert first_task.cancelled() or first_task.done() diff --git a/tests/test_handler_helpers_trace_discovery_login.py b/tests/test_handler_helpers_trace_discovery_login.py new file mode 100644 index 0000000..e711adb --- /dev/null +++ b/tests/test_handler_helpers_trace_discovery_login.py @@ -0,0 +1,314 @@ +import asyncio +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from pymc_core.protocol.constants import PAYLOAD_TYPE_ANON_REQ, ROUTE_TYPE_DIRECT +from repeater.handler_helpers.discovery import DiscoveryHelper +from repeater.handler_helpers.login import LoginHelper +from repeater.handler_helpers.trace import TraceHelper + + +class DummyPacket: + def __init__(self, *, route=ROUTE_TYPE_DIRECT, path=b"", payload=b"\x01\x02", snr=2.5, rssi=-70): + self.header = route + self.path = bytearray(path) + self.path_len = len(self.path) + self.payload = bytearray(payload) + self.snr = snr + self.rssi = rssi + + def get_route_type(self): + return self.header + + def get_payload_type(self): + return 0x09 + + def get_snr(self): + return self.snr + + def calculate_packet_hash(self): + return bytes.fromhex("A1B2C3D4E5F6A7B8") + + def write_to(self): + return b"\x01\x02\x03" + + +class FakeIdentity: + def __init__(self, first_byte=0x42): + self._pk = bytes([first_byte]) + bytes(range(1, 33)) + + def get_public_key(self): + return self._pk + + +@pytest.mark.asyncio +async def test_trace_helper_should_forward_matching_next_hop_only(): + repeater_handler = MagicMock() + repeater_handler.is_duplicate.return_value = False + helper = TraceHelper( + local_hash=0x42, + local_identity=FakeIdentity(0x42), + repeater_handler=repeater_handler, + ) + packet = DummyPacket(path=b"\x00") + + assert helper._should_forward_trace(packet, b"", flags=0, hash_width=1) is False + assert helper._should_forward_trace(packet, b"\x01", flags=0, hash_width=0) is False + + # offset = len(path)=1 for hash_width=1, so this trace is complete and not forwarded + assert helper._should_forward_trace(packet, b"\x42", flags=0, hash_width=1) is False + + # next hop mismatch + packet.path = bytearray() + assert helper._should_forward_trace(packet, b"\x99", flags=0, hash_width=1) is False + + # match + non-duplicate forwards + assert helper._should_forward_trace(packet, b"\x42", flags=0, hash_width=1) is True + + repeater_handler.is_duplicate.return_value = True + assert helper._should_forward_trace(packet, b"\x42", flags=0, hash_width=1) is False + + +@pytest.mark.asyncio +async def test_trace_helper_process_sets_pending_ping_and_forwards(): + repeater_handler = MagicMock() + repeater_handler.is_duplicate.return_value = False + repeater_handler.calculate_packet_score.return_value = 0.9 + helper = TraceHelper( + local_hash=0x42, + local_identity=FakeIdentity(0x42), + repeater_handler=repeater_handler, + ) + + tag = 77 + evt = helper.register_ping(tag, 0x42) + + packet = DummyPacket(path=b"\x01", payload=b"\xAA\xBB\xCC") + helper._forward_trace_packet = AsyncMock() + helper._extract_path_info = MagicMock(return_value=([], [])) + helper._should_forward_trace = MagicMock(return_value=True) + helper.trace_handler._parse_trace_payload = MagicMock( + return_value={ + "valid": True, + "trace_path_bytes": b"\x42", + "flags": 0, + "trace_hops": [b"\x42"], + "trace_path": [0x42], + "tag": tag, + } + ) + helper.trace_handler._format_trace_response = MagicMock(return_value="trace ok") + + await helper.process_trace_packet(packet) + + assert evt.is_set() + assert helper.pending_pings[tag]["result"]["rssi"] == -70 + repeater_handler.log_trace_record.assert_called_once() + helper._forward_trace_packet.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_trace_helper_ignores_zero_rssi_pending_ping_response(): + helper = TraceHelper(local_hash=0x42, local_identity=FakeIdentity(0x42), repeater_handler=MagicMock()) + tag = 9 + evt = helper.register_ping(tag, 0x42) + + packet = DummyPacket(path=b"\x01", rssi=0) + helper.trace_handler._parse_trace_payload = MagicMock( + return_value={ + "valid": True, + "trace_path_bytes": b"\x42", + "flags": 0, + "trace_hops": [b"\x42"], + "trace_path": [0x42], + "tag": tag, + } + ) + + await helper.process_trace_packet(packet) + + assert not evt.is_set() + assert helper.pending_pings[tag]["result"] is None + + +@pytest.mark.asyncio +async def test_trace_helper_forward_trace_packet_updates_recent_record_and_injects(): + packet_injector = AsyncMock(return_value=True) + repeater_handler = MagicMock() + pkt = DummyPacket(path=b"", snr=3.5) + pkt_hash = pkt.calculate_packet_hash().hex().upper()[:16] + repeater_handler.recent_packets = [{"packet_hash": pkt_hash, "transmitted": False}] + + helper = TraceHelper( + local_hash=0x42, + local_identity=FakeIdentity(0x42), + repeater_handler=repeater_handler, + packet_injector=packet_injector, + ) + + await helper._forward_trace_packet(pkt, num_hops=1) + + assert repeater_handler.recent_packets[0]["transmitted"] is True + assert repeater_handler.recent_packets[0]["drop_reason"] == "trace_forwarded" + assert pkt.path_len == 1 + packet_injector.assert_awaited_once() + + +def test_trace_helper_cleanup_stale_pings(): + helper = TraceHelper(local_hash=0x42, local_identity=FakeIdentity(0x42), repeater_handler=MagicMock()) + helper.pending_pings = { + 1: {"sent_at": time.time() - 100, "event": asyncio.Event(), "result": None, "target": 1}, + 2: {"sent_at": time.time(), "event": asyncio.Event(), "result": None, "target": 2}, + } + + helper.cleanup_stale_pings(max_age_seconds=10) + + assert 1 not in helper.pending_pings + assert 2 in helper.pending_pings + + +def test_discovery_request_filter_match_and_mismatch(): + helper = DiscoveryHelper(local_identity=FakeIdentity(0x42), packet_injector=AsyncMock(), node_type=2) + helper._send_discovery_response = MagicMock() + + helper._on_discovery_request({"tag": 1, "filter": 0x00, "prefix_only": False, "snr": 1.2, "rssi": -80}) + helper._send_discovery_response.assert_not_called() + + helper._on_discovery_request({"tag": 2, "filter": 0x04, "prefix_only": True, "snr": 2.3, "rssi": -70}) + helper._send_discovery_response.assert_called_once_with(2, 2, 2.3, True) + + +def test_discovery_request_without_identity_does_not_send(): + helper = DiscoveryHelper(local_identity=None, packet_injector=AsyncMock(), node_type=2) + helper._send_discovery_response = MagicMock() + + helper._on_discovery_request({"tag": 7, "filter": 0x04, "prefix_only": False, "snr": 0.0, "rssi": -90}) + + helper._send_discovery_response.assert_not_called() + + +@pytest.mark.asyncio +async def test_discovery_send_packet_async_success_failure_and_exception(): + injector = AsyncMock(side_effect=[True, False, RuntimeError("send fail")]) + helper = DiscoveryHelper(local_identity=FakeIdentity(0x42), packet_injector=injector) + + await helper._send_packet_async(packet=object(), tag=0x11) + await helper._send_packet_async(packet=object(), tag=0x12) + await helper._send_packet_async(packet=object(), tag=0x13) + + assert injector.await_count == 3 + + +def test_discovery_send_response_without_injector_is_safe(): + helper = DiscoveryHelper(local_identity=FakeIdentity(0x42), packet_injector=None) + + with patch("pymc_core.protocol.packet_builder.PacketBuilder.create_discovery_response", return_value=object()): + helper._send_discovery_response(tag=5, node_type=2, inbound_snr=1.0, prefix_only=False) + + +def test_login_register_identity_room_server_requires_passwords(): + helper = LoginHelper(identity_manager=MagicMock(), packet_injector=AsyncMock()) + identity = FakeIdentity(0x51) + + with ( + patch("repeater.handler_helpers.acl.ACL") as acl_cls, + patch("repeater.handler_helpers.login.LoginServerHandler") as handler_cls, + ): + helper.register_identity( + name="room-a", + identity=identity, + identity_type="room_server", + config={"settings": {}}, + ) + + acl_cls.assert_not_called() + handler_cls.assert_not_called() + assert 0x51 not in helper.handlers + + +def test_login_register_identity_repeater_creates_acl_and_handler(): + helper = LoginHelper(identity_manager=MagicMock(), packet_injector=AsyncMock()) + identity = FakeIdentity(0x52) + acl_obj = MagicMock() + handler_obj = MagicMock() + + with ( + patch("repeater.handler_helpers.acl.ACL", return_value=acl_obj) as acl_cls, + patch("repeater.handler_helpers.login.LoginServerHandler", return_value=handler_obj) as handler_cls, + ): + helper.register_identity( + name="repeater-main", + identity=identity, + identity_type="repeater", + config={"repeater": {"security": {"max_clients": 3, "admin_password": "a", "guest_password": "g"}}}, + ) + + acl_cls.assert_called_once() + handler_cls.assert_called_once() + handler_obj.set_send_packet_callback.assert_called_once() + assert helper.handlers[0x52] is handler_obj + assert helper.acls[0x52] is acl_obj + + +@pytest.mark.asyncio +async def test_login_process_packet_routes_to_registered_handler_and_marks_no_retransmit(): + helper = LoginHelper(identity_manager=MagicMock(), packet_injector=AsyncMock()) + login_handler = AsyncMock() + helper.handlers[0x62] = login_handler + + packet = SimpleNamespace( + payload=bytearray([0x62, 0xAA]), + get_payload_type=lambda: 0x01, + mark_do_not_retransmit=MagicMock(), + ) + + handled = await helper.process_login_packet(packet) + + assert handled is True + login_handler.assert_awaited_once_with(packet) + packet.mark_do_not_retransmit.assert_called_once() + + +@pytest.mark.asyncio +async def test_login_process_packet_unknown_and_short_payload_are_not_handled(): + helper = LoginHelper(identity_manager=MagicMock(), packet_injector=AsyncMock()) + + short_packet = SimpleNamespace(payload=bytearray()) + assert await helper.process_login_packet(short_packet) is False + + unknown_packet = SimpleNamespace( + payload=bytearray([0x63]), + get_payload_type=lambda: PAYLOAD_TYPE_ANON_REQ, + ) + assert await helper.process_login_packet(unknown_packet) is False + + +@pytest.mark.asyncio +async def test_login_delayed_send_success_and_error_paths(): + injector = AsyncMock(side_effect=[True, RuntimeError("send failed")]) + helper = LoginHelper(identity_manager=MagicMock(), packet_injector=injector) + + with patch("repeater.handler_helpers.login.asyncio.sleep", new_callable=AsyncMock): + await helper._delayed_send(packet=object(), delay_ms=10) + await helper._delayed_send(packet=object(), delay_ms=10) + + assert injector.await_count == 2 + + +def test_login_acl_access_and_client_listing(): + helper = LoginHelper(identity_manager=MagicMock(), packet_injector=AsyncMock()) + acl_a = MagicMock() + acl_b = MagicMock() + acl_a.get_all_clients.return_value = [{"id": "a1"}] + acl_b.get_all_clients.return_value = [{"id": "b1"}, {"id": "b2"}] + helper.acls = {0x70: acl_a, 0x71: acl_b} + + assert helper.get_acl_for_identity(0x70) is acl_a + assert helper.get_acl_for_identity(0x99) is None + assert helper.list_authenticated_clients(0x71) == [{"id": "b1"}, {"id": "b2"}] + + all_clients = helper.list_authenticated_clients() + assert {c["id"] for c in all_clients} == {"a1", "b1", "b2"} diff --git a/tests/test_identity_manager_and_repeater_cli.py b/tests/test_identity_manager_and_repeater_cli.py new file mode 100644 index 0000000..ce149d3 --- /dev/null +++ b/tests/test_identity_manager_and_repeater_cli.py @@ -0,0 +1,269 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from repeater.handler_helpers.repeater_cli import MeshCLI, RepeaterCLI +from repeater.identity_manager import IdentityManager + + +class _FakeIdentity: + def __init__(self, pubkey: bytes, addr: bytes = b"\xAA\xBB"): + self._pubkey = pubkey + self._addr = addr + + def get_public_key(self): + return self._pubkey + + def get_address_bytes(self): + return self._addr + + +def _base_config(): + return { + "version": "9.9.9", + "repeater": { + "name": "node-1", + "mode": "forward", + "latitude": 12.3, + "longitude": 45.6, + "airtime_factor": 1.1, + "advert_interval_minutes": 120, + "flood_advert_interval_hours": 24, + "max_flood_hops": 32, + "rx_delay_base": 0.4, + "tx_delay_factor": 1.2, + "direct_tx_delay_factor": 0.7, + "multi_acks": 2, + "interference_threshold": -111, + "agc_reset_interval": 8, + }, + "radio": { + "frequency": 915000000, + "bandwidth": 125000, + "spreading_factor": 7, + "coding_rate": 5, + "tx_power": 22, + }, + "security": {"guest_password": "guest", "allow_read_only": True}, + } + + +def test_identity_manager_register_lookup_and_collision_paths(): + mgr = IdentityManager(config={}) + id_a = _FakeIdentity(bytes([0x11]) + b"A" * 31, addr=b"\x01\x02") + id_b_collision = _FakeIdentity(bytes([0x11]) + b"B" * 31, addr=b"\x03\x04") + + assert mgr.register_identity("alpha", id_a, {"k": 1}, "repeater") is True + assert mgr.has_identity(0x11) is True + assert mgr.get_identity_by_hash(0x11)[0] is id_a + assert mgr.get_identity_by_name("alpha")[0] is id_a + + # Collision on first pubkey byte should be rejected. + assert mgr.register_identity("beta", id_b_collision, {"k": 2}, "room_server") is False + + +def test_identity_manager_list_and_type_filtering(): + mgr = IdentityManager(config={}) + id_a = _FakeIdentity(bytes([0x22]) + b"A" * 31) + id_b = _FakeIdentity(bytes([0x33]) + b"B" * 31) + + mgr.register_identity("rep-main", id_a, {"x": 1}, "repeater") + mgr.register_identity("room-a", id_b, {"y": 2}, "room_server") + + listed = mgr.list_identities() + assert len(listed) == 2 + assert any(item["hash"] == "0x22" and item["name"] == "repeater:rep-main" for item in listed) + assert any(item["hash"] == "0x33" and item["type"] == "room_server" for item in listed) + + assert mgr.has_identity_type("repeater") is True + assert mgr.has_identity_type("room_server") is True + assert mgr.has_identity_type("unknown") is False + + by_type = mgr.get_identities_by_type("room_server") + assert len(by_type) == 1 + assert by_type[0][0] == "room-a" + + +def test_identity_manager_list_handles_none_identity_fields(): + mgr = IdentityManager(config={}) + mgr.identities[0x44] = (None, {}, "repeater") + mgr.registered_hashes[0x44] = "repeater:ghost" + + listed = mgr.list_identities() + assert listed[0]["address"] == "N/A" + assert listed[0]["public_key"] is None + + +def test_repeater_cli_alias_points_to_mesh_cli(): + assert RepeaterCLI is MeshCLI + + +def test_cli_non_admin_and_prefix_passthrough(): + cfg = _base_config() + save = MagicMock() + cli = MeshCLI("/tmp/config.yaml", cfg, save) + + assert cli.handle_command(b"x", "help", is_admin=False) == "Error: Admin permission required" + assert cli.handle_command(b"x", "01|help set", is_admin=True).startswith("01|") + + +def test_cli_help_and_route_unknown_commands(): + cli = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock()) + + help_text = cli._route_command("help") + assert "pyMC CLI Commands" in help_text + + assert "No detailed help" in cli._route_command("help not-a-topic") + assert cli._route_command("start ota").startswith("Error:") + assert cli._route_command("gps now").startswith("Error:") + assert cli._route_command("stats-air").startswith("Error:") + assert cli._route_command("totally-unknown") == "Unknown command" + + +def test_cli_reboot_uses_service_utils_result(): + cli = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock()) + + with patch("repeater.service_utils.restart_service", return_value=(True, "restarted")): + assert cli._cmd_reboot() == "OK - restarted" + + with patch("repeater.service_utils.restart_service", return_value=(False, "denied")): + assert cli._cmd_reboot() == "Error: denied" + + +def test_cli_clock_time_password_and_version_commands(): + cfg = _base_config() + save = MagicMock() + cli = MeshCLI("/tmp/config.yaml", cfg, save, identity_type="room_server") + + assert "UTC" in cli._cmd_clock("clock") + assert "not needed" in cli._cmd_clock("clock sync") + assert cli._cmd_clock("clock bad") == "Unknown clock command" + assert cli._cmd_time("time 1 2").startswith("Error:") + + assert cli._cmd_password("password ") == "Error: Password cannot be empty" + assert cli._cmd_password("password newpass") == "password now: newpass" + assert cfg["security"]["password"] == "newpass" + save.assert_called() + + assert cli._cmd_version() == "pyMC_room_server v9.9.9" + + +def test_cli_get_commands_cover_expected_fields(): + cli = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock()) + + assert cli._cmd_get("af") == "> 1.1" + assert cli._cmd_get("name") == "> node-1" + assert cli._cmd_get("repeat") == "> on" + assert cli._cmd_get("lat") == "> 12.3" + assert cli._cmd_get("lon") == "> 45.6" + assert cli._cmd_get("radio") == "> 915.0,125.0,7,5" + assert cli._cmd_get("freq") == "> 915.0" + assert cli._cmd_get("tx") == "> 22" + assert cli._cmd_get("role") == "> repeater" + assert cli._cmd_get("guest.password") == "> guest" + assert cli._cmd_get("allow.read.only") == "> on" + assert cli._cmd_get("advert.interval") == "> 120" + assert cli._cmd_get("flood.advert.interval") == "> 24" + assert cli._cmd_get("flood.max") == "> 32" + assert cli._cmd_get("rxdelay") == "> 0.4" + assert cli._cmd_get("txdelay") == "> 1.2" + assert cli._cmd_get("direct.txdelay") == "> 0.7" + assert cli._cmd_get("multi.acks") == "> 2" + assert cli._cmd_get("int.thresh") == "> -111" + assert cli._cmd_get("agc.reset.interval") == "> 8" + assert cli._cmd_get("public.key").startswith("Error:") + assert cli._cmd_get("missing") == "??: missing" + + +def test_cli_set_commands_apply_and_validate_ranges(): + cfg = _base_config() + save = MagicMock() + cli = MeshCLI("/tmp/config.yaml", cfg, save) + + assert cli._cmd_set("af 2.5") == "OK" + assert cfg["repeater"]["airtime_factor"] == 2.5 + + assert cli._cmd_set("name repeater-z") == "OK" + assert cfg["repeater"]["name"] == "repeater-z" + + assert cli._cmd_set("repeat off").endswith("OFF") + assert cfg["repeater"]["mode"] == "monitor" + + assert cli._cmd_set("lat 1.25") == "OK" + assert cli._cmd_set("lon 2.5") == "OK" + + assert cli._cmd_set("radio 900000000 250000 9 6").startswith("OK") + assert cfg["radio"]["frequency"] == 900000000.0 + + assert cli._cmd_set("freq 868000000") .startswith("OK") + assert cli._cmd_set("tx 17") == "OK" + assert cli._cmd_set("guest.password gpw") == "OK" + assert cli._cmd_set("allow.read.only off") == "OK" + + assert cli._cmd_set("advert.interval 59").startswith("Error: interval range") + assert cli._cmd_set("advert.interval 60") == "OK" + + assert cli._cmd_set("flood.advert.interval 2").startswith("Error: interval range") + assert cli._cmd_set("flood.advert.interval 48") == "OK" + + assert cli._cmd_set("flood.max 65") == "Error: max 64" + assert cli._cmd_set("flood.max 64") == "OK" + + assert cli._cmd_set("rxdelay -1") == "Error: cannot be negative" + assert cli._cmd_set("txdelay -1") == "Error: cannot be negative" + assert cli._cmd_set("direct.txdelay -1") == "Error: cannot be negative" + + assert cli._cmd_set("multi.acks 5") == "OK" + assert cli._cmd_set("int.thresh -120") == "OK" + assert cli._cmd_set("agc.reset.interval 10") == "OK - interval rounded to 8" + + +def test_cli_set_command_error_paths(): + cfg = _base_config() + save = MagicMock() + cli = MeshCLI("/tmp/config.yaml", cfg, save) + + assert cli._cmd_set("af") == "Error: Missing value" + assert cli._cmd_set("radio 1 2 3") == "Error: Expected freq bw sf cr" + assert cli._cmd_set("unknown.key 1") == "unknown config: unknown.key" + assert cli._cmd_set("tx not-int").startswith("Error: invalid value") + + cli.save_config = MagicMock(side_effect=RuntimeError("disk full")) + assert cli._cmd_set("name x").startswith("Error:") + + +def test_cli_setperm_region_neighbor_tempradio_log_paths(): + cli = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock(), enable_regions=False) + + assert cli._cmd_setperm("setperm") == "Err - bad params" + assert cli._cmd_setperm("setperm deadbeef zz") == "Err - invalid permissions" + assert cli._cmd_setperm("setperm deadbeef 2").startswith("Error:") + + assert "not available" in cli._route_command("region load us") + + cli_regions = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock(), enable_regions=True) + assert cli_regions._cmd_region("region").startswith("Error:") + assert cli_regions._cmd_region("region load x").startswith("Error:") + assert cli_regions._cmd_region("region save").startswith("Error:") + assert cli_regions._cmd_region("region allowf").startswith("Error:") + assert cli_regions._cmd_region("region what").startswith("Err -") + + assert cli._cmd_neighbors().startswith("Error:") + assert cli._cmd_neighbor_remove("neighbor.remove ") == "ERR: Missing pubkey" + assert cli._cmd_neighbor_remove("neighbor.remove 001122").startswith("Error:") + + assert cli._cmd_tempradio("tempradio 1 2 3") .startswith("Error:") + assert cli._cmd_tempradio("tempradio 299 125 7 5 10") == "Error: invalid frequency" + assert cli._cmd_tempradio("tempradio 915 6 7 5 10") == "Error: invalid bandwidth" + assert cli._cmd_tempradio("tempradio 915 125 4 5 10") == "Error: invalid spreading factor" + assert cli._cmd_tempradio("tempradio 915 125 7 9 10") == "Error: invalid coding rate" + assert cli._cmd_tempradio("tempradio 915 125 7 5 0") == "Error: invalid timeout" + assert cli._cmd_tempradio("tempradio 915 125 7 5 x") == "Error, invalid params" + assert cli._cmd_tempradio("tempradio 915 125 7 5 10").startswith("Error:") + + assert cli._cmd_log("log start").startswith("Error:") + assert cli._cmd_log("log stop").startswith("Error:") + assert cli._cmd_log("log erase").startswith("Error:") + assert cli._cmd_log("log") == "Error: Use journalctl to view logs" + assert cli._cmd_log("log weird") == "Unknown log command" diff --git a/tests/test_keygen_local_cli.py b/tests/test_keygen_local_cli.py new file mode 100644 index 0000000..7ecc1f6 --- /dev/null +++ b/tests/test_keygen_local_cli.py @@ -0,0 +1,196 @@ +import hashlib +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from repeater import keygen +from repeater import local_cli + + +class _Resp: + def __init__(self, payload): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps(self._payload).encode() + + +def test_generate_meshcore_keypair_clamps_scalar_and_shapes_output(): + seed = b"\xff" * 32 + captured = {} + + def _fake_scalarmult(scalar_bytes): + captured["scalar"] = scalar_bytes + return b"\xAA" * 32 + + with ( + patch("repeater.keygen.secrets.token_bytes", return_value=seed), + patch("repeater.keygen.crypto_scalarmult_ed25519_base_noclamp", side_effect=_fake_scalarmult), + ): + pub, priv = keygen.generate_meshcore_keypair() + + digest = hashlib.sha512(seed).digest() + expected = bytearray(digest[:32]) + expected[0] &= 248 + expected[31] &= 63 + expected[31] |= 64 + + assert pub == b"\xAA" * 32 + assert len(pub) == 32 + assert len(priv) == 64 + assert captured["scalar"] == bytes(expected) + assert priv[:32] == bytes(expected) + assert priv[32:] == digest[32:64] + + +def test_generate_vanity_key_success_after_multiple_attempts_and_none_on_limit(): + pairs = [ + (bytes.fromhex("11" * 32), b"p" * 64), + (bytes.fromhex("22" * 32), b"q" * 64), + (bytes.fromhex("ab" + "33" * 31), b"r" * 64), + ] + + with patch("repeater.keygen.generate_meshcore_keypair", side_effect=pairs): + out = keygen.generate_vanity_key(prefix="AB", max_iterations=10) + + assert out is not None + assert out["attempts"] == 3 + assert out["public_hex"].startswith("ab") + assert out["private_hex"] == (b"r" * 64).hex() + + with patch( + "repeater.keygen.generate_meshcore_keypair", + return_value=(bytes.fromhex("00" * 32), b"z" * 64), + ): + miss = keygen.generate_vanity_key(prefix="FF", max_iterations=2) + assert miss is None + + +def test_load_config_reads_yaml_from_explicit_path(tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text("http:\n port: 8123\n") + + cfg = local_cli._load_config(str(cfg_path)) + assert cfg["http"]["port"] == 8123 + + +def test_load_config_returns_empty_when_not_found(tmp_path): + cfg = local_cli._load_config(str(tmp_path / "missing.yaml")) + assert cfg == {} + + +def test_run_client_cli_exits_when_auth_missing_or_connection_fails(capsys): + # Empty password path -> auth fail -> sys.exit(1) + with pytest.raises(SystemExit): + local_cli.run_client_cli(password="") + + out1 = capsys.readouterr().out + assert "Authentication failed" in out1 + + # URLError during auth should exit with connection message. + import urllib.error + + with ( + patch("urllib.request.urlopen", side_effect=urllib.error.URLError("down")), + pytest.raises(SystemExit), + ): + local_cli.run_client_cli(password="secret") + + out2 = capsys.readouterr().out + assert "Cannot connect to repeater" in out2 + + +def test_run_client_cli_happy_path_and_command_error_branch(capsys): + responses = [ + _Resp({"token": "jwt-token"}), + _Resp({"success": True, "data": {"reply": "pong"}}), + _Resp({"success": False, "error": "bad cmd"}), + ] + + with ( + patch("urllib.request.urlopen", side_effect=responses), + patch("builtins.input", side_effect=["ping", "oops", "exit"]), + ): + local_cli.run_client_cli(password="secret", port=9000) + + out = capsys.readouterr().out + assert "connected to http://127.0.0.1:9000" in out + assert "pong" in out + assert "Error: bad cmd" in out + + +def test_run_client_cli_handles_runtime_connection_error_during_command(capsys): + import urllib.error + + def _urlopen_side_effect(*_args, **_kwargs): + if not hasattr(_urlopen_side_effect, "count"): + _urlopen_side_effect.count = 0 + _urlopen_side_effect.count += 1 + if _urlopen_side_effect.count == 1: + return _Resp({"token": "jwt-token"}) + raise urllib.error.URLError("timeout") + + with ( + patch("urllib.request.urlopen", side_effect=_urlopen_side_effect), + patch("builtins.input", side_effect=["status", "quit"]), + ): + local_cli.run_client_cli(password="secret") + + out = capsys.readouterr().out + assert "Connection error: timeout" in out + + +def test_main_uses_config_defaults_and_cli_overrides(capsys): + class _Args: + config = "/tmp/cfg.yaml" + host = None + port = None + + config = { + "repeater": {"security": {"admin_password": "pw"}}, + "http": {"port": 8765}, + } + + with ( + patch("argparse.ArgumentParser.parse_args", return_value=_Args()), + patch("repeater.local_cli._load_config", return_value=config), + patch("repeater.local_cli.run_client_cli") as run_cli, + ): + local_cli.main() + + run_cli.assert_called_once_with(host="127.0.0.1", port=8765, password="pw") + + class _ArgsOverride: + config = "/tmp/cfg.yaml" + host = "10.0.0.9" + port = 9999 + + with ( + patch("argparse.ArgumentParser.parse_args", return_value=_ArgsOverride()), + patch("repeater.local_cli._load_config", return_value=config), + patch("repeater.local_cli.run_client_cli") as run_cli2, + ): + local_cli.main() + + run_cli2.assert_called_once_with(host="10.0.0.9", port=9999, password="pw") + + config_missing_pw = {"repeater": {"security": {}}, "http": {"port": 8765}} + with ( + patch("argparse.ArgumentParser.parse_args", return_value=_Args()), + patch("repeater.local_cli._load_config", return_value=config_missing_pw), + patch("sys.exit", side_effect=SystemExit(1)) as exit_mock, + pytest.raises(SystemExit), + ): + local_cli.main() + + exit_mock.assert_called_once_with(1) + out = capsys.readouterr().out + assert "No admin_password found" in out diff --git a/tests/test_main_py_coverage.py b/tests/test_main_py_coverage.py new file mode 100644 index 0000000..199caeb --- /dev/null +++ b/tests/test_main_py_coverage.py @@ -0,0 +1,333 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from repeater.companion.constants import STATS_TYPE_CORE, STATS_TYPE_PACKETS, STATS_TYPE_RADIO +from repeater.main import RepeaterDaemon, main as repeater_main + + +class _FakeIdentity: + def __init__(self, pubkey: bytes): + self._pubkey = pubkey + + def get_public_key(self): + return self._pubkey + + +def _base_config(): + return { + "repeater": { + "node_name": "node-test", + "mode": "forward", + "latitude": 1.0, + "longitude": 2.0, + }, + "logging": {"level": "INFO"}, + } + + +@pytest.mark.asyncio +async def test_router_callback_enqueues_and_handles_enqueue_error(): + daemon = RepeaterDaemon(_base_config(), radio=object()) + packet = object() + + daemon.router = SimpleNamespace(enqueue=AsyncMock()) + await daemon._router_callback(packet) + daemon.router.enqueue.assert_awaited_once_with(packet) + + daemon.router = SimpleNamespace(enqueue=AsyncMock(side_effect=RuntimeError("boom"))) + await daemon._router_callback(packet) + + +def test_register_text_handler_for_identity_branches(): + daemon = RepeaterDaemon(_base_config(), radio=object()) + identity = _FakeIdentity(b"A" * 32) + + daemon.text_helper = None + assert daemon.register_text_handler_for_identity("room", identity) is False + + helper = SimpleNamespace(register_identity=MagicMock()) + daemon.text_helper = helper + assert daemon.register_text_handler_for_identity("room", identity) is True + helper.register_identity.assert_called_once() + + helper_fail = SimpleNamespace(register_identity=MagicMock(side_effect=RuntimeError("x"))) + daemon.text_helper = helper_fail + assert daemon.register_text_handler_for_identity("room", identity) is False + + +def test_get_stats_includes_public_key_gps_sensors_and_radio_state(): + daemon = RepeaterDaemon(_base_config(), radio=object()) + daemon.repeater_handler = SimpleNamespace(get_stats=lambda: {"rx": 1}) + daemon.local_identity = _FakeIdentity(b"B" * 32) + daemon.gps_service = SimpleNamespace(get_summary=lambda: {"gps": "ok"}) + daemon.sensor_manager = SimpleNamespace(get_summary=lambda: {"loaded": 1}) + daemon.radio_status = "degraded" + daemon.radio_error = "missing device" + + stats = daemon.get_stats() + + assert stats["rx"] == 1 + assert stats["public_key"] == (b"B" * 32).hex() + assert stats["gps"]["gps"] == "ok" + assert stats["sensors"]["loaded"] == 1 + assert stats["radio_status"] == "degraded" + assert stats["radio_error"] == "missing device" + + +def test_detect_container_from_proc_env_and_fallback_path(): + with patch("builtins.open", MagicMock()) as open_mock: + open_mock.return_value.__enter__.return_value.read.return_value = b"container=docker" + assert RepeaterDaemon._detect_container() is True + + with ( + patch("builtins.open", side_effect=OSError("no proc")), + patch("os.path.exists", return_value=True), + ): + assert RepeaterDaemon._detect_container() is True + + with ( + patch("builtins.open", side_effect=OSError("no proc")), + patch("os.path.exists", return_value=False), + ): + assert RepeaterDaemon._detect_container() is False + + +@pytest.mark.asyncio +async def test_get_companion_stats_core_radio_packets_and_unknown(): + daemon = RepeaterDaemon(_base_config(), radio=object()) + engine = SimpleNamespace( + airtime_mgr=SimpleNamespace(get_stats=lambda: {"total_airtime_ms": 5000}), + start_time=0, + get_cached_noise_floor=lambda: -110, + rx_count=7, + forwarded_count=4, + dropped_count=2, + ) + daemon.repeater_handler = engine + daemon.companion_bridges = { + 1: SimpleNamespace(message_queue=SimpleNamespace(count=3)), + 2: SimpleNamespace(message_queue=SimpleNamespace(count=2)), + } + daemon.dispatcher = SimpleNamespace( + radio=SimpleNamespace(get_last_rssi=lambda: -70, get_last_snr=lambda: 4.5) + ) + + with patch("time.time", return_value=100): + core = await daemon._get_companion_stats(STATS_TYPE_CORE) + assert core["queue_len"] == 5 + assert core["uptime_secs"] == 100 + + radio = await daemon._get_companion_stats(STATS_TYPE_RADIO) + assert radio["noise_floor"] == -110 + assert radio["last_rssi"] == -70 + assert radio["tx_air_secs"] == 5 + + packets = await daemon._get_companion_stats(STATS_TYPE_PACKETS) + assert packets["recv"] == 7 + assert packets["sent"] == 4 + assert packets["recv_errors"] == 2 + + assert await daemon._get_companion_stats(999) == {} + + +@pytest.mark.asyncio +async def test_raw_rx_and_duplicate_logging_hooks(): + daemon = RepeaterDaemon(_base_config(), radio=object()) + + fs_ok = SimpleNamespace(push_rx_raw=MagicMock()) + fs_fail = SimpleNamespace(push_rx_raw=MagicMock(side_effect=RuntimeError("x"))) + daemon.companion_frame_servers = [fs_ok, fs_fail] + + await daemon._on_raw_rx_for_companions(b"abc", rssi=-90, snr=2.0) + fs_ok.push_rx_raw.assert_called_once() + + engine = SimpleNamespace( + is_duplicate=MagicMock(side_effect=[False, True]), + record_duplicate=MagicMock(), + ) + daemon.repeater_handler = engine + + pkt = SimpleNamespace(_rssi=-77, _snr=1.5) + daemon._on_raw_packet_for_dedup_logging(pkt, b"", {}) + daemon._on_raw_packet_for_dedup_logging(pkt, b"", {}) + engine.record_duplicate.assert_called_once_with(pkt, rssi=-77, snr=1.5) + + +@pytest.mark.asyncio +async def test_deliver_control_data_filters_non_discovery_and_pushes_valid(): + daemon = RepeaterDaemon(_base_config(), radio=object()) + fs_ok = SimpleNamespace(push_control_data=AsyncMock()) + fs_fail = SimpleNamespace(push_control_data=AsyncMock(side_effect=RuntimeError("err"))) + daemon.companion_frame_servers = [fs_ok, fs_fail] + + await daemon.deliver_control_data(1.0, -70, 0, b"", b"\x80\x00") + fs_ok.push_control_data.assert_not_awaited() + + payload = bytes([0x90, 0x00, 0x11, 0x22, 0x33, 0x44]) + await daemon.deliver_control_data(1.0, -70, 2, b"\xAA\xBB", payload) + fs_ok.push_control_data.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_trace_complete_for_companions_requires_valid_lengths(): + daemon = RepeaterDaemon(_base_config(), radio=object()) + fs = SimpleNamespace(push_trace_data_async=AsyncMock()) + daemon.companion_frame_servers = [fs] + + packet = SimpleNamespace(path=bytearray([1, 2, 3]), get_snr=lambda: 2.0) + + await daemon._on_trace_complete_for_companions(packet, {"trace_path_bytes": b""}) + fs.push_trace_data_async.assert_not_awaited() + + parsed = { + "trace_path_bytes": b"\xAA\xBB\xCC\xDD", + "flags": 0, + "tag": 1, + "auth_code": 2, + } + await daemon._on_trace_complete_for_companions(packet, parsed) + fs.push_trace_data_async.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_register_identity_everywhere_calls_helpers_and_respects_collision(): + daemon = RepeaterDaemon(_base_config(), radio=object()) + identity = _FakeIdentity(b"Q" * 32) + + daemon.identity_manager = SimpleNamespace(register_identity=MagicMock(return_value=False)) + daemon.login_helper = SimpleNamespace(register_identity=MagicMock()) + daemon.text_helper = SimpleNamespace(register_identity=MagicMock()) + daemon.protocol_request_helper = SimpleNamespace(register_identity=MagicMock()) + + assert daemon._register_identity_everywhere("x", identity, {}, "room_server") is False + daemon.login_helper.register_identity.assert_not_called() + + daemon.identity_manager.register_identity = MagicMock(return_value=True) + assert daemon._register_identity_everywhere("x", identity, {}, "room_server") is True + daemon.login_helper.register_identity.assert_called_once() + daemon.text_helper.register_identity.assert_called_once() + daemon.protocol_request_helper.register_identity.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_advert_branches_and_success_path(): + daemon = RepeaterDaemon(_base_config(), radio=object()) + + # Missing dispatcher/local identity + assert await daemon.send_advert() is False + + daemon.dispatcher = SimpleNamespace(send_packet=AsyncMock(), packet_filter=SimpleNamespace(track_packet=MagicMock())) + daemon.local_identity = _FakeIdentity(b"\x21" + b"x" * 31) + daemon.config["repeater"]["mode"] = "no_tx" + assert await daemon.send_advert() is False + + daemon.config["repeater"]["mode"] = "forward" + daemon.repeater_handler = SimpleNamespace(mark_seen=MagicMock()) + daemon.gps_service = SimpleNamespace( + get_repeater_location=lambda: {"latitude": 9.1, "longitude": 8.2, "source": "gps"} + ) + + packet = SimpleNamespace(calculate_packet_hash=lambda: b"\xAB" * 16) + with patch("pymc_core.protocol.PacketBuilder.create_advert", return_value=packet): + ok = await daemon.send_advert() + + assert ok is True + daemon.dispatcher.send_packet.assert_awaited_once_with(packet, wait_for_ack=False) + daemon.repeater_handler.mark_seen.assert_called_once_with(packet) + daemon.dispatcher.packet_filter.track_packet.assert_called_once() + + +def test_update_repeater_location_from_gps_branches(): + daemon = RepeaterDaemon(_base_config(), radio=object()) + + assert daemon._update_repeater_location_from_gps({"latitude": None, "longitude": 1.0}) is False + + # No change in location should return False. + unchanged = {"latitude": 1.0, "longitude": 2.0} + assert daemon._update_repeater_location_from_gps(unchanged) is False + + # Without config manager, updates in-memory config. + updated = {"latitude": 3.5, "longitude": 4.5} + assert daemon._update_repeater_location_from_gps(updated) is True + assert daemon.config["repeater"]["latitude"] == 3.5 + assert daemon.config["repeater"]["longitude"] == 4.5 + + daemon.config_manager = SimpleNamespace(update_and_save=MagicMock(return_value={"success": False, "error": "nope"})) + assert daemon._update_repeater_location_from_gps({"latitude": 5.5, "longitude": 6.5}) is False + + daemon.config_manager = SimpleNamespace(update_and_save=MagicMock(return_value={"success": True})) + assert daemon._update_repeater_location_from_gps({"latitude": 6.5, "longitude": 7.5}) is True + + +def test_signal_shutdown_idempotence_and_task_cancel(): + daemon = RepeaterDaemon(_base_config(), radio=object()) + loop = SimpleNamespace(create_task=MagicMock(side_effect=lambda coro: coro.close())) + sig = SimpleNamespace(name="SIGTERM") + + daemon._shutdown_started = True + daemon._signal_shutdown(sig, loop) + loop.create_task.assert_not_called() + + daemon._shutdown_started = False + daemon._main_task = SimpleNamespace(done=lambda: False, cancel=MagicMock()) + daemon._signal_shutdown(sig, loop) + loop.create_task.assert_called_once() + daemon._main_task.cancel.assert_called_once() + + +@pytest.mark.asyncio +async def test_shutdown_stops_components_and_handles_errors(): + daemon = RepeaterDaemon(_base_config(), radio=SimpleNamespace(cleanup=MagicMock())) + daemon.config["radio_type"] = "none" + + frame_server = SimpleNamespace(stop=AsyncMock()) + bridge = SimpleNamespace(stop=AsyncMock()) + daemon.companion_frame_servers = [frame_server] + daemon.companion_bridges = {1: bridge} + daemon.router = SimpleNamespace(stop=AsyncMock()) + daemon.http_server = SimpleNamespace(stop=MagicMock()) + daemon.glass_handler = SimpleNamespace(stop=AsyncMock()) + daemon.sensor_manager = SimpleNamespace(stop=MagicMock()) + daemon.gps_service = SimpleNamespace(stop=MagicMock()) + daemon.repeater_handler = SimpleNamespace(storage=SimpleNamespace(close=MagicMock())) + + await daemon._shutdown() + + frame_server.stop.assert_awaited_once() + bridge.stop.assert_awaited_once() + daemon.router.stop.assert_awaited_once() + daemon.radio.cleanup.assert_called_once() + + +def test_main_entrypoint_success_and_fatal_paths(monkeypatch): + class _Args: + config = "/tmp/test.yaml" + log_level = "DEBUG" + + cfg = _base_config() + fake_daemon = SimpleNamespace(run=MagicMock(return_value=object())) + + with ( + patch("argparse.ArgumentParser.parse_args", return_value=_Args()), + patch("repeater.main.load_config", return_value=cfg), + patch("repeater.main.RepeaterDaemon", return_value=fake_daemon), + patch("asyncio.run", MagicMock()), + ): + repeater_main() + + assert cfg["logging"]["level"] == "DEBUG" + + with ( + patch("argparse.ArgumentParser.parse_args", return_value=_Args()), + patch("repeater.main.load_config", return_value=_base_config()), + patch("repeater.main.RepeaterDaemon", return_value=fake_daemon), + patch("asyncio.run", side_effect=RuntimeError("fatal")), + patch("sys.exit", side_effect=SystemExit(1)) as exit_mock, + ): + with pytest.raises(SystemExit): + repeater_main() + + exit_mock.assert_called_once_with(1) diff --git a/tests/test_packet_router.py b/tests/test_packet_router.py index 2b46514..1e62ca5 100644 --- a/tests/test_packet_router.py +++ b/tests/test_packet_router.py @@ -19,9 +19,24 @@ import time import unittest from unittest.mock import AsyncMock, MagicMock, patch +from pymc_core.node.handlers.ack import AckHandler +from pymc_core.node.handlers.advert import AdvertHandler +from pymc_core.node.handlers.control import ControlHandler +from pymc_core.node.handlers.group_text import GroupTextHandler +from pymc_core.node.handlers.login_response import LoginResponseHandler +from pymc_core.node.handlers.login_server import LoginServerHandler +from pymc_core.node.handlers.path import PathHandler +from pymc_core.node.handlers.protocol_request import ProtocolRequestHandler +from pymc_core.node.handlers.protocol_response import ProtocolResponseHandler +from pymc_core.node.handlers.text import TextMessageHandler from pymc_core.node.handlers.trace import TraceHandler +from pymc_core.protocol.constants import ROUTE_TYPE_DIRECT -from repeater.packet_router import PacketRouter +from repeater.packet_router import ( + PacketRouter, + _companion_dedup_key, + _is_direct_final_hop, +) # --------------------------------------------------------------------------- @@ -54,9 +69,17 @@ def _make_packet(payload_type: int = 0xFF): pkt.timestamp = time.time() pkt._injected_for_tx = False pkt.path = bytearray() + pkt.calculate_packet_hash.return_value = b"\x01" * 32 + pkt.mark_do_not_retransmit = MagicMock() return pkt +def _make_bridge(): + bridge = MagicMock() + bridge.process_received_packet = AsyncMock() + return bridge + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -326,3 +349,208 @@ class TestInFlightCap(unittest.IsolatedAsyncioTestCase): if __name__ == "__main__": unittest.main() + + +class TestPacketRouterRoutingBranches(unittest.IsolatedAsyncioTestCase): + def test_companion_dedup_key_handles_hash_exceptions(self): + pkt = MagicMock() + pkt.calculate_packet_hash.side_effect = RuntimeError("bad packet") + self.assertIsNone(_companion_dedup_key(pkt)) + + def test_is_direct_final_hop_helper(self): + pkt = _make_packet() + pkt.header = ROUTE_TYPE_DIRECT + pkt.path = bytearray() + self.assertTrue(_is_direct_final_hop(pkt)) + pkt.path = bytearray(b"\x01") + self.assertFalse(_is_direct_final_hop(pkt)) + + async def test_should_deliver_path_to_companions_dedupes(self): + router = PacketRouter(_make_daemon()) + pkt = _make_packet(PathHandler.payload_type()) + self.assertTrue(router._should_deliver_path_to_companions(pkt)) + self.assertFalse(router._should_deliver_path_to_companions(pkt)) + key = _companion_dedup_key(pkt) + router._companion_delivered[key] = time.time() - 1.0 + # Expired entries are only pruned once the dict grows beyond 200 entries. + for i in range(205): + router._companion_delivered[f"K{i}"] = time.time() + 60.0 + self.assertTrue(router._should_deliver_path_to_companions(pkt)) + + async def test_enqueue_drops_oldest_when_queue_full(self): + router = PacketRouter(_make_daemon()) + router.queue = asyncio.Queue(maxsize=1) + p1 = _make_packet() + p2 = _make_packet() + await router.queue.put(p1) + await router.enqueue(p2) + got = await router.queue.get() + self.assertIs(got, p2) + + async def test_inject_packet_returns_false_on_engine_error(self): + daemon = _make_daemon() + daemon.repeater_handler = AsyncMock(side_effect=RuntimeError("boom")) + router = PacketRouter(daemon) + ok = await router.inject_packet(_make_packet()) + self.assertFalse(ok) + + async def test_on_route_done_handles_task_exception(self): + router = PacketRouter(_make_daemon()) + + async def _fails(): + raise RuntimeError("route fail") + + task = asyncio.create_task(_fails()) + with self.assertRaises(RuntimeError): + await task + router._in_flight = 1 + router._route_tasks.add(task) + router._on_route_done(task) + self.assertEqual(router._in_flight, 0) + self.assertEqual(len(router._route_tasks), 0) + + async def test_route_trace_inbound_uses_trace_helper_and_skips_engine(self): + daemon = _make_daemon() + daemon.trace_helper = MagicMock() + daemon.trace_helper.process_trace_packet = AsyncMock() + router = PacketRouter(daemon) + pkt = _make_packet(TraceHandler.payload_type()) + await router._route_packet(pkt) + daemon.trace_helper.process_trace_packet.assert_awaited_once() + daemon.repeater_handler.assert_not_awaited() + + async def test_route_control_calls_discovery_and_delivery_and_engine(self): + daemon = _make_daemon() + daemon.discovery_helper = MagicMock() + daemon.discovery_helper.control_handler = AsyncMock() + daemon.deliver_control_data = AsyncMock() + router = PacketRouter(daemon) + pkt = _make_packet(ControlHandler.payload_type()) + pkt.path_len = 0 + await router._route_packet(pkt) + daemon.discovery_helper.control_handler.assert_awaited_once() + pkt.mark_do_not_retransmit.assert_called_once() + daemon.deliver_control_data.assert_awaited_once() + daemon.repeater_handler.assert_awaited_once() + + async def test_route_advert_delivers_to_helpers_and_engine(self): + daemon = _make_daemon() + daemon.advert_helper = MagicMock() + daemon.advert_helper.process_advert_packet = AsyncMock() + bridge = _make_bridge() + daemon.companion_bridges = {0x42: bridge} + router = PacketRouter(daemon) + pkt = _make_packet(AdvertHandler.payload_type()) + await router._route_packet(pkt) + daemon.advert_helper.process_advert_packet.assert_awaited_once() + bridge.process_received_packet.assert_awaited_once() + daemon.repeater_handler.assert_awaited_once() + + async def test_route_login_server_to_companion_marks_processed(self): + daemon = _make_daemon() + bridge = _make_bridge() + daemon.companion_bridges = {0x7A: bridge} + daemon.repeater_handler = AsyncMock() + daemon.repeater_handler.storage = MagicMock() + daemon.repeater_handler.record_packet_only = MagicMock() + router = PacketRouter(daemon) + pkt = _make_packet(LoginServerHandler.payload_type()) + pkt.payload = bytes([0x7A, 0x99]) + await router._route_packet(pkt) + bridge.process_received_packet.assert_awaited_once() + daemon.repeater_handler.assert_not_awaited() + + async def test_route_text_to_helper_marks_processed(self): + daemon = _make_daemon() + daemon.text_helper = MagicMock() + daemon.text_helper.process_text_packet = AsyncMock(return_value=True) + daemon.repeater_handler.storage = MagicMock() + daemon.repeater_handler.record_packet_only = MagicMock() + router = PacketRouter(daemon) + pkt = _make_packet(TextMessageHandler.payload_type()) + pkt.payload = bytes([0xEE, 0x01]) + await router._route_packet(pkt) + daemon.text_helper.process_text_packet.assert_awaited_once() + daemon.repeater_handler.assert_not_awaited() + + async def test_route_ack_delivers_to_all_bridges_and_engine(self): + daemon = _make_daemon() + b1 = _make_bridge() + b2 = _make_bridge() + daemon.companion_bridges = {0x01: b1, 0x02: b2} + router = PacketRouter(daemon) + pkt = _make_packet(AckHandler.payload_type()) + await router._route_packet(pkt) + b1.process_received_packet.assert_awaited_once() + b2.process_received_packet.assert_awaited_once() + daemon.repeater_handler.assert_awaited_once() + + async def test_route_path_dedupes_companion_delivery(self): + daemon = _make_daemon() + bridge = _make_bridge() + daemon.companion_bridges = {0x01: bridge} + router = PacketRouter(daemon) + pkt = _make_packet(PathHandler.payload_type()) + pkt.payload = bytes([0x01, 0xAA]) + await router._route_packet(pkt) + await router._route_packet(pkt) + bridge.process_received_packet.assert_awaited_once() + self.assertEqual(daemon.repeater_handler.await_count, 2) + + async def test_route_login_response_final_hop_skips_engine(self): + daemon = _make_daemon() + b1 = _make_bridge() + daemon.companion_bridges = {0x01: b1} + daemon.local_hash = 0xFF + daemon.repeater_handler.storage = MagicMock() + daemon.repeater_handler.record_packet_only = MagicMock() + router = PacketRouter(daemon) + pkt = _make_packet(LoginResponseHandler.payload_type()) + pkt.header = ROUTE_TYPE_DIRECT + pkt.path = bytearray() + pkt.payload = bytes([0xFF, 0x22]) + await router._route_packet(pkt) + b1.process_received_packet.assert_awaited_once() + daemon.repeater_handler.assert_not_awaited() + + async def test_route_protocol_response_final_hop_skips_engine(self): + daemon = _make_daemon() + b1 = _make_bridge() + daemon.companion_bridges = {0x01: b1} + daemon.repeater_handler.storage = MagicMock() + daemon.repeater_handler.record_packet_only = MagicMock() + router = PacketRouter(daemon) + pkt = _make_packet(ProtocolResponseHandler.payload_type()) + pkt.header = ROUTE_TYPE_DIRECT + pkt.path = bytearray() + # PathHandler and ProtocolResponseHandler currently share payload type=8. + # Patch PathHandler type here so ProtocolResponse branch is reachable. + with patch("repeater.packet_router.PathHandler.payload_type", return_value=0x55): + await router._route_packet(pkt) + self.assertGreaterEqual(b1.process_received_packet.await_count, 1) + daemon.repeater_handler.assert_not_awaited() + + async def test_route_protocol_request_final_hop_skips_engine(self): + daemon = _make_daemon() + b1 = _make_bridge() + daemon.companion_bridges = {0x01: b1} + daemon.repeater_handler.storage = MagicMock() + daemon.repeater_handler.record_packet_only = MagicMock() + router = PacketRouter(daemon) + pkt = _make_packet(ProtocolRequestHandler.payload_type()) + pkt.header = ROUTE_TYPE_DIRECT + pkt.path = bytearray() + pkt.payload = bytes([0xAA, 0xBB]) + await router._route_packet(pkt) + b1.process_received_packet.assert_awaited_once() + daemon.repeater_handler.assert_not_awaited() + + async def test_route_group_text_delivers_and_forwards(self): + daemon = _make_daemon() + b1 = _make_bridge() + daemon.companion_bridges = {0x01: b1} + router = PacketRouter(daemon) + pkt = _make_packet(GroupTextHandler.payload_type()) + await router._route_packet(pkt) + b1.process_received_packet.assert_awaited_once() + daemon.repeater_handler.assert_awaited_once() diff --git a/tests/test_sensors.py b/tests/test_sensors.py index ea91f5c..fc04b23 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -9,11 +9,13 @@ import pytest from repeater.sensors import SensorBase, SensorManager, SensorRegistry from repeater.sensors import ens210 as ens210_module from repeater.sensors import ina219 as ina219_module +from repeater.sensors import lafvin_ups_3s as lafvin_ups_3s_module from repeater.sensors import shtc3 as shtc3_module from repeater.sensors import waveshare_ups_d as waveshare_ups_d_module from repeater.sensors import waveshare_ups_e as waveshare_ups_e_module from repeater.sensors.ens210 import ENS210Sensor from repeater.sensors.ina219 import INA219Sensor +from repeater.sensors.lafvin_ups_3s import LafvinUps3sSensor from repeater.sensors.shtc3 import SHTC3Sensor from repeater.sensors.waveshare_ups_d import WaveshareUpsDSensor from repeater.sensors.waveshare_ups_e import WaveshareUpsESensor @@ -308,3 +310,83 @@ def test_waveshare_ups_e_sensor_reads_pack_state(monkeypatch): assert reading["data"]["low_cell_warning"] is True assert reading["data"]["time_to_full_min"] == 90 assert "time_to_empty_min" not in reading["data"] + + +def test_lafvin_pack_voltage_to_percent_piecewise_bounds(): + assert lafvin_ups_3s_module._pack_voltage_to_percent(12.6) == 100 + assert lafvin_ups_3s_module._pack_voltage_to_percent(12.0) == 85 + assert lafvin_ups_3s_module._pack_voltage_to_percent(11.4) == 60 + assert lafvin_ups_3s_module._pack_voltage_to_percent(11.1) == 39 + assert lafvin_ups_3s_module._pack_voltage_to_percent(10.5) == 15 + assert lafvin_ups_3s_module._pack_voltage_to_percent(9.0) == 0 + assert lafvin_ups_3s_module._pack_voltage_to_percent(8.5) == 0 + + +def test_lafvin_sensor_handles_missing_dependency(monkeypatch): + monkeypatch.setattr( + SensorBase, + "ensure_python_modules", + lambda self, modules: False, + ) + + sensor = LafvinUps3sSensor("battery") + reading = sensor.read() + + assert sensor.available is False + assert reading["ok"] is False + assert "not available" in reading["error"] + + +def test_lafvin_sensor_reads_pack_state(monkeypatch): + class _Bus: + def __init__(self, bus_number): + self.bus_number = bus_number + + def write_i2c_block_data(self, addr, register, data): + return None + + def read_i2c_block_data(self, addr, register, length): + values = { + lafvin_ups_3s_module._REG_BUS: [0x1F, 0x40], + lafvin_ups_3s_module._REG_SHUNT: [0x00, 0x64], + lafvin_ups_3s_module._REG_CURRENT: [0xFC, 0x18], + lafvin_ups_3s_module._REG_POWER: [0x00, 0x64], + } + return values[register] + + def close(self): + return None + + _install_fake_smbus2(monkeypatch, _Bus) + monkeypatch.setattr(lafvin_ups_3s_module.time, "sleep", lambda *_args, **_kwargs: None) + + reading = LafvinUps3sSensor("battery").read() + + assert reading["ok"] is True + assert reading["data"]["bus_voltage_v"] == 4.0 + assert reading["data"]["battery_percent"] == 0 + assert reading["data"]["charge_state"] == "charging" + assert reading["data"]["current_ma"] == pytest.approx(-152.6, abs=0.2) + assert reading["data"]["power_mw"] == pytest.approx(305.2, abs=0.2) + + +def test_lafvin_sensor_read_wraps_bus_failures(monkeypatch): + class _BrokenBus: + def __init__(self, bus_number): + self.bus_number = bus_number + + def write_i2c_block_data(self, addr, register, data): + return None + + def read_i2c_block_data(self, addr, register, length): + raise RuntimeError("i2c broken") + + def close(self): + return None + + _install_fake_smbus2(monkeypatch, _BrokenBus) + monkeypatch.setattr(lafvin_ups_3s_module.time, "sleep", lambda *_args, **_kwargs: None) + + reading = LafvinUps3sSensor("battery").read() + assert reading["ok"] is False + assert "LAFVIN UPS 3S read failed" in reading["error"]