mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-12 03:43:04 +02:00
Refactor test cases and base code for consistency and readability
- Updated byte representations in tests to use lowercase hex format for consistency. - Reformatted code for better readability, including line breaks and indentation adjustments. - Consolidated multiple lines into single lines where appropriate to enhance clarity. - Ensured that all test cases maintain consistent formatting and style across the test suite.
This commit is contained in:
@@ -18,7 +18,7 @@ def _semtech_airtime_ms(payload_len: int, sf: int, bw_hz: int, cr: int, preamble
|
||||
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_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)
|
||||
|
||||
@@ -21,9 +21,7 @@ def _make_api(config=None):
|
||||
|
||||
|
||||
def _attach_storage(api, storage):
|
||||
api.daemon_instance = SimpleNamespace(
|
||||
repeater_handler=SimpleNamespace(storage=storage)
|
||||
)
|
||||
api.daemon_instance = SimpleNamespace(repeater_handler=SimpleNamespace(storage=storage))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -333,7 +331,7 @@ def test_config_export_redacts_secrets_and_identity_keys(cherrypy_ctx):
|
||||
"companions": [{"name": "c1", "identity_key": bytes.fromhex("0102")}],
|
||||
"room_servers": [{"name": "r1", "identity_key": bytes.fromhex("0304")}],
|
||||
},
|
||||
"misc": {"blob": b"\x0A\x0B"},
|
||||
"misc": {"blob": b"\x0a\x0b"},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -689,7 +687,9 @@ def test_db_vacuum_options_success_and_error(cherrypy_ctx):
|
||||
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_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
|
||||
@@ -840,96 +840,99 @@ radio_type: none
|
||||
|
||||
|
||||
def test_update_web_config_options_no_updates_success_failure(cherrypy_ctx):
|
||||
request, _ = cherrypy_ctx
|
||||
api = _make_api({"web": {"cors_enabled": True}})
|
||||
request, _ = cherrypy_ctx
|
||||
api = _make_api({"web": {"cors_enabled": True}})
|
||||
|
||||
request.method = "OPTIONS"
|
||||
assert api.update_web_config() == ""
|
||||
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.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,
|
||||
)
|
||||
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"
|
||||
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, _ = 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 = "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"]
|
||||
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")
|
||||
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()
|
||||
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"])
|
||||
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(
|
||||
"""
|
||||
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",
|
||||
)
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = api.validate_config()
|
||||
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
|
||||
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(
|
||||
"""
|
||||
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:
|
||||
@@ -946,25 +949,25 @@ pymc_tcp:
|
||||
host: REPLACE_WITH_MODEM_HOST
|
||||
port: 70000
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = api.validate_config()
|
||||
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
|
||||
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(
|
||||
"""
|
||||
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:
|
||||
@@ -978,26 +981,26 @@ radio:
|
||||
tx_power: 22
|
||||
preamble_length: 16
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = api.validate_config()
|
||||
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
|
||||
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(
|
||||
"""
|
||||
"""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:
|
||||
@@ -1014,25 +1017,25 @@ kiss:
|
||||
port: /dev/ttyUSB0
|
||||
baud_rate: true
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = api.validate_config()
|
||||
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
|
||||
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(
|
||||
"""
|
||||
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:
|
||||
@@ -1055,29 +1058,29 @@ sx1262:
|
||||
txen_pin: 18
|
||||
rxen_pin: 17
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = api.validate_config()
|
||||
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
|
||||
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(
|
||||
"""
|
||||
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:
|
||||
@@ -1101,67 +1104,67 @@ sx1262:
|
||||
rxen_pin: 17
|
||||
en_pins: [21, bad]
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = api.validate_config()
|
||||
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
|
||||
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}}}
|
||||
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()
|
||||
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
|
||||
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 ***"},
|
||||
]
|
||||
}
|
||||
}
|
||||
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()
|
||||
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"] == ""
|
||||
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"] == ""
|
||||
|
||||
|
||||
def test_stats_includes_versions_and_buildroot_image_info(cherrypy_ctx):
|
||||
@@ -1169,7 +1172,10 @@ def test_stats_includes_versions_and_buildroot_image_info(cherrypy_ctx):
|
||||
api = _make_api({"radio_type": "sx1262", "web": {"site_name": "Field"}})
|
||||
api.stats_getter = lambda: {"uptime": 10}
|
||||
|
||||
with patch("repeater.web.api_endpoints.get_buildroot_image_info", return_value={"image_name": "pyMC", "image_version": "1.2.3"}):
|
||||
with patch(
|
||||
"repeater.web.api_endpoints.get_buildroot_image_info",
|
||||
return_value={"image_name": "pyMC", "image_version": "1.2.3"},
|
||||
):
|
||||
out = api.stats()
|
||||
|
||||
assert out["uptime"] == 10
|
||||
@@ -1184,7 +1190,9 @@ def test_gps_snapshot_when_service_present_and_default_when_absent(cherrypy_ctx)
|
||||
del cherrypy_ctx
|
||||
api = _make_api({"gps": {"enabled": True}})
|
||||
|
||||
api.daemon_instance = SimpleNamespace(gps_service=SimpleNamespace(get_snapshot=lambda: {"running": True}))
|
||||
api.daemon_instance = SimpleNamespace(
|
||||
gps_service=SimpleNamespace(get_snapshot=lambda: {"running": True})
|
||||
)
|
||||
out = api.gps()
|
||||
assert out == {"success": True, "data": {"running": True}}
|
||||
|
||||
@@ -1226,9 +1234,16 @@ def test_check_pymc_console_and_mqtt_status_and_broker_presets(cherrypy_ctx):
|
||||
assert status2["success"] is True
|
||||
assert status2["data"]["brokers"][0]["name"] == "main"
|
||||
|
||||
with patch("repeater.presets.list_presets", return_value=["waev"]), patch(
|
||||
"repeater.presets.get_preset",
|
||||
return_value={"display_name": "Waev", "website": "https://waev.app", "brokers": [{"host": "h"}]},
|
||||
with (
|
||||
patch("repeater.presets.list_presets", return_value=["waev"]),
|
||||
patch(
|
||||
"repeater.presets.get_preset",
|
||||
return_value={
|
||||
"display_name": "Waev",
|
||||
"website": "https://waev.app",
|
||||
"brokers": [{"host": "h"}],
|
||||
},
|
||||
),
|
||||
):
|
||||
presets = api.broker_presets()
|
||||
assert presets["success"] is True
|
||||
@@ -1587,14 +1602,18 @@ def test_identity_endpoints_paths(cherrypy_ctx):
|
||||
if t == "room_server"
|
||||
else [("comp1", _FakeIdentityObj(0x51), {"settings": {"tcp_port": 5000}})]
|
||||
),
|
||||
get_identity_by_name=lambda n: (_FakeIdentityObj(0x42), {}, "room_server") if n == "main" else None,
|
||||
get_identity_by_name=lambda n: (
|
||||
(_FakeIdentityObj(0x42), {}, "room_server") if n == "main" else None
|
||||
),
|
||||
named_identities={"comp1": 1, "main": 1},
|
||||
)
|
||||
api.daemon_instance = SimpleNamespace(identity_manager=id_mgr)
|
||||
api.config = {
|
||||
"identities": {
|
||||
"room_servers": [{"name": "main", "identity_key": "a" * 64, "settings": {"x": 1}}],
|
||||
"companions": [{"name": "comp1", "identity_key": "b" * 64, "settings": {"tcp_port": 5000}}],
|
||||
"companions": [
|
||||
{"name": "comp1", "identity_key": "b" * 64, "settings": {"tcp_port": 5000}}
|
||||
],
|
||||
}
|
||||
}
|
||||
api.config_manager.save_to_file.return_value = True
|
||||
@@ -1616,15 +1635,27 @@ def test_identity_endpoints_paths(cherrypy_ctx):
|
||||
assert api.create_identity()["success"] is False
|
||||
request.json = {"name": "x", "type": "invalid"}
|
||||
assert api.create_identity()["success"] is False
|
||||
request.json = {"name": "x", "type": "room_server", "settings": {"admin_password": "p", "guest_password": "p"}}
|
||||
request.json = {
|
||||
"name": "x",
|
||||
"type": "room_server",
|
||||
"settings": {"admin_password": "p", "guest_password": "p"},
|
||||
}
|
||||
assert api.create_identity()["success"] is False
|
||||
request.json = {"name": "comp1", "type": "companion", "identity_key": "aa" * 32}
|
||||
assert api.create_identity()["success"] is False
|
||||
|
||||
request.json = {"name": "new-comp", "type": "companion", "identity_key": "cc" * 32, "settings": {"node_name": "N"}}
|
||||
request.json = {
|
||||
"name": "new-comp",
|
||||
"type": "companion",
|
||||
"identity_key": "cc" * 32,
|
||||
"settings": {"node_name": "N"},
|
||||
}
|
||||
api.event_loop = object()
|
||||
api.daemon_instance = SimpleNamespace(add_companion_from_config=MagicMock())
|
||||
with patch("asyncio.run_coroutine_threadsafe", return_value=SimpleNamespace(result=lambda timeout: True)):
|
||||
with patch(
|
||||
"asyncio.run_coroutine_threadsafe",
|
||||
return_value=SimpleNamespace(result=lambda timeout: True),
|
||||
):
|
||||
created = api.create_identity()
|
||||
assert created["success"] is True
|
||||
|
||||
@@ -1674,11 +1705,16 @@ def test_acl_endpoints_paths(cherrypy_ctx):
|
||||
login_helper = SimpleNamespace(get_acl_dict=lambda: {0x42: acl, 0x51: _FakeACL([])})
|
||||
id_mgr = SimpleNamespace(
|
||||
get_identities_by_type=lambda t: (
|
||||
[("room1", _FakeIdentityObj(0x42), {})] if t == "room_server" else [("comp1", _FakeIdentityObj(0x51), {})]
|
||||
[("room1", _FakeIdentityObj(0x42), {})]
|
||||
if t == "room_server"
|
||||
else [("comp1", _FakeIdentityObj(0x51), {})]
|
||||
)
|
||||
)
|
||||
local = _FakeIdentityObj(0x42)
|
||||
frame_server = SimpleNamespace(companion_hash="0x51", _client_writer=SimpleNamespace(get_extra_info=lambda k: ("10.0.0.2", 1234)))
|
||||
frame_server = SimpleNamespace(
|
||||
companion_hash="0x51",
|
||||
_client_writer=SimpleNamespace(get_extra_info=lambda k: ("10.0.0.2", 1234)),
|
||||
)
|
||||
api.daemon_instance = SimpleNamespace(
|
||||
login_helper=login_helper,
|
||||
identity_manager=id_mgr,
|
||||
@@ -1727,14 +1763,37 @@ def test_room_endpoint_slice(cherrypy_ctx):
|
||||
request.method = "GET"
|
||||
db = SimpleNamespace(
|
||||
get_room_message_count=MagicMock(return_value=1),
|
||||
get_room_messages=MagicMock(return_value=[{"id": 1, "author_pubkey": "aa" * 32, "post_timestamp": 1.0, "sender_timestamp": 1, "message_text": "m", "txt_type": 0}]),
|
||||
get_room_messages=MagicMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": 1,
|
||||
"author_pubkey": "aa" * 32,
|
||||
"post_timestamp": 1.0,
|
||||
"sender_timestamp": 1,
|
||||
"message_text": "m",
|
||||
"txt_type": 0,
|
||||
}
|
||||
]
|
||||
),
|
||||
get_messages_since=MagicMock(return_value=[]),
|
||||
delete_room_message=MagicMock(return_value=True),
|
||||
clear_room_messages=MagicMock(return_value=1),
|
||||
get_all_room_clients=MagicMock(return_value=[]),
|
||||
)
|
||||
room = SimpleNamespace(db=db, max_posts=10, _running=True, next_push_time=0, last_cleanup_time=0)
|
||||
with patch.object(api, "_get_room_server_by_name_or_hash", return_value={"room_server": room, "name": "room", "hash": 0x42, "identity": None, "config": {}}):
|
||||
room = SimpleNamespace(
|
||||
db=db, max_posts=10, _running=True, next_push_time=0, last_cleanup_time=0
|
||||
)
|
||||
with patch.object(
|
||||
api,
|
||||
"_get_room_server_by_name_or_hash",
|
||||
return_value={
|
||||
"room_server": room,
|
||||
"name": "room",
|
||||
"hash": 0x42,
|
||||
"identity": None,
|
||||
"config": {},
|
||||
},
|
||||
):
|
||||
_attach_storage(api, SimpleNamespace(get_node_name_by_pubkey=lambda _pk: "Node"))
|
||||
msgs = api.room_messages(room_name="room")
|
||||
assert msgs["success"] is True
|
||||
|
||||
@@ -20,7 +20,9 @@ def test_jwt_handler_create_and_verify_and_invalid_cases():
|
||||
assert payload["sub"] == "admin"
|
||||
assert payload["client_id"] == "client-1"
|
||||
|
||||
expired = jwt.encode({"sub": "admin", "client_id": "c", "iat": 1, "exp": 1}, secret, algorithm="HS256")
|
||||
expired = jwt.encode(
|
||||
{"sub": "admin", "client_id": "c", "iat": 1, "exp": 1}, secret, algorithm="HS256"
|
||||
)
|
||||
assert h.verify_jwt(expired) is None
|
||||
assert h.verify_jwt("not-a-token") is None
|
||||
|
||||
|
||||
@@ -35,8 +35,14 @@ def _jwt_ok_payload():
|
||||
|
||||
def _jwt_handler(ok=True):
|
||||
if ok:
|
||||
return SimpleNamespace(verify_jwt=lambda _token: _jwt_ok_payload(), create_jwt=lambda u, c: "jwt-new", expiry_minutes=15)
|
||||
return SimpleNamespace(verify_jwt=lambda _token: None, create_jwt=lambda u, c: "jwt-new", expiry_minutes=15)
|
||||
return SimpleNamespace(
|
||||
verify_jwt=lambda _token: _jwt_ok_payload(),
|
||||
create_jwt=lambda u, c: "jwt-new",
|
||||
expiry_minutes=15,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
verify_jwt=lambda _token: None, create_jwt=lambda u, c: "jwt-new", expiry_minutes=15
|
||||
)
|
||||
|
||||
|
||||
def _token_mgr():
|
||||
@@ -78,7 +84,9 @@ def test_tokens_index_get_post_and_error_paths(cp_ctx):
|
||||
# GET exception
|
||||
_req, _resp, cfg = cp_ctx(method="GET", headers={"Authorization": "Bearer ok"})
|
||||
cfg["jwt_handler"] = _jwt_handler(ok=True)
|
||||
cfg["token_manager"] = SimpleNamespace(list_tokens=lambda: (_ for _ in ()).throw(RuntimeError("db")))
|
||||
cfg["token_manager"] = SimpleNamespace(
|
||||
list_tokens=lambda: (_ for _ in ()).throw(RuntimeError("db"))
|
||||
)
|
||||
out = endpoint.index()
|
||||
assert out["success"] is False
|
||||
assert cherrypy.response.status == 500
|
||||
@@ -144,7 +152,11 @@ def test_tokens_default_delete_paths(cp_ctx):
|
||||
|
||||
|
||||
def test_login_paths(cp_ctx):
|
||||
auth = AuthEndpoints(config={"repeater": {"security": {"admin_password": "pw"}}}, jwt_handler=_jwt_handler(ok=True), token_manager=_token_mgr())
|
||||
auth = AuthEndpoints(
|
||||
config={"repeater": {"security": {"admin_password": "pw"}}},
|
||||
jwt_handler=_jwt_handler(ok=True),
|
||||
token_manager=_token_mgr(),
|
||||
)
|
||||
|
||||
cp_ctx(method="OPTIONS")
|
||||
assert auth.login() == b""
|
||||
@@ -153,12 +165,18 @@ def test_login_paths(cp_ctx):
|
||||
out = json.loads(auth.login().decode())
|
||||
assert out["success"] is False
|
||||
|
||||
cp_ctx(method="POST", body=json.dumps({"username": "admin", "password": "pw", "client_id": "abc"}).encode())
|
||||
cp_ctx(
|
||||
method="POST",
|
||||
body=json.dumps({"username": "admin", "password": "pw", "client_id": "abc"}).encode(),
|
||||
)
|
||||
out = json.loads(auth.login().decode())
|
||||
assert out["success"] is True
|
||||
assert out["token"] == "jwt-new"
|
||||
|
||||
cp_ctx(method="POST", body=json.dumps({"username": "admin", "password": "bad", "client_id": "abc"}).encode())
|
||||
cp_ctx(
|
||||
method="POST",
|
||||
body=json.dumps({"username": "admin", "password": "bad", "client_id": "abc"}).encode(),
|
||||
)
|
||||
out = json.loads(auth.login().decode())
|
||||
assert out["success"] is False
|
||||
|
||||
@@ -201,7 +219,9 @@ def test_refresh_paths(cp_ctx):
|
||||
assert out["success"] is True # falls back to payload client_id
|
||||
|
||||
# api token path
|
||||
_req, _resp, cfg = cp_ctx(method="POST", headers={"X-API-Key": "k"}, body=json.dumps({"client_id": "z"}).encode())
|
||||
_req, _resp, cfg = cp_ctx(
|
||||
method="POST", headers={"X-API-Key": "k"}, body=json.dumps({"client_id": "z"}).encode()
|
||||
)
|
||||
cfg["jwt_handler"] = _jwt_handler(ok=False)
|
||||
cfg["token_manager"] = _token_mgr()
|
||||
out = json.loads(auth.refresh().decode())
|
||||
@@ -269,7 +289,9 @@ def test_change_password_paths(cp_ctx):
|
||||
_req, _resp, cfg = cp_ctx(
|
||||
method="POST",
|
||||
headers={"Authorization": "Bearer ok"},
|
||||
body=json.dumps({"current_password": "old-password", "new_password": "new-password"}).encode(),
|
||||
body=json.dumps(
|
||||
{"current_password": "old-password", "new_password": "new-password"}
|
||||
).encode(),
|
||||
)
|
||||
cfg["jwt_handler"] = _jwt_handler(ok=True)
|
||||
cfg["token_manager"] = _token_mgr()
|
||||
@@ -286,7 +308,9 @@ def test_change_password_paths(cp_ctx):
|
||||
_req, _resp, cfg = cp_ctx(
|
||||
method="POST",
|
||||
headers={"Authorization": "Bearer ok"},
|
||||
body=json.dumps({"current_password": "old-password", "new_password": "new-password"}).encode(),
|
||||
body=json.dumps(
|
||||
{"current_password": "old-password", "new_password": "new-password"}
|
||||
).encode(),
|
||||
)
|
||||
cfg["jwt_handler"] = _jwt_handler(ok=True)
|
||||
cfg["token_manager"] = _token_mgr()
|
||||
|
||||
@@ -134,9 +134,12 @@ async def test_frame_server_persistence_paths_and_stop():
|
||||
get_channel=lambda idx: None,
|
||||
)
|
||||
|
||||
with patch("repeater.companion.frame_server._BaseFrameServer.__init__", lambda self, **kwargs: None), patch(
|
||||
"repeater.companion.frame_server._BaseFrameServer.stop", AsyncMock()
|
||||
) as base_stop:
|
||||
with (
|
||||
patch(
|
||||
"repeater.companion.frame_server._BaseFrameServer.__init__", lambda self, **kwargs: None
|
||||
),
|
||||
patch("repeater.companion.frame_server._BaseFrameServer.stop", AsyncMock()) as base_stop,
|
||||
):
|
||||
srv = CompanionFrameServer(bridge=bridge, companion_hash="h", sqlite_handler=sqlite)
|
||||
srv.bridge = bridge
|
||||
srv.companion_hash = "h"
|
||||
@@ -171,7 +174,9 @@ async def test_frame_server_persistence_paths_and_stop():
|
||||
sqlite.companion_upsert_contact.assert_called_once()
|
||||
|
||||
bridge.get_contacts = lambda: [contact]
|
||||
bridge.get_channel = lambda idx: (SimpleNamespace(name="c1", secret="s") if idx == 1 else None)
|
||||
bridge.get_channel = lambda idx: (
|
||||
SimpleNamespace(name="c1", secret="s") if idx == 1 else None
|
||||
)
|
||||
await srv.stop()
|
||||
|
||||
sqlite.companion_save_contacts.assert_called_once()
|
||||
@@ -185,7 +190,9 @@ async def test_frame_server_persistence_paths_and_stop():
|
||||
async def test_frame_server_no_more_messages_response_when_empty():
|
||||
bridge = SimpleNamespace(sync_next_message=lambda: None)
|
||||
|
||||
with patch("repeater.companion.frame_server._BaseFrameServer.__init__", lambda self, **kwargs: None):
|
||||
with patch(
|
||||
"repeater.companion.frame_server._BaseFrameServer.__init__", lambda self, **kwargs: None
|
||||
):
|
||||
srv = CompanionFrameServer(bridge=bridge, companion_hash="h", sqlite_handler=None)
|
||||
srv.bridge = bridge
|
||||
srv._write_frame = MagicMock()
|
||||
|
||||
@@ -110,7 +110,11 @@ def test_resolve_tcp_endpoint_paths(monkeypatch):
|
||||
|
||||
# daemon with empty bridges
|
||||
daemon = SimpleNamespace(
|
||||
identity_manager=SimpleNamespace(get_identities_by_type=lambda _t: [("c1", SimpleNamespace(get_public_key=lambda: b"\x01"), {})]),
|
||||
identity_manager=SimpleNamespace(
|
||||
get_identities_by_type=lambda _t: [
|
||||
("c1", SimpleNamespace(get_public_key=lambda: b"\x01"), {})
|
||||
]
|
||||
),
|
||||
companion_bridges={},
|
||||
config={"identities": {"companions": []}},
|
||||
)
|
||||
@@ -119,9 +123,19 @@ def test_resolve_tcp_endpoint_paths(monkeypatch):
|
||||
|
||||
# found in identity+bridge and in config, bind 0.0.0.0 => loopback
|
||||
daemon = SimpleNamespace(
|
||||
identity_manager=SimpleNamespace(get_identities_by_type=lambda _t: [("c1", SimpleNamespace(get_public_key=lambda: b"\x01"), {})]),
|
||||
identity_manager=SimpleNamespace(
|
||||
get_identities_by_type=lambda _t: [
|
||||
("c1", SimpleNamespace(get_public_key=lambda: b"\x01"), {})
|
||||
]
|
||||
),
|
||||
companion_bridges={1: object()},
|
||||
config={"identities": {"companions": [{"name": "c1", "settings": {"tcp_port": 6000, "bind_address": "0.0.0.0"}}]}},
|
||||
config={
|
||||
"identities": {
|
||||
"companions": [
|
||||
{"name": "c1", "settings": {"tcp_port": 6000, "bind_address": "0.0.0.0"}}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
proxy.set_daemon(daemon)
|
||||
assert ws._resolve_tcp_endpoint("c1") == ("127.0.0.1", 6000)
|
||||
@@ -166,7 +180,9 @@ def test_tcp_to_ws_and_teardown():
|
||||
ws2._companion_name = "c2"
|
||||
tcp_ref = MagicMock()
|
||||
ws2._tcp = tcp_ref
|
||||
ws2._teardown = proxy.CompanionFrameWebSocket._teardown.__get__(ws2, proxy.CompanionFrameWebSocket)
|
||||
ws2._teardown = proxy.CompanionFrameWebSocket._teardown.__get__(
|
||||
ws2, proxy.CompanionFrameWebSocket
|
||||
)
|
||||
ws2._teardown()
|
||||
tcp_ref.close.assert_called_once()
|
||||
ws2.close.assert_called_once()
|
||||
|
||||
@@ -127,4 +127,4 @@ def test_live_update_daemon_applies_kiss_radio_config():
|
||||
)
|
||||
]
|
||||
assert radio.radio_config == config["radio"]
|
||||
assert daemon.repeater_handler.radio_config == config["radio"]
|
||||
assert daemon.repeater_handler.radio_config == config["radio"]
|
||||
|
||||
+343
-240
@@ -5,6 +5,7 @@ Covers: flood_forward, direct_forward, process_packet, duplicate detection,
|
||||
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 time
|
||||
@@ -101,13 +102,14 @@ def handler():
|
||||
patch("repeater.engine.RepeaterHandler._start_background_tasks"),
|
||||
):
|
||||
from repeater.engine import RepeaterHandler
|
||||
|
||||
h = RepeaterHandler(config, dispatcher, LOCAL_HASH)
|
||||
return h
|
||||
|
||||
|
||||
def _make_flood_packet(payload: bytes = b"\x01\x02\x03\x04",
|
||||
path: bytes = b"",
|
||||
payload_type: int = 0x01) -> Packet:
|
||||
def _make_flood_packet(
|
||||
payload: bytes = b"\x01\x02\x03\x04", path: bytes = b"", payload_type: int = 0x01
|
||||
) -> Packet:
|
||||
"""Build a FLOOD-routed packet."""
|
||||
pkt = Packet()
|
||||
# header: route=FLOOD(0x01), payload_type shifted, version=0
|
||||
@@ -119,9 +121,9 @@ def _make_flood_packet(payload: bytes = b"\x01\x02\x03\x04",
|
||||
return pkt
|
||||
|
||||
|
||||
def _make_direct_packet(payload: bytes = b"\x01\x02\x03\x04",
|
||||
path: bytes = None,
|
||||
payload_type: int = 0x01) -> Packet:
|
||||
def _make_direct_packet(
|
||||
payload: bytes = b"\x01\x02\x03\x04", path: bytes = None, payload_type: int = 0x01
|
||||
) -> Packet:
|
||||
"""Build a DIRECT-routed packet with path[0] == LOCAL_HASH by default."""
|
||||
if path is None:
|
||||
path = bytes([LOCAL_HASH, 0xCC, 0xDD])
|
||||
@@ -134,10 +136,12 @@ def _make_direct_packet(payload: bytes = b"\x01\x02\x03\x04",
|
||||
return pkt
|
||||
|
||||
|
||||
def _make_transport_flood_packet(payload: bytes = b"\x01\x02\x03\x04",
|
||||
path: bytes = b"",
|
||||
payload_type: int = 0x01,
|
||||
transport_codes=(0x1234, 0x5678)) -> Packet:
|
||||
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 | (payload_type << PH_TYPE_SHIFT)
|
||||
@@ -149,10 +153,12 @@ def _make_transport_flood_packet(payload: bytes = b"\x01\x02\x03\x04",
|
||||
return pkt
|
||||
|
||||
|
||||
def _make_transport_direct_packet(payload: bytes = b"\x01\x02\x03\x04",
|
||||
path: bytes = None,
|
||||
payload_type: int = 0x01,
|
||||
transport_codes=(0x1234, 0x5678)) -> Packet:
|
||||
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])
|
||||
@@ -170,6 +176,7 @@ def _make_transport_direct_packet(payload: bytes = b"\x01\x02\x03\x04",
|
||||
# 1. flood_forward
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestFloodForward:
|
||||
"""flood_forward: validation, duplicate suppression, path append."""
|
||||
|
||||
@@ -237,7 +244,7 @@ class TestFloodForward:
|
||||
def test_hash_computed_before_path_append(self, handler):
|
||||
"""mark_seen must use the pre-append hash so duplicate detection works
|
||||
when another node sends the same packet with or without our hash."""
|
||||
pkt1 = _make_flood_packet(payload=b"\xAA\xBB")
|
||||
pkt1 = _make_flood_packet(payload=b"\xaa\xbb")
|
||||
hash_before = pkt1.calculate_packet_hash().hex().upper()
|
||||
|
||||
handler.flood_forward(pkt1)
|
||||
@@ -271,6 +278,7 @@ class TestFloodForward:
|
||||
# 2. direct_forward
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestDirectForward:
|
||||
"""direct_forward: next-hop check, path consumption, duplicate suppression."""
|
||||
|
||||
@@ -343,6 +351,7 @@ class TestDirectForward:
|
||||
# 3. process_packet — route dispatch
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestProcessPacket:
|
||||
"""process_packet routes to flood_forward or direct_forward."""
|
||||
|
||||
@@ -364,7 +373,7 @@ class TestProcessPacket:
|
||||
|
||||
def test_transport_flood_dispatched(self, handler):
|
||||
pkt = _make_transport_flood_packet()
|
||||
with patch.object(handler, '_check_transport_codes', return_value=(True, "")):
|
||||
with patch.object(handler, "_check_transport_codes", return_value=(True, "")):
|
||||
result = handler.process_packet(pkt, snr=5.0)
|
||||
assert result is not None
|
||||
fwd_pkt, _ = result
|
||||
@@ -410,6 +419,7 @@ class TestProcessPacket:
|
||||
# 4. is_duplicate / mark_seen / cache management
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestDuplicateDetection:
|
||||
"""Duplicate tracking, TTL clean-up, and cache eviction."""
|
||||
|
||||
@@ -464,6 +474,7 @@ class TestDuplicateDetection:
|
||||
# 5. validate_packet
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestValidatePacket:
|
||||
"""validate_packet: empty payload, oversized path."""
|
||||
|
||||
@@ -500,51 +511,62 @@ class TestValidatePacket:
|
||||
# 6. calculate_packet_score — static method
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestPacketScore:
|
||||
"""Score: SNR thresholds, collision penalty, clamping."""
|
||||
|
||||
def test_below_threshold_returns_zero(self):
|
||||
from repeater.engine import RepeaterHandler
|
||||
|
||||
# SF8 threshold is -10.0
|
||||
score = RepeaterHandler.calculate_packet_score(snr=-15.0, packet_len=50, spreading_factor=8)
|
||||
assert score == 0.0
|
||||
|
||||
def test_at_threshold_returns_zero(self):
|
||||
from repeater.engine import RepeaterHandler
|
||||
|
||||
score = RepeaterHandler.calculate_packet_score(snr=-10.0, packet_len=50, spreading_factor=8)
|
||||
assert score == 0.0
|
||||
|
||||
def test_above_threshold_positive(self):
|
||||
from repeater.engine import RepeaterHandler
|
||||
|
||||
score = RepeaterHandler.calculate_packet_score(snr=0.0, packet_len=50, spreading_factor=8)
|
||||
assert score > 0.0
|
||||
|
||||
def test_high_snr_high_score(self):
|
||||
from repeater.engine import RepeaterHandler
|
||||
|
||||
score = RepeaterHandler.calculate_packet_score(snr=10.0, packet_len=10, spreading_factor=8)
|
||||
assert score > 0.5
|
||||
|
||||
def test_long_packet_collision_penalty(self):
|
||||
from repeater.engine import RepeaterHandler
|
||||
|
||||
short = RepeaterHandler.calculate_packet_score(snr=5.0, packet_len=10, spreading_factor=8)
|
||||
long_ = RepeaterHandler.calculate_packet_score(snr=5.0, packet_len=250, spreading_factor=8)
|
||||
assert short > long_
|
||||
|
||||
def test_score_clamped_to_0_1(self):
|
||||
from repeater.engine import RepeaterHandler
|
||||
|
||||
score = RepeaterHandler.calculate_packet_score(snr=50.0, packet_len=1, spreading_factor=8)
|
||||
assert 0.0 <= score <= 1.0
|
||||
|
||||
def test_sf_below_7_returns_zero(self):
|
||||
from repeater.engine import RepeaterHandler
|
||||
|
||||
score = RepeaterHandler.calculate_packet_score(snr=10.0, packet_len=50, spreading_factor=6)
|
||||
assert score == 0.0
|
||||
|
||||
def test_each_sf_has_different_threshold(self):
|
||||
from repeater.engine import RepeaterHandler
|
||||
|
||||
scores = {}
|
||||
for sf in (7, 8, 9, 10, 11, 12):
|
||||
scores[sf] = RepeaterHandler.calculate_packet_score(snr=-5.0, packet_len=50, spreading_factor=sf)
|
||||
scores[sf] = RepeaterHandler.calculate_packet_score(
|
||||
snr=-5.0, packet_len=50, spreading_factor=sf
|
||||
)
|
||||
# Higher SF → lower threshold → better reception at same SNR
|
||||
# At SNR=-5, SF7 (threshold -7.5) should be worse than SF12 (threshold -20)
|
||||
assert scores[12] > scores[7]
|
||||
@@ -554,6 +576,7 @@ class TestPacketScore:
|
||||
# 7. _calculate_tx_delay
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestTxDelay:
|
||||
"""TX delay: flood random, direct fixed, score adjustment, cap."""
|
||||
|
||||
@@ -616,11 +639,12 @@ class TestTxDelay:
|
||||
# 8. Hash stability through forwarding operations
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestHashStabilityThroughForwarding:
|
||||
"""Verify hash is computed on original packet (before path mutation)."""
|
||||
|
||||
def test_flood_hash_unchanged_after_forward(self, handler):
|
||||
pkt = _make_flood_packet(payload=b"\xDE\xAD")
|
||||
pkt = _make_flood_packet(payload=b"\xde\xad")
|
||||
hash_before = pkt.calculate_packet_hash().hex().upper()
|
||||
|
||||
handler.flood_forward(pkt)
|
||||
@@ -628,8 +652,7 @@ class TestHashStabilityThroughForwarding:
|
||||
assert hash_before in handler.seen_packets
|
||||
|
||||
def test_direct_hash_unchanged_after_forward(self, handler):
|
||||
pkt = _make_direct_packet(payload=b"\xBE\xEF",
|
||||
path=bytes([LOCAL_HASH, 0xCC]))
|
||||
pkt = _make_direct_packet(payload=b"\xbe\xef", path=bytes([LOCAL_HASH, 0xCC]))
|
||||
hash_before = pkt.calculate_packet_hash().hex().upper()
|
||||
|
||||
handler.direct_forward(pkt)
|
||||
@@ -638,17 +661,15 @@ class TestHashStabilityThroughForwarding:
|
||||
def test_flood_second_identical_detected_as_duplicate(self, handler):
|
||||
"""Two identical packets with the same payload (but path not yet modified)
|
||||
should be correctly detected as duplicates."""
|
||||
p1 = _make_flood_packet(payload=b"\xCA\xFE")
|
||||
p2 = _make_flood_packet(payload=b"\xCA\xFE")
|
||||
p1 = _make_flood_packet(payload=b"\xca\xfe")
|
||||
p2 = _make_flood_packet(payload=b"\xca\xfe")
|
||||
handler.flood_forward(p1)
|
||||
result = handler.flood_forward(p2)
|
||||
assert result is None
|
||||
|
||||
def test_direct_second_identical_detected_as_duplicate(self, handler):
|
||||
p1 = _make_direct_packet(payload=b"\xCA\xFE",
|
||||
path=bytes([LOCAL_HASH, 0x11]))
|
||||
p2 = _make_direct_packet(payload=b"\xCA\xFE",
|
||||
path=bytes([LOCAL_HASH, 0x11]))
|
||||
p1 = _make_direct_packet(payload=b"\xca\xfe", path=bytes([LOCAL_HASH, 0x11]))
|
||||
p2 = _make_direct_packet(payload=b"\xca\xfe", path=bytes([LOCAL_HASH, 0x11]))
|
||||
handler.direct_forward(p1)
|
||||
result = handler.direct_forward(p2)
|
||||
assert result is None
|
||||
@@ -658,6 +679,7 @@ class TestHashStabilityThroughForwarding:
|
||||
# 9. unscoped flood policy
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestUnscopedFloodPolicy:
|
||||
"""unscoped_flood_allow=False blocks plain flood, transport checked."""
|
||||
|
||||
@@ -680,7 +702,7 @@ class TestUnscopedFloodPolicy:
|
||||
# unscoped traffic is denied — the two settings are fully independent.
|
||||
handler.config["mesh"]["unscoped_flood_allow"] = False
|
||||
pkt = _make_transport_flood_packet()
|
||||
with patch.object(handler, '_check_transport_codes', return_value=(True, "")):
|
||||
with patch.object(handler, "_check_transport_codes", return_value=(True, "")):
|
||||
result = handler.flood_forward(pkt)
|
||||
assert result is not None # transport flood passes; unscoped=False did not block it
|
||||
|
||||
@@ -735,6 +757,7 @@ class TestFloodLoopDetection:
|
||||
# 10. Airtime / duty-cycle integration
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestAirtimeIntegration:
|
||||
"""Airtime calculation and duty-cycle enforcement."""
|
||||
|
||||
@@ -771,6 +794,7 @@ class TestAirtimeIntegration:
|
||||
# 11. Config reload
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestConfigReload:
|
||||
"""reload_runtime_config updates in-memory state."""
|
||||
|
||||
@@ -799,6 +823,7 @@ class TestConfigReload:
|
||||
# 12. _get_drop_reason
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestGetDropReason:
|
||||
"""_get_drop_reason: determine why a packet was not forwarded."""
|
||||
|
||||
@@ -842,12 +867,13 @@ class TestGetDropReason:
|
||||
# 13. Transport route forwarding
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestTransportForwarding:
|
||||
"""TRANSPORT_FLOOD and TRANSPORT_DIRECT: packet routing through process_packet."""
|
||||
|
||||
def test_transport_flood_appends_path(self, handler):
|
||||
pkt = _make_transport_flood_packet(path=b"\x11")
|
||||
with patch.object(handler, '_check_transport_codes', return_value=(True, "")):
|
||||
with patch.object(handler, "_check_transport_codes", return_value=(True, "")):
|
||||
result = handler.process_packet(pkt, snr=5.0)
|
||||
assert result is not None
|
||||
fwd_pkt, _ = result
|
||||
@@ -863,7 +889,7 @@ class TestTransportForwarding:
|
||||
|
||||
def test_transport_codes_preserved_after_flood(self, handler):
|
||||
pkt = _make_transport_flood_packet(transport_codes=(0xAAAA, 0xBBBB))
|
||||
with patch.object(handler, '_check_transport_codes', return_value=(True, "")):
|
||||
with patch.object(handler, "_check_transport_codes", return_value=(True, "")):
|
||||
result = handler.process_packet(pkt, snr=5.0)
|
||||
assert result is not None
|
||||
fwd_pkt, _ = result
|
||||
@@ -881,6 +907,7 @@ class TestTransportForwarding:
|
||||
# 14. Statistics tracking
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestStatistics:
|
||||
"""RX/TX/dropped counters and recent_packets list."""
|
||||
|
||||
@@ -908,6 +935,7 @@ class TestStatistics:
|
||||
# 15. Edge cases and regression tests
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Miscellaneous edge cases and regressions."""
|
||||
|
||||
@@ -924,12 +952,12 @@ class TestEdgeCases:
|
||||
def test_flood_forward_idempotent_on_second_call(self, handler):
|
||||
"""Calling flood_forward again with the SAME packet object should
|
||||
detect as duplicate (the first call already mark_seen'd it)."""
|
||||
pkt = _make_flood_packet(payload=b"\xFF" * 10)
|
||||
pkt = _make_flood_packet(payload=b"\xff" * 10)
|
||||
r1 = handler.flood_forward(pkt)
|
||||
assert r1 is not None
|
||||
# Now pkt has local_hash appended, but hash was computed pre-append.
|
||||
# A new packet with same original payload should be duplicate.
|
||||
pkt2 = _make_flood_packet(payload=b"\xFF" * 10)
|
||||
pkt2 = _make_flood_packet(payload=b"\xff" * 10)
|
||||
r2 = handler.flood_forward(pkt2)
|
||||
assert r2 is None
|
||||
|
||||
@@ -988,6 +1016,7 @@ class TestEdgeCases:
|
||||
# 15b. TX mode: forward, monitor, no_tx
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTxMode:
|
||||
"""forward = repeat on; monitor = no repeat, local TX allowed; no_tx = all TX off."""
|
||||
@@ -1048,6 +1077,7 @@ class TestTxMode:
|
||||
# 16. Airtime calculation correctness
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestAirtimeCalculation:
|
||||
"""Semtech LoRa airtime formula validation."""
|
||||
|
||||
@@ -1055,8 +1085,9 @@ class TestAirtimeCalculation:
|
||||
"""SF7, 125kHz, CR4/5, 10-byte payload — well-known reference value."""
|
||||
mgr = handler.airtime_mgr
|
||||
# Override to known settings
|
||||
at = mgr.calculate_airtime(10, spreading_factor=7, bandwidth_hz=125000,
|
||||
coding_rate=5, preamble_len=8)
|
||||
at = mgr.calculate_airtime(
|
||||
10, spreading_factor=7, bandwidth_hz=125000, coding_rate=5, preamble_len=8
|
||||
)
|
||||
# Semtech calculator: ~36ms for these params
|
||||
assert 30.0 < at < 50.0
|
||||
|
||||
@@ -1080,190 +1111,240 @@ class TestAirtimeCalculation:
|
||||
# ---- 20 GOOD packets: all should be forwarded by process_packet ----
|
||||
GOOD_PACKETS = [
|
||||
# (id, description, builder)
|
||||
("good_flood_minimal",
|
||||
"Flood, 1-byte payload, empty path",
|
||||
lambda: _make_flood_packet(payload=b"\x01")),
|
||||
|
||||
("good_flood_typical",
|
||||
"Flood, 10-byte payload, 2-hop path",
|
||||
lambda: _make_flood_packet(payload=bytes(range(10)), path=b"\x11\x22")),
|
||||
|
||||
("good_flood_max_payload_type",
|
||||
"Flood, payload_type=15 (max 4-bit)",
|
||||
lambda: _make_flood_packet(payload=b"\xAA\xBB", payload_type=15)),
|
||||
|
||||
("good_flood_payload_type_0",
|
||||
"Flood, payload_type=0 (plain text)",
|
||||
lambda: _make_flood_packet(payload=b"\x01\x02\x03", payload_type=0)),
|
||||
|
||||
("good_flood_long_payload",
|
||||
"Flood, 200-byte payload",
|
||||
lambda: _make_flood_packet(payload=bytes(range(200)))),
|
||||
|
||||
("good_flood_single_byte_path",
|
||||
"Flood, path has 1 prior hop",
|
||||
lambda: _make_flood_packet(payload=b"\xDE\xAD", path=b"\x42")),
|
||||
|
||||
("good_flood_binary_payload",
|
||||
"Flood, all-zero payload",
|
||||
lambda: _make_flood_packet(payload=b"\x00" * 16)),
|
||||
|
||||
("good_flood_high_entropy",
|
||||
"Flood, high-entropy random-looking payload",
|
||||
lambda: _make_flood_packet(payload=bytes(i ^ 0xA5 for i in range(64)))),
|
||||
|
||||
("good_flood_advert_type",
|
||||
"Flood, payload_type=4 (ADVERT)",
|
||||
lambda: _make_flood_packet(payload=b"\xAB\x01\x02\x03", payload_type=4)),
|
||||
|
||||
("good_direct_minimal",
|
||||
"Direct, 1-byte payload, single hop to us (forward with empty path)",
|
||||
lambda: _make_direct_packet(payload=b"\x01", path=bytes([LOCAL_HASH]))),
|
||||
|
||||
("good_direct_multihop",
|
||||
"Direct, 3-hop remaining path (us + 2 more)",
|
||||
lambda: _make_direct_packet(payload=b"\xCA\xFE", path=bytes([LOCAL_HASH, 0x11, 0x22]))),
|
||||
|
||||
("good_direct_long_payload",
|
||||
"Direct, 150-byte payload",
|
||||
lambda: _make_direct_packet(payload=bytes(range(150)), path=bytes([LOCAL_HASH, 0xBB]))),
|
||||
|
||||
("good_direct_type_2",
|
||||
"Direct, payload_type=2 (ACK)",
|
||||
lambda: _make_direct_packet(payload=b"\x01\x02", path=bytes([LOCAL_HASH]),
|
||||
payload_type=2)),
|
||||
|
||||
("good_direct_long_remaining_path",
|
||||
"Direct, 10 hops remaining after us",
|
||||
lambda: _make_direct_packet(payload=b"\xFF\xEE",
|
||||
path=bytes([LOCAL_HASH] + list(range(10))))),
|
||||
|
||||
("good_transport_direct_basic",
|
||||
"Transport direct, basic hop to us",
|
||||
lambda: _make_transport_direct_packet(payload=b"\x01\x02")),
|
||||
("good_transport_direct_long_path",
|
||||
"Transport direct, 5 remaining hops",
|
||||
lambda: _make_transport_direct_packet(
|
||||
payload=b"\xDE\xAD\xBE\xEF",
|
||||
path=bytes([LOCAL_HASH, 0x11, 0x22, 0x33, 0x44]))),
|
||||
(
|
||||
"good_flood_minimal",
|
||||
"Flood, 1-byte payload, empty path",
|
||||
lambda: _make_flood_packet(payload=b"\x01"),
|
||||
),
|
||||
(
|
||||
"good_flood_typical",
|
||||
"Flood, 10-byte payload, 2-hop path",
|
||||
lambda: _make_flood_packet(payload=bytes(range(10)), path=b"\x11\x22"),
|
||||
),
|
||||
(
|
||||
"good_flood_max_payload_type",
|
||||
"Flood, payload_type=15 (max 4-bit)",
|
||||
lambda: _make_flood_packet(payload=b"\xaa\xbb", payload_type=15),
|
||||
),
|
||||
(
|
||||
"good_flood_payload_type_0",
|
||||
"Flood, payload_type=0 (plain text)",
|
||||
lambda: _make_flood_packet(payload=b"\x01\x02\x03", payload_type=0),
|
||||
),
|
||||
(
|
||||
"good_flood_long_payload",
|
||||
"Flood, 200-byte payload",
|
||||
lambda: _make_flood_packet(payload=bytes(range(200))),
|
||||
),
|
||||
(
|
||||
"good_flood_single_byte_path",
|
||||
"Flood, path has 1 prior hop",
|
||||
lambda: _make_flood_packet(payload=b"\xde\xad", path=b"\x42"),
|
||||
),
|
||||
(
|
||||
"good_flood_binary_payload",
|
||||
"Flood, all-zero payload",
|
||||
lambda: _make_flood_packet(payload=b"\x00" * 16),
|
||||
),
|
||||
(
|
||||
"good_flood_high_entropy",
|
||||
"Flood, high-entropy random-looking payload",
|
||||
lambda: _make_flood_packet(payload=bytes(i ^ 0xA5 for i in range(64))),
|
||||
),
|
||||
(
|
||||
"good_flood_advert_type",
|
||||
"Flood, payload_type=4 (ADVERT)",
|
||||
lambda: _make_flood_packet(payload=b"\xab\x01\x02\x03", payload_type=4),
|
||||
),
|
||||
(
|
||||
"good_direct_minimal",
|
||||
"Direct, 1-byte payload, single hop to us (forward with empty path)",
|
||||
lambda: _make_direct_packet(payload=b"\x01", path=bytes([LOCAL_HASH])),
|
||||
),
|
||||
(
|
||||
"good_direct_multihop",
|
||||
"Direct, 3-hop remaining path (us + 2 more)",
|
||||
lambda: _make_direct_packet(payload=b"\xca\xfe", path=bytes([LOCAL_HASH, 0x11, 0x22])),
|
||||
),
|
||||
(
|
||||
"good_direct_long_payload",
|
||||
"Direct, 150-byte payload",
|
||||
lambda: _make_direct_packet(payload=bytes(range(150)), path=bytes([LOCAL_HASH, 0xBB])),
|
||||
),
|
||||
(
|
||||
"good_direct_type_2",
|
||||
"Direct, payload_type=2 (ACK)",
|
||||
lambda: _make_direct_packet(payload=b"\x01\x02", path=bytes([LOCAL_HASH]), payload_type=2),
|
||||
),
|
||||
(
|
||||
"good_direct_long_remaining_path",
|
||||
"Direct, 10 hops remaining after us",
|
||||
lambda: _make_direct_packet(
|
||||
payload=b"\xff\xee", path=bytes([LOCAL_HASH] + list(range(10)))
|
||||
),
|
||||
),
|
||||
(
|
||||
"good_transport_direct_basic",
|
||||
"Transport direct, basic hop to us",
|
||||
lambda: _make_transport_direct_packet(payload=b"\x01\x02"),
|
||||
),
|
||||
(
|
||||
"good_transport_direct_long_path",
|
||||
"Transport direct, 5 remaining hops",
|
||||
lambda: _make_transport_direct_packet(
|
||||
payload=b"\xde\xad\xbe\xef", path=bytes([LOCAL_HASH, 0x11, 0x22, 0x33, 0x44])
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ---- 20 BAD packets: all should be dropped / return None ----
|
||||
BAD_PACKETS = [
|
||||
# (id, description, builder)
|
||||
("bad_empty_payload",
|
||||
"Empty bytearray payload",
|
||||
lambda: _make_flood_packet(payload=b""),
|
||||
"Empty payload"),
|
||||
|
||||
("bad_none_payload",
|
||||
"payload = None",
|
||||
lambda: (lambda p: (setattr(p, "payload", None), p)[-1])(_make_flood_packet()),
|
||||
"Empty payload"),
|
||||
|
||||
("bad_path_at_max",
|
||||
"Path exactly MAX_PATH_SIZE — no room to append",
|
||||
lambda: _make_flood_packet(payload=b"\x01", path=bytes(range(MAX_PATH_SIZE))),
|
||||
"Path length"),
|
||||
|
||||
("bad_flood_path_near_max",
|
||||
"Flood, path = MAX_PATH_SIZE - 1 (63 hops; path_len encodes 0-63, cannot append)",
|
||||
lambda: _make_flood_packet(payload=b"\xFF", path=bytes(range(MAX_PATH_SIZE - 1))),
|
||||
"cannot append"),
|
||||
|
||||
("bad_path_over_max",
|
||||
"Path exceeds MAX_PATH_SIZE",
|
||||
lambda: _make_flood_packet(payload=b"\x01", path=bytes(range(MAX_PATH_SIZE + 5))),
|
||||
"Path length"),
|
||||
|
||||
("bad_do_not_retransmit",
|
||||
"Marked do-not-retransmit",
|
||||
lambda: (lambda p: (p.mark_do_not_retransmit(), p)[-1])(_make_flood_packet()),
|
||||
"do not retransmit"),
|
||||
|
||||
("bad_direct_wrong_hop",
|
||||
"Direct packet, path[0] != LOCAL_HASH",
|
||||
lambda: _make_direct_packet(path=bytes([0xFF, 0xCC])),
|
||||
"not for us"),
|
||||
|
||||
("bad_direct_empty_path",
|
||||
"Direct packet with empty path",
|
||||
lambda: _make_direct_packet(path=b""),
|
||||
"no path"),
|
||||
|
||||
("bad_direct_none_path",
|
||||
"Direct packet with path = None",
|
||||
lambda: (lambda p: (setattr(p, "path", None), setattr(p, "path_len", 0), p)[-1])(
|
||||
_make_direct_packet()),
|
||||
"no path"),
|
||||
|
||||
("bad_flood_policy_off",
|
||||
"Plain flood when unscoped_flood_allow=False (needs config override)",
|
||||
lambda: _make_flood_packet(payload=b"\x01\x02"),
|
||||
"unscoped flood"),
|
||||
|
||||
("bad_transport_flood_no_keys",
|
||||
"Transport flood with no configured transport keys — always denied",
|
||||
lambda: _make_transport_flood_packet(payload=b"\x01\x02"),
|
||||
"transport"),
|
||||
|
||||
("bad_direct_empty_payload",
|
||||
"Direct with empty payload (now caught by validate_packet)",
|
||||
lambda: (lambda p: (setattr(p, "payload", bytearray()), setattr(p, "payload_len", 0), p)[-1])(
|
||||
_make_direct_packet(path=bytes([LOCAL_HASH]))),
|
||||
"Empty payload"),
|
||||
|
||||
("bad_flood_zero_len_payload",
|
||||
"Flood with payload_len forced to 0",
|
||||
lambda: (lambda p: (setattr(p, "payload_len", 0), setattr(p, "payload", bytearray()), p)[-1])(
|
||||
_make_flood_packet(payload=b"\x01")),
|
||||
"Empty payload"),
|
||||
|
||||
("bad_direct_only_wrong_hops",
|
||||
"Direct path of all 0xFF bytes (none match LOCAL_HASH)",
|
||||
lambda: _make_direct_packet(path=bytes([0xFF, 0xFE, 0xFD])),
|
||||
"not for us"),
|
||||
|
||||
("bad_transport_direct_wrong_hop",
|
||||
"Transport direct with wrong first hop",
|
||||
lambda: _make_transport_direct_packet(path=bytes([0x01, 0x02])),
|
||||
"not for us"),
|
||||
|
||||
("bad_transport_direct_empty_path",
|
||||
"Transport direct with empty path",
|
||||
lambda: _make_transport_direct_packet(path=b""),
|
||||
"no path"),
|
||||
|
||||
("bad_transport_direct_none_path",
|
||||
"Transport direct with path = None",
|
||||
lambda: (lambda p: (setattr(p, "path", None), setattr(p, "path_len", 0), p)[-1])(
|
||||
_make_transport_direct_packet()),
|
||||
"no path"),
|
||||
|
||||
("bad_flood_payload_255_zeros",
|
||||
"Flood with payload = bytearray(0) (empty)",
|
||||
lambda: (lambda p: (setattr(p, "payload", bytearray()), setattr(p, "payload_len", 0), p)[-1])(
|
||||
_make_flood_packet()),
|
||||
"Empty payload"),
|
||||
|
||||
("bad_direct_none_payload",
|
||||
"Direct with None payload (now caught by validate_packet)",
|
||||
lambda: (lambda p: (setattr(p, "payload", None), p)[-1])(
|
||||
_make_direct_packet(path=bytes([LOCAL_HASH]))),
|
||||
"Empty payload"),
|
||||
|
||||
("bad_flood_do_not_retransmit_custom",
|
||||
"Flood, do-not-retransmit with custom drop reason",
|
||||
lambda: (lambda p: (p.mark_do_not_retransmit(), setattr(p, "drop_reason", "Advert consumed"), p)[-1])(
|
||||
_make_flood_packet(payload=b"\xAB")),
|
||||
"Advert consumed"),
|
||||
|
||||
("bad_direct_do_not_retransmit",
|
||||
"Direct, marked do-not-retransmit (now caught by direct_forward)",
|
||||
lambda: (lambda p: (p.mark_do_not_retransmit(), p)[-1])(
|
||||
_make_direct_packet(payload=b"\x99", path=bytes([LOCAL_HASH, 0x11]))),
|
||||
"do not retransmit"),
|
||||
(
|
||||
"bad_empty_payload",
|
||||
"Empty bytearray payload",
|
||||
lambda: _make_flood_packet(payload=b""),
|
||||
"Empty payload",
|
||||
),
|
||||
(
|
||||
"bad_none_payload",
|
||||
"payload = None",
|
||||
lambda: (lambda p: (setattr(p, "payload", None), p)[-1])(_make_flood_packet()),
|
||||
"Empty payload",
|
||||
),
|
||||
(
|
||||
"bad_path_at_max",
|
||||
"Path exactly MAX_PATH_SIZE — no room to append",
|
||||
lambda: _make_flood_packet(payload=b"\x01", path=bytes(range(MAX_PATH_SIZE))),
|
||||
"Path length",
|
||||
),
|
||||
(
|
||||
"bad_flood_path_near_max",
|
||||
"Flood, path = MAX_PATH_SIZE - 1 (63 hops; path_len encodes 0-63, cannot append)",
|
||||
lambda: _make_flood_packet(payload=b"\xff", path=bytes(range(MAX_PATH_SIZE - 1))),
|
||||
"cannot append",
|
||||
),
|
||||
(
|
||||
"bad_path_over_max",
|
||||
"Path exceeds MAX_PATH_SIZE",
|
||||
lambda: _make_flood_packet(payload=b"\x01", path=bytes(range(MAX_PATH_SIZE + 5))),
|
||||
"Path length",
|
||||
),
|
||||
(
|
||||
"bad_do_not_retransmit",
|
||||
"Marked do-not-retransmit",
|
||||
lambda: (lambda p: (p.mark_do_not_retransmit(), p)[-1])(_make_flood_packet()),
|
||||
"do not retransmit",
|
||||
),
|
||||
(
|
||||
"bad_direct_wrong_hop",
|
||||
"Direct packet, path[0] != LOCAL_HASH",
|
||||
lambda: _make_direct_packet(path=bytes([0xFF, 0xCC])),
|
||||
"not for us",
|
||||
),
|
||||
(
|
||||
"bad_direct_empty_path",
|
||||
"Direct packet with empty path",
|
||||
lambda: _make_direct_packet(path=b""),
|
||||
"no path",
|
||||
),
|
||||
(
|
||||
"bad_direct_none_path",
|
||||
"Direct packet with path = None",
|
||||
lambda: (lambda p: (setattr(p, "path", None), setattr(p, "path_len", 0), p)[-1])(
|
||||
_make_direct_packet()
|
||||
),
|
||||
"no path",
|
||||
),
|
||||
(
|
||||
"bad_flood_policy_off",
|
||||
"Plain flood when unscoped_flood_allow=False (needs config override)",
|
||||
lambda: _make_flood_packet(payload=b"\x01\x02"),
|
||||
"unscoped flood",
|
||||
),
|
||||
(
|
||||
"bad_transport_flood_no_keys",
|
||||
"Transport flood with no configured transport keys — always denied",
|
||||
lambda: _make_transport_flood_packet(payload=b"\x01\x02"),
|
||||
"transport",
|
||||
),
|
||||
(
|
||||
"bad_direct_empty_payload",
|
||||
"Direct with empty payload (now caught by validate_packet)",
|
||||
lambda: (
|
||||
lambda p: (setattr(p, "payload", bytearray()), setattr(p, "payload_len", 0), p)[-1]
|
||||
)(_make_direct_packet(path=bytes([LOCAL_HASH]))),
|
||||
"Empty payload",
|
||||
),
|
||||
(
|
||||
"bad_flood_zero_len_payload",
|
||||
"Flood with payload_len forced to 0",
|
||||
lambda: (
|
||||
lambda p: (setattr(p, "payload_len", 0), setattr(p, "payload", bytearray()), p)[-1]
|
||||
)(_make_flood_packet(payload=b"\x01")),
|
||||
"Empty payload",
|
||||
),
|
||||
(
|
||||
"bad_direct_only_wrong_hops",
|
||||
"Direct path of all 0xFF bytes (none match LOCAL_HASH)",
|
||||
lambda: _make_direct_packet(path=bytes([0xFF, 0xFE, 0xFD])),
|
||||
"not for us",
|
||||
),
|
||||
(
|
||||
"bad_transport_direct_wrong_hop",
|
||||
"Transport direct with wrong first hop",
|
||||
lambda: _make_transport_direct_packet(path=bytes([0x01, 0x02])),
|
||||
"not for us",
|
||||
),
|
||||
(
|
||||
"bad_transport_direct_empty_path",
|
||||
"Transport direct with empty path",
|
||||
lambda: _make_transport_direct_packet(path=b""),
|
||||
"no path",
|
||||
),
|
||||
(
|
||||
"bad_transport_direct_none_path",
|
||||
"Transport direct with path = None",
|
||||
lambda: (lambda p: (setattr(p, "path", None), setattr(p, "path_len", 0), p)[-1])(
|
||||
_make_transport_direct_packet()
|
||||
),
|
||||
"no path",
|
||||
),
|
||||
(
|
||||
"bad_flood_payload_255_zeros",
|
||||
"Flood with payload = bytearray(0) (empty)",
|
||||
lambda: (
|
||||
lambda p: (setattr(p, "payload", bytearray()), setattr(p, "payload_len", 0), p)[-1]
|
||||
)(_make_flood_packet()),
|
||||
"Empty payload",
|
||||
),
|
||||
(
|
||||
"bad_direct_none_payload",
|
||||
"Direct with None payload (now caught by validate_packet)",
|
||||
lambda: (lambda p: (setattr(p, "payload", None), p)[-1])(
|
||||
_make_direct_packet(path=bytes([LOCAL_HASH]))
|
||||
),
|
||||
"Empty payload",
|
||||
),
|
||||
(
|
||||
"bad_flood_do_not_retransmit_custom",
|
||||
"Flood, do-not-retransmit with custom drop reason",
|
||||
lambda: (
|
||||
lambda p: (p.mark_do_not_retransmit(), setattr(p, "drop_reason", "Advert consumed"), p)[
|
||||
-1
|
||||
]
|
||||
)(_make_flood_packet(payload=b"\xab")),
|
||||
"Advert consumed",
|
||||
),
|
||||
(
|
||||
"bad_direct_do_not_retransmit",
|
||||
"Direct, marked do-not-retransmit (now caught by direct_forward)",
|
||||
lambda: (lambda p: (p.mark_do_not_retransmit(), p)[-1])(
|
||||
_make_direct_packet(payload=b"\x99", path=bytes([LOCAL_HASH, 0x11]))
|
||||
),
|
||||
"do not retransmit",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -1276,7 +1357,9 @@ class TestGoodPacketArray:
|
||||
"""All 20 good packets should be forwarded successfully."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, desc, builder", GOOD_PACKETS, ids=_good_ids,
|
||||
"name, desc, builder",
|
||||
GOOD_PACKETS,
|
||||
ids=_good_ids,
|
||||
)
|
||||
def test_process_packet_forwards(self, handler, name, desc, builder):
|
||||
pkt = builder()
|
||||
@@ -1286,14 +1369,18 @@ class TestGoodPacketArray:
|
||||
assert delay >= 0.0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, desc, builder", GOOD_PACKETS, ids=_good_ids,
|
||||
"name, desc, builder",
|
||||
GOOD_PACKETS,
|
||||
ids=_good_ids,
|
||||
)
|
||||
def test_good_packet_not_duplicate_on_first_see(self, handler, name, desc, builder):
|
||||
pkt = builder()
|
||||
assert handler.is_duplicate(pkt) is False, f"[{name}] falsely flagged as duplicate"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, desc, builder", GOOD_PACKETS, ids=_good_ids,
|
||||
"name, desc, builder",
|
||||
GOOD_PACKETS,
|
||||
ids=_good_ids,
|
||||
)
|
||||
def test_good_packet_path_modified(self, handler, name, desc, builder):
|
||||
pkt = builder()
|
||||
@@ -1317,7 +1404,8 @@ class TestBadPacketArray:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, desc, builder, expected_reason",
|
||||
BAD_PACKETS, ids=_bad_ids,
|
||||
BAD_PACKETS,
|
||||
ids=_bad_ids,
|
||||
)
|
||||
def test_process_packet_drops(self, handler, name, desc, builder, expected_reason):
|
||||
# Two entries need unscoped_flood_allow=False
|
||||
@@ -1330,7 +1418,8 @@ class TestBadPacketArray:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, desc, builder, expected_reason",
|
||||
BAD_PACKETS, ids=_bad_ids,
|
||||
BAD_PACKETS,
|
||||
ids=_bad_ids,
|
||||
)
|
||||
def test_drop_reason_set(self, handler, name, desc, builder, expected_reason):
|
||||
if "policy_off" in name:
|
||||
@@ -1345,7 +1434,8 @@ class TestBadPacketArray:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, desc, builder, expected_reason",
|
||||
BAD_PACKETS, ids=_bad_ids,
|
||||
BAD_PACKETS,
|
||||
ids=_bad_ids,
|
||||
)
|
||||
def test_bad_packet_not_marked_seen(self, handler, name, desc, builder, expected_reason):
|
||||
"""Dropped packets must NOT pollute the seen cache."""
|
||||
@@ -1405,9 +1495,7 @@ class TestPacketInjectionRouting:
|
||||
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")
|
||||
)
|
||||
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),
|
||||
@@ -1426,7 +1514,7 @@ class TestPacketInjectionRouting:
|
||||
self._prepare_fast_tx(handler)
|
||||
|
||||
pkt = _inject_from_wire(
|
||||
_make_direct_packet(payload=b"\xAA\xBB", path=bytes([LOCAL_HASH, 0x44, 0x55]))
|
||||
_make_direct_packet(payload=b"\xaa\xbb", path=bytes([LOCAL_HASH, 0x44, 0x55]))
|
||||
)
|
||||
|
||||
with (
|
||||
@@ -1440,9 +1528,7 @@ class TestPacketInjectionRouting:
|
||||
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")
|
||||
)
|
||||
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)
|
||||
@@ -1474,9 +1560,7 @@ class TestPacketInjectionRouting:
|
||||
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"")
|
||||
)
|
||||
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")),
|
||||
@@ -1490,16 +1574,14 @@ class TestPacketInjectionRouting:
|
||||
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"")
|
||||
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"")
|
||||
)
|
||||
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
|
||||
@@ -1555,7 +1637,9 @@ class TestPacketInjectionRouting:
|
||||
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):
|
||||
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(
|
||||
@@ -1579,7 +1663,9 @@ class TestPacketInjectionRouting:
|
||||
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):
|
||||
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(
|
||||
@@ -1845,7 +1931,11 @@ class TestEngineTransmissionAndBackgroundLifecycle:
|
||||
|
||||
with (
|
||||
patch("repeater.engine.time.time", return_value=100000.0),
|
||||
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock, side_effect=asyncio.CancelledError),
|
||||
patch(
|
||||
"repeater.engine.asyncio.sleep",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=asyncio.CancelledError,
|
||||
),
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await handler._background_timer_loop()
|
||||
@@ -1870,7 +1960,11 @@ class TestEngineTransmissionAndBackgroundLifecycle:
|
||||
|
||||
with (
|
||||
patch("repeater.engine.time.time", return_value=100000.0),
|
||||
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock, side_effect=asyncio.CancelledError),
|
||||
patch(
|
||||
"repeater.engine.asyncio.sleep",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=asyncio.CancelledError,
|
||||
),
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await handler._background_timer_loop()
|
||||
@@ -1893,7 +1987,9 @@ class TestEngineTransmissionAndBackgroundLifecycle:
|
||||
|
||||
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.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()
|
||||
@@ -1912,7 +2008,9 @@ class TestEngineTransmissionAndBackgroundLifecycle:
|
||||
await handler._record_noise_floor_async()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_crc_errors_returns_without_storage_and_handles_storage_exception(self, handler):
|
||||
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()
|
||||
@@ -1926,7 +2024,9 @@ class TestEngineTransmissionAndBackgroundLifecycle:
|
||||
await handler._record_crc_errors_async()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_periodic_advert_handles_missing_handler_and_handler_exception(self, handler):
|
||||
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()
|
||||
|
||||
@@ -1945,7 +2045,10 @@ class TestEngineRecordAndCleanupHelpers:
|
||||
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]
|
||||
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()
|
||||
@@ -1960,7 +2063,7 @@ class TestEngineRecordAndCleanupHelpers:
|
||||
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)
|
||||
pkt.header = 0x00 << PH_TYPE_SHIFT
|
||||
|
||||
handler.record_duplicate(pkt, rssi=-90, snr=0.5)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ objects to verify:
|
||||
- mark_seen / is_duplicate cache behaviour
|
||||
- do_not_retransmit flag handling
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
@@ -106,12 +107,12 @@ class TestDuplicateSuppression:
|
||||
def test_same_packet_forwarded_twice_is_duplicate(self):
|
||||
"""Forwarding the same packet a second time must be rejected as duplicate."""
|
||||
h = _make_handler()
|
||||
pkt1 = _make_flood_packet(payload=b"\xDE\xAD")
|
||||
pkt1 = _make_flood_packet(payload=b"\xde\xad")
|
||||
result1 = h.flood_forward(pkt1)
|
||||
assert result1 is not None
|
||||
|
||||
# Same content in a fresh Packet object
|
||||
pkt2 = _make_flood_packet(payload=b"\xDE\xAD")
|
||||
pkt2 = _make_flood_packet(payload=b"\xde\xad")
|
||||
result2 = h.flood_forward(pkt2)
|
||||
assert result2 is None
|
||||
assert pkt2.drop_reason == "Duplicate"
|
||||
@@ -128,7 +129,7 @@ class TestDuplicateSuppression:
|
||||
def test_mark_seen_makes_is_duplicate_true(self):
|
||||
"""mark_seen records the hash; is_duplicate finds it."""
|
||||
h = _make_handler()
|
||||
pkt = _make_flood_packet(payload=b"\xAA\xBB")
|
||||
pkt = _make_flood_packet(payload=b"\xaa\xbb")
|
||||
assert not h.is_duplicate(pkt)
|
||||
h.mark_seen(pkt)
|
||||
assert h.is_duplicate(pkt)
|
||||
@@ -149,10 +150,8 @@ class TestDuplicateSuppression:
|
||||
except for TRACE packets. Two flood packets with different paths
|
||||
but same payload have the same hash.
|
||||
"""
|
||||
pkt_a = _make_flood_packet(path_bytes=b"\x11", hash_size=1, hash_count=1,
|
||||
payload=b"\xFF")
|
||||
pkt_b = _make_flood_packet(path_bytes=b"\x22", hash_size=1, hash_count=1,
|
||||
payload=b"\xFF")
|
||||
pkt_a = _make_flood_packet(path_bytes=b"\x11", hash_size=1, hash_count=1, payload=b"\xff")
|
||||
pkt_b = _make_flood_packet(path_bytes=b"\x22", hash_size=1, hash_count=1, payload=b"\xff")
|
||||
assert pkt_a.calculate_packet_hash() == pkt_b.calculate_packet_hash()
|
||||
|
||||
def test_seen_cache_eviction(self):
|
||||
@@ -183,62 +182,53 @@ class TestLoopDetection1Byte:
|
||||
|
||||
def test_loop_detect_off_allows_own_hash(self):
|
||||
"""With loop_detect=off, packet with our hash in path is forwarded."""
|
||||
h = _make_handler(loop_detect="off",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
h = _make_handler(loop_detect="off", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Path contains our 1-byte hash (0xAB) once
|
||||
pkt = _make_flood_packet(b"\xAB", hash_size=1, hash_count=1)
|
||||
pkt = _make_flood_packet(b"\xab", hash_size=1, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
def test_loop_detect_strict_blocks_single_occurrence(self):
|
||||
"""strict mode (threshold=1): one occurrence of our hash → loop."""
|
||||
h = _make_handler(loop_detect="strict",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xAB", hash_size=1, hash_count=1)
|
||||
h = _make_handler(loop_detect="strict", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xab", hash_size=1, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert "loop" in pkt.drop_reason.lower()
|
||||
|
||||
def test_loop_detect_moderate_allows_one_occurrence(self):
|
||||
"""moderate mode (threshold=2): one occurrence is fine."""
|
||||
h = _make_handler(loop_detect="moderate",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\x11\xAB", hash_size=1, hash_count=2)
|
||||
h = _make_handler(loop_detect="moderate", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\x11\xab", hash_size=1, hash_count=2)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
def test_loop_detect_moderate_blocks_two_occurrences(self):
|
||||
"""moderate mode (threshold=2): two occurrences → loop."""
|
||||
h = _make_handler(loop_detect="moderate",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xAB\x11\xAB", hash_size=1, hash_count=3)
|
||||
h = _make_handler(loop_detect="moderate", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xab\x11\xab", hash_size=1, hash_count=3)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert "loop" in pkt.drop_reason.lower()
|
||||
|
||||
def test_loop_detect_minimal_allows_three_occurrences(self):
|
||||
"""minimal mode (threshold=4): three occurrences still OK."""
|
||||
h = _make_handler(loop_detect="minimal",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xAB\x11\xAB\x22\xAB", hash_size=1, hash_count=5)
|
||||
h = _make_handler(loop_detect="minimal", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xab\x11\xab\x22\xab", hash_size=1, hash_count=5)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
def test_loop_detect_minimal_blocks_four_occurrences(self):
|
||||
"""minimal mode (threshold=4): four occurrences → loop."""
|
||||
h = _make_handler(loop_detect="minimal",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(
|
||||
b"\xAB\x11\xAB\x22\xAB\x33\xAB", hash_size=1, hash_count=7
|
||||
)
|
||||
h = _make_handler(loop_detect="minimal", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xab\x11\xab\x22\xab\x33\xab", hash_size=1, hash_count=7)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert "loop" in pkt.drop_reason.lower()
|
||||
|
||||
def test_loop_detect_no_match_passes(self):
|
||||
"""Strict mode still passes if our hash is not in the path."""
|
||||
h = _make_handler(loop_detect="strict",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
h = _make_handler(loop_detect="strict", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\x11\x22\x33", hash_size=1, hash_count=3)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
@@ -261,15 +251,13 @@ class TestOwnHashReForwarding:
|
||||
Receiving an identical packet is a duplicate.
|
||||
"""
|
||||
h = _make_handler(loop_detect="off")
|
||||
pkt = _make_flood_packet(b"\x11", hash_size=1, hash_count=1,
|
||||
payload=b"\xAA\xBB")
|
||||
pkt = _make_flood_packet(b"\x11", hash_size=1, hash_count=1, payload=b"\xaa\xbb")
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
# The original packet's payload hash was marked seen
|
||||
|
||||
# Receiving same original packet again (before our hop was appended)
|
||||
pkt2 = _make_flood_packet(b"\x11", hash_size=1, hash_count=1,
|
||||
payload=b"\xAA\xBB")
|
||||
pkt2 = _make_flood_packet(b"\x11", hash_size=1, hash_count=1, payload=b"\xaa\xbb")
|
||||
result2 = h.flood_forward(pkt2)
|
||||
assert result2 is None
|
||||
assert pkt2.drop_reason == "Duplicate"
|
||||
@@ -283,8 +271,7 @@ class TestOwnHashReForwarding:
|
||||
h = _make_handler(loop_detect="strict", local_hash_bytes=our_hash)
|
||||
|
||||
# Original packet arrives, we forward (appending 0xAB)
|
||||
pkt = _make_flood_packet(b"\x11", hash_size=1, hash_count=1,
|
||||
payload=b"\xDD\xEE")
|
||||
pkt = _make_flood_packet(b"\x11", hash_size=1, hash_count=1, payload=b"\xdd\xee")
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
# Now path is [0x11, 0xAB], and this exact payload is in seen_packets
|
||||
@@ -293,8 +280,10 @@ class TestOwnHashReForwarding:
|
||||
# so it's a new payload in the packet hash sense (different path iteration)
|
||||
# but path contains our hash 0xAB
|
||||
looped_pkt = _make_flood_packet(
|
||||
b"\x11\xAB\x22", hash_size=1, hash_count=3,
|
||||
payload=b"\xDD\xEE\xFF" # different payload → not a duplicate
|
||||
b"\x11\xab\x22",
|
||||
hash_size=1,
|
||||
hash_count=3,
|
||||
payload=b"\xdd\xee\xff", # different payload → not a duplicate
|
||||
)
|
||||
result2 = h.flood_forward(looped_pkt)
|
||||
assert result2 is None
|
||||
@@ -328,25 +317,22 @@ class TestLoopDetectionMultiByte:
|
||||
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]))
|
||||
h = _make_handler(loop_detect="strict", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Path hop is AB11; local 2-byte hash is ABCD.
|
||||
pkt = _make_flood_packet(b"\xAB\x11", hash_size=2, hash_count=1)
|
||||
pkt = _make_flood_packet(b"\xab\x11", hash_size=2, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
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."""
|
||||
h = _make_handler(loop_detect="off",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xAB\x11", hash_size=2, hash_count=1)
|
||||
h = _make_handler(loop_detect="off", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xab\x11", hash_size=2, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
def test_2_byte_no_local_hash_byte_passes_strict(self):
|
||||
"""If local_hash byte doesn't appear anywhere in the 2-byte path, strict passes."""
|
||||
h = _make_handler(loop_detect="strict",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
h = _make_handler(loop_detect="strict", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Path: [0x11, 0x22] — no 0xAB byte
|
||||
pkt = _make_flood_packet(b"\x11\x22", hash_size=2, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
@@ -354,10 +340,9 @@ class TestLoopDetectionMultiByte:
|
||||
|
||||
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]))
|
||||
h = _make_handler(loop_detect="strict", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Hop 11AB33 does not equal local 3-byte hash ABCDEF.
|
||||
pkt = _make_flood_packet(b"\x11\xAB\x33", hash_size=3, hash_count=1)
|
||||
pkt = _make_flood_packet(b"\x11\xab\x33", hash_size=3, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
@@ -366,10 +351,9 @@ class TestLoopDetectionMultiByte:
|
||||
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]))
|
||||
h = _make_handler(loop_detect="moderate", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# 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)
|
||||
pkt = _make_flood_packet(b"\xab\x11\xab\x22", hash_size=2, hash_count=2)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
@@ -378,14 +362,13 @@ class TestLoopDetectionMultiByte:
|
||||
After flood_forward in 2-byte mode, verify the path contains
|
||||
only the expected bytes (no extra, no corruption).
|
||||
"""
|
||||
h = _make_handler(loop_detect="off",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
h = _make_handler(loop_detect="off", local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\x11\x22", hash_size=2, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 2
|
||||
hashes = result.get_path_hashes()
|
||||
assert hashes == [b"\x11\x22", b"\xAB\xCD"]
|
||||
assert hashes == [b"\x11\x22", b"\xab\xcd"]
|
||||
|
||||
|
||||
# ===================================================================
|
||||
@@ -494,8 +477,10 @@ class TestValidatePacket:
|
||||
pkt = _make_flood_packet(bytes(63), hash_size=1, hash_count=63)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert "maximum" in (pkt.drop_reason or "").lower() or \
|
||||
"exceed" in (pkt.drop_reason or "").lower()
|
||||
assert (
|
||||
"maximum" in (pkt.drop_reason or "").lower()
|
||||
or "exceed" in (pkt.drop_reason or "").lower()
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
@@ -510,10 +495,8 @@ class TestSerializationAfterForward:
|
||||
"""
|
||||
|
||||
def test_forwarded_1_byte_round_trips(self):
|
||||
h = _make_handler(loop_detect="moderate",
|
||||
local_hash_bytes=bytes([0x42, 0x00, 0x00]))
|
||||
pkt = _make_flood_packet(b"\x11\x22", hash_size=1, hash_count=2,
|
||||
payload=b"\xAA\xBB")
|
||||
h = _make_handler(loop_detect="moderate", local_hash_bytes=bytes([0x42, 0x00, 0x00]))
|
||||
pkt = _make_flood_packet(b"\x11\x22", hash_size=1, hash_count=2, payload=b"\xaa\xbb")
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
wire = result.write_to()
|
||||
@@ -521,13 +504,11 @@ class TestSerializationAfterForward:
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_count() == 3
|
||||
assert pkt2.get_path_hashes() == [b"\x11", b"\x22", b"\x42"]
|
||||
assert pkt2.get_payload() == b"\xAA\xBB"
|
||||
assert pkt2.get_payload() == b"\xaa\xbb"
|
||||
|
||||
def test_forwarded_2_byte_round_trips(self):
|
||||
h = _make_handler(loop_detect="off",
|
||||
local_hash_bytes=bytes([0xAA, 0xBB, 0xCC]))
|
||||
pkt = _make_flood_packet(b"\x11\x22", hash_size=2, hash_count=1,
|
||||
payload=b"\xDE\xAD")
|
||||
h = _make_handler(loop_detect="off", local_hash_bytes=bytes([0xAA, 0xBB, 0xCC]))
|
||||
pkt = _make_flood_packet(b"\x11\x22", hash_size=2, hash_count=1, payload=b"\xde\xad")
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
wire = result.write_to()
|
||||
@@ -535,13 +516,11 @@ class TestSerializationAfterForward:
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_size() == 2
|
||||
assert pkt2.get_path_hash_count() == 2
|
||||
assert pkt2.get_path_hashes() == [b"\x11\x22", b"\xAA\xBB"]
|
||||
assert pkt2.get_path_hashes() == [b"\x11\x22", b"\xaa\xbb"]
|
||||
|
||||
def test_forwarded_3_byte_round_trips(self):
|
||||
h = _make_handler(loop_detect="off",
|
||||
local_hash_bytes=bytes([0xAA, 0xBB, 0xCC]))
|
||||
pkt = _make_flood_packet(b"\x11\x22\x33", hash_size=3, hash_count=1,
|
||||
payload=b"\xBE\xEF")
|
||||
h = _make_handler(loop_detect="off", local_hash_bytes=bytes([0xAA, 0xBB, 0xCC]))
|
||||
pkt = _make_flood_packet(b"\x11\x22\x33", hash_size=3, hash_count=1, payload=b"\xbe\xef")
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
wire = result.write_to()
|
||||
@@ -549,7 +528,7 @@ class TestSerializationAfterForward:
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_size() == 3
|
||||
assert pkt2.get_path_hash_count() == 2
|
||||
assert pkt2.get_path_hashes() == [b"\x11\x22\x33", b"\xAA\xBB\xCC"]
|
||||
assert pkt2.get_path_hashes() == [b"\x11\x22\x33", b"\xaa\xbb\xcc"]
|
||||
|
||||
|
||||
# ===================================================================
|
||||
@@ -572,7 +551,7 @@ class TestFloodChainLoopDetection:
|
||||
]
|
||||
handlers = [_make_handler(loop_detect="strict", local_hash_bytes=h) for h in hashes]
|
||||
|
||||
pkt = _make_flood_packet(payload=b"\xFE\xED")
|
||||
pkt = _make_flood_packet(payload=b"\xfe\xed")
|
||||
for i, h in enumerate(handlers):
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None, f"repeater {i} unexpectedly dropped packet"
|
||||
@@ -582,7 +561,7 @@ class TestFloodChainLoopDetection:
|
||||
path_bytes=bytes(result.path),
|
||||
hash_size=1,
|
||||
hash_count=result.get_path_hash_count(),
|
||||
payload=b"\xFE\xED" + bytes([i + 1]),
|
||||
payload=b"\xfe\xed" + bytes([i + 1]),
|
||||
)
|
||||
|
||||
assert pkt.get_path_hash_count() == 3
|
||||
@@ -607,7 +586,8 @@ class TestFloodChainLoopDetection:
|
||||
|
||||
# B forwards (new payload to avoid dedup)
|
||||
pkt_b = _make_flood_packet(
|
||||
bytes(pkt.path), hash_size=1,
|
||||
bytes(pkt.path),
|
||||
hash_size=1,
|
||||
hash_count=pkt.get_path_hash_count(),
|
||||
payload=b"\x01\x02\x03\x04",
|
||||
)
|
||||
@@ -616,7 +596,8 @@ class TestFloodChainLoopDetection:
|
||||
|
||||
# C forwards
|
||||
pkt_c = _make_flood_packet(
|
||||
bytes(pkt_b.path), hash_size=1,
|
||||
bytes(pkt_b.path),
|
||||
hash_size=1,
|
||||
hash_count=pkt_b.get_path_hash_count(),
|
||||
payload=b"\x01\x02\x03\x04\x05",
|
||||
)
|
||||
@@ -625,7 +606,8 @@ class TestFloodChainLoopDetection:
|
||||
|
||||
# Back to A — 0x11 is already in path → strict blocks it
|
||||
pkt_a2 = _make_flood_packet(
|
||||
bytes(pkt_c.path), hash_size=1,
|
||||
bytes(pkt_c.path),
|
||||
hash_size=1,
|
||||
hash_count=pkt_c.get_path_hash_count(),
|
||||
payload=b"\x01\x02\x03\x04\x05\x06",
|
||||
)
|
||||
@@ -640,11 +622,11 @@ class TestFloodChainLoopDetection:
|
||||
"""
|
||||
h = _make_handler(loop_detect="off", local_hash_bytes=bytes([0x11, 0x00, 0x00]))
|
||||
|
||||
pkt = _make_flood_packet(payload=b"\xAA\xBB")
|
||||
pkt = _make_flood_packet(payload=b"\xaa\xbb")
|
||||
assert h.flood_forward(pkt) is not None
|
||||
|
||||
# Same payload comes back
|
||||
pkt2 = _make_flood_packet(payload=b"\xAA\xBB")
|
||||
pkt2 = _make_flood_packet(payload=b"\xaa\xbb")
|
||||
result = h.flood_forward(pkt2)
|
||||
assert result is None
|
||||
assert pkt2.drop_reason == "Duplicate"
|
||||
@@ -664,7 +646,8 @@ class TestFloodChainLoopDetection:
|
||||
|
||||
# B forwards
|
||||
pkt_b = _make_flood_packet(
|
||||
bytes(pkt.path), hash_size=2,
|
||||
bytes(pkt.path),
|
||||
hash_size=2,
|
||||
hash_count=pkt.get_path_hash_count(),
|
||||
payload=b"\x01\x02\x03",
|
||||
)
|
||||
@@ -673,7 +656,8 @@ class TestFloodChainLoopDetection:
|
||||
|
||||
# Back to A — byte 0xAA is in path → strict detects it
|
||||
pkt_a2 = _make_flood_packet(
|
||||
bytes(pkt_b.path), hash_size=2,
|
||||
bytes(pkt_b.path),
|
||||
hash_size=2,
|
||||
hash_count=pkt_b.get_path_hash_count(),
|
||||
payload=b"\x01\x02\x03\x04",
|
||||
)
|
||||
|
||||
@@ -5,7 +5,9 @@ import time
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
_MODULE_PATH = Path(__file__).resolve().parents[1] / "repeater" / "data_acquisition" / "glass_handler.py"
|
||||
_MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "repeater" / "data_acquisition" / "glass_handler.py"
|
||||
)
|
||||
_SPEC = importlib.util.spec_from_file_location("repeater_glass_handler", _MODULE_PATH)
|
||||
_MODULE = importlib.util.module_from_spec(_SPEC)
|
||||
assert _SPEC and _SPEC.loader
|
||||
|
||||
@@ -3,7 +3,9 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_MODULE_PATH = Path(__file__).resolve().parents[1] / "repeater" / "data_acquisition" / "gps_service.py"
|
||||
_MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "repeater" / "data_acquisition" / "gps_service.py"
|
||||
)
|
||||
_SPEC = importlib.util.spec_from_file_location("repeater_gps_service", _MODULE_PATH)
|
||||
_MODULE = importlib.util.module_from_spec(_SPEC)
|
||||
assert _SPEC and _SPEC.loader
|
||||
@@ -28,12 +30,8 @@ def test_nmea_parser_combines_rmc_gga_gsa_gsv_attributes():
|
||||
assert parser.ingest_sentence(
|
||||
_sentence("GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,")
|
||||
)
|
||||
assert parser.ingest_sentence(
|
||||
_sentence("GPGSA,A,3,04,05,09,12,24,25,29,,,,,,1.8,1.0,1.5")
|
||||
)
|
||||
assert parser.ingest_sentence(
|
||||
_sentence("GPGSV,1,1,03,04,77,045,42,05,13,180,35,09,07,095,29")
|
||||
)
|
||||
assert parser.ingest_sentence(_sentence("GPGSA,A,3,04,05,09,12,24,25,29,,,,,,1.8,1.0,1.5"))
|
||||
assert parser.ingest_sentence(_sentence("GPGSV,1,1,03,04,77,045,42,05,13,180,35,09,07,095,29"))
|
||||
|
||||
snapshot = parser.snapshot()
|
||||
|
||||
@@ -378,6 +376,7 @@ def test_gps_service_reflects_runtime_manual_location_updates():
|
||||
assert snapshot["gps_position"]["longitude"] == -71.1076
|
||||
assert snapshot["position_meta"]["source"] == "manual_config"
|
||||
|
||||
|
||||
def test_repeater_location_uses_config_when_gps_opt_in_disabled():
|
||||
service = GPSService(
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ class _FakeIdentity:
|
||||
|
||||
|
||||
class _FakePacket:
|
||||
def __init__(self, *, header=0x00, path=None, pkt_hash=b"\xAA" * 16):
|
||||
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
|
||||
@@ -286,7 +286,9 @@ def test_advert_reload_config_and_cleanup_old_state_bounds_memory():
|
||||
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._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)
|
||||
|
||||
@@ -77,7 +77,9 @@ def test_cmd_advert_branches_and_success_schedule():
|
||||
fake_loop = SimpleNamespace(is_running=lambda: True)
|
||||
cli._event_loop = fake_loop
|
||||
|
||||
with patch("asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: coro.close()) as run_ts:
|
||||
with patch(
|
||||
"asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: coro.close()
|
||||
) as run_ts:
|
||||
out = cli._cmd_advert()
|
||||
|
||||
assert out == "OK - Advert sent"
|
||||
@@ -124,7 +126,9 @@ def test_cmd_get_public_key_and_neighbor_branches():
|
||||
cli.storage_handler = storage
|
||||
assert cli._cmd_neighbors() == "No neighbors discovered yet"
|
||||
|
||||
storage.get_neighbors = lambda: {"aa": {"is_repeater": False, "zero_hop": False, "last_seen": 1}}
|
||||
storage.get_neighbors = lambda: {
|
||||
"aa": {"is_repeater": False, "zero_hop": False, "last_seen": 1}
|
||||
}
|
||||
assert "No repeaters or zero hop" in cli._cmd_neighbors()
|
||||
|
||||
storage.get_neighbors = lambda: {
|
||||
@@ -137,7 +141,9 @@ def test_cmd_get_public_key_and_neighbor_branches():
|
||||
assert "abcdef12:20:4" in out
|
||||
assert "11223344:10:1" in out
|
||||
|
||||
cli.storage_handler = SimpleNamespace(get_neighbors=MagicMock(side_effect=RuntimeError("db fail")))
|
||||
cli.storage_handler = SimpleNamespace(
|
||||
get_neighbors=MagicMock(side_effect=RuntimeError("db fail"))
|
||||
)
|
||||
assert cli._cmd_neighbors().startswith("Error:")
|
||||
|
||||
|
||||
@@ -179,7 +185,7 @@ def test_cmd_set_updates_and_validation_errors():
|
||||
def test_misc_commands_and_routes():
|
||||
cli = MeshCLI("/tmp/cfg.yaml", _base_config(), _cfg_mgr(), enable_regions=True)
|
||||
|
||||
assert cli._cmd_region("region") .startswith("Error:")
|
||||
assert cli._cmd_region("region").startswith("Error:")
|
||||
assert cli._cmd_region("region load us").startswith("Error:")
|
||||
assert cli._cmd_region("region save").startswith("Error:")
|
||||
assert cli._cmd_region("region remove x").startswith("Error:")
|
||||
|
||||
@@ -53,9 +53,11 @@ async def test_path_helper_updates_client_out_path_on_valid_decrypt():
|
||||
helper = PathHelper(acl_dict={0x11: acl})
|
||||
|
||||
# Payload: dest(0x11), src(0x22), mac+data...
|
||||
packet = _PathPacket(payload=b"\x11\x22\xAA\xBB\xCC")
|
||||
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"):
|
||||
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
|
||||
@@ -71,14 +73,17 @@ async def test_path_helper_returns_false_for_non_matching_or_invalid_inputs():
|
||||
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
|
||||
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
|
||||
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
|
||||
assert await helper.process_path_packet(_PathPacket(payload=b"\x11\x22\xaa\xbb")) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -173,9 +178,24 @@ def test_protocol_request_access_list_admin_and_reserved_rules():
|
||||
|
||||
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},
|
||||
"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(
|
||||
@@ -209,10 +229,16 @@ def test_protocol_request_owner_info_fallback_version():
|
||||
|
||||
|
||||
def test_text_helper_cli_prefix_and_admin_permission_checks():
|
||||
acl = _FakeACL([
|
||||
_FakeClient(pubkey=bytes([0x21]) + b"x" * 31, shared_secret=b"k" * 32, permissions=0x02),
|
||||
_FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32, permissions=0x01),
|
||||
])
|
||||
acl = _FakeACL(
|
||||
[
|
||||
_FakeClient(
|
||||
pubkey=bytes([0x21]) + b"x" * 31, shared_secret=b"k" * 32, permissions=0x02
|
||||
),
|
||||
_FakeClient(
|
||||
pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32, permissions=0x01
|
||||
),
|
||||
]
|
||||
)
|
||||
helper = TextHelper(identity_manager=MagicMock(), acl_dict={0x41: acl})
|
||||
|
||||
assert helper._is_cli_command("get status") is True
|
||||
@@ -301,11 +327,16 @@ def test_text_helper_register_identity_room_server_without_event_loop_is_safe():
|
||||
with (
|
||||
patch("repeater.handler_helpers.text.TextMessageHandler", return_value=MagicMock()),
|
||||
patch("repeater.handler_helpers.text.RoomServer") as room_server_cls,
|
||||
patch("repeater.handler_helpers.text.asyncio.get_running_loop", side_effect=RuntimeError("no loop")),
|
||||
patch(
|
||||
"repeater.handler_helpers.text.asyncio.get_running_loop",
|
||||
side_effect=RuntimeError("no loop"),
|
||||
),
|
||||
):
|
||||
room_server_obj = MagicMock()
|
||||
room_server_cls.return_value = room_server_obj
|
||||
helper.register_identity("room-a", identity, identity_type="room_server", radio_config={"max_posts": 2})
|
||||
helper.register_identity(
|
||||
"room-a", identity, identity_type="room_server", radio_config={"max_posts": 2}
|
||||
)
|
||||
|
||||
assert 0x34 in helper.room_servers
|
||||
|
||||
@@ -313,7 +344,9 @@ def test_text_helper_register_identity_room_server_without_event_loop_is_safe():
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_helper_send_cli_reply_uses_direct_path_from_client():
|
||||
helper = TextHelper(identity_manager=MagicMock(), packet_injector=AsyncMock())
|
||||
sender = _FakeClient(pubkey=bytes([0x99]) + b"x" * 31, shared_secret=b"s" * 32, permissions=0x02)
|
||||
sender = _FakeClient(
|
||||
pubkey=bytes([0x99]) + b"x" * 31, shared_secret=b"s" * 32, permissions=0x02
|
||||
)
|
||||
sender.out_path = bytearray([0xAA, 0xBB])
|
||||
sender.out_path_len = 2
|
||||
helper.acl_dict = {0x10: _FakeACL([sender])}
|
||||
@@ -332,6 +365,6 @@ async def test_text_helper_send_cli_reply_uses_direct_path_from_client():
|
||||
handler_info={"identity": _FakeId(bytes([0x10]) + b"i" * 31)},
|
||||
)
|
||||
|
||||
assert bytes(reply_packet.path) == b"\xAA\xBB"
|
||||
assert bytes(reply_packet.path) == b"\xaa\xbb"
|
||||
assert reply_packet.path_len == 2
|
||||
helper._send_packet.assert_awaited_once_with(reply_packet, wait_for_ack=False)
|
||||
|
||||
@@ -122,7 +122,7 @@ async def test_room_server_push_post_to_client_success_direct_route_sets_path_an
|
||||
rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock())
|
||||
rs._handle_ack_received = AsyncMock()
|
||||
|
||||
client = _FakeClient(pubkey=b"E" * 32, out_path=b"\xAA\xBB", out_path_len=2)
|
||||
client = _FakeClient(pubkey=b"E" * 32, out_path=b"\xaa\xbb", out_path_len=2)
|
||||
post = {
|
||||
"author_pubkey": (b"F" * 32).hex(),
|
||||
"message_text": "payload",
|
||||
@@ -131,17 +131,28 @@ async def test_room_server_push_post_to_client_success_direct_route_sets_path_an
|
||||
|
||||
packet = SimpleNamespace(path=bytearray(), path_len=0)
|
||||
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=packet),
|
||||
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=packet,
|
||||
),
|
||||
):
|
||||
ok = await rs.push_post_to_client(client, post)
|
||||
|
||||
assert ok is True
|
||||
assert bytes(packet.path) == b"\xAA\xBB"
|
||||
assert bytes(packet.path) == b"\xaa\xbb"
|
||||
assert packet.path_len == 2
|
||||
injector.assert_awaited_once_with(packet, wait_for_ack=True)
|
||||
rs._handle_ack_received.assert_awaited_once_with(client.id.get_public_key(), post["post_timestamp"])
|
||||
rs._handle_ack_received.assert_awaited_once_with(
|
||||
client.id.get_public_key(), post["post_timestamp"]
|
||||
)
|
||||
rs.global_limiter.release.assert_called_once()
|
||||
|
||||
|
||||
@@ -169,9 +180,18 @@ async def test_room_server_push_post_to_client_backoff_skip_and_timeout_path():
|
||||
# Out of backoff and send fails -> 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)),
|
||||
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)
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ 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):
|
||||
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)
|
||||
@@ -86,7 +88,7 @@ async def test_trace_helper_process_sets_pending_ping_and_forwards():
|
||||
tag = 77
|
||||
evt = helper.register_ping(tag, 0x42)
|
||||
|
||||
packet = DummyPacket(path=b"\x01", payload=b"\xAA\xBB\xCC")
|
||||
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)
|
||||
@@ -112,7 +114,9 @@ async def test_trace_helper_process_sets_pending_ping_and_forwards():
|
||||
|
||||
@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())
|
||||
helper = TraceHelper(
|
||||
local_hash=0x42, local_identity=FakeIdentity(0x42), repeater_handler=MagicMock()
|
||||
)
|
||||
tag = 9
|
||||
evt = helper.register_ping(tag, 0x42)
|
||||
|
||||
@@ -158,7 +162,9 @@ async def test_trace_helper_forward_trace_packet_updates_recent_record_and_injec
|
||||
|
||||
|
||||
def test_trace_helper_cleanup_stale_pings():
|
||||
helper = TraceHelper(local_hash=0x42, local_identity=FakeIdentity(0x42), repeater_handler=MagicMock())
|
||||
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},
|
||||
@@ -171,13 +177,19 @@ def test_trace_helper_cleanup_stale_pings():
|
||||
|
||||
|
||||
def test_discovery_request_filter_match_and_mismatch():
|
||||
helper = DiscoveryHelper(local_identity=FakeIdentity(0x42), packet_injector=AsyncMock(), node_type=2)
|
||||
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._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._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)
|
||||
|
||||
|
||||
@@ -185,7 +197,9 @@ 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._on_discovery_request(
|
||||
{"tag": 7, "filter": 0x04, "prefix_only": False, "snr": 0.0, "rssi": -90}
|
||||
)
|
||||
|
||||
helper._send_discovery_response.assert_not_called()
|
||||
|
||||
@@ -205,7 +219,10 @@ async def test_discovery_send_packet_async_success_failure_and_exception():
|
||||
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()):
|
||||
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)
|
||||
|
||||
|
||||
@@ -237,13 +254,19 @@ def test_login_register_identity_repeater_creates_acl_and_handler():
|
||||
|
||||
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,
|
||||
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"}}},
|
||||
config={
|
||||
"repeater": {
|
||||
"security": {"max_clients": 3, "admin_password": "a", "guest_password": "g"}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
acl_cls.assert_called_once()
|
||||
|
||||
@@ -31,7 +31,9 @@ def test_doc_endpoint_routes_and_openapi_json_paths(monkeypatch):
|
||||
assert doc.index() == "docs-html"
|
||||
assert doc.docs() == "docs-html"
|
||||
|
||||
monkeypatch.setattr(cherrypy, "response", SimpleNamespace(headers={}, status=200), raising=False)
|
||||
monkeypatch.setattr(
|
||||
cherrypy, "response", SimpleNamespace(headers={}, status=200), raising=False
|
||||
)
|
||||
|
||||
# success path
|
||||
monkeypatch.setattr("builtins.open", lambda *args, **kwargs: io.StringIO("openapi: 3.0.0\n"))
|
||||
@@ -90,7 +92,6 @@ def test_stats_app_index_error_paths(monkeypatch, tmp_path):
|
||||
with pytest.raises(cherrypy.HTTPError):
|
||||
app.index()
|
||||
|
||||
|
||||
# Force generic open() exception branch
|
||||
def _explode(*_args, **_kwargs):
|
||||
raise RuntimeError("boom")
|
||||
@@ -107,15 +108,21 @@ def test_http_server_utility_methods(monkeypatch, tmp_path):
|
||||
self.token_manager = object()
|
||||
|
||||
monkeypatch.setattr(hs.HTTPStatsServer, "_init_auth_handlers", _fake_init_auth)
|
||||
monkeypatch.setattr(hs, "StatsApp", lambda *args, **kwargs: SimpleNamespace(api=SimpleNamespace(config_manager=object())))
|
||||
monkeypatch.setattr(
|
||||
hs,
|
||||
"StatsApp",
|
||||
lambda *args, **kwargs: SimpleNamespace(api=SimpleNamespace(config_manager=object())),
|
||||
)
|
||||
monkeypatch.setattr(hs, "AuthEndpoints", lambda *args, **kwargs: object())
|
||||
monkeypatch.setattr(hs, "DocEndpoint", lambda *_args, **_kwargs: object())
|
||||
|
||||
server = hs.HTTPStatsServer(config={"web": {"cors_enabled": False}}, config_path=str(Path(tmp_path) / "cfg.yml"))
|
||||
server = hs.HTTPStatsServer(
|
||||
config={"web": {"cors_enabled": False}}, config_path=str(Path(tmp_path) / "cfg.yml")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cherrypy, "response", SimpleNamespace(headers={}), raising=False)
|
||||
out = server._json_error_handler(401, "no", "", "")
|
||||
assert "\"success\": false" in out
|
||||
assert '"success": false' in out
|
||||
|
||||
install_called = {"v": False}
|
||||
monkeypatch.setattr(hs.cherrypy_cors, "install", lambda: install_called.__setitem__("v", True))
|
||||
@@ -123,6 +130,11 @@ def test_http_server_utility_methods(monkeypatch, tmp_path):
|
||||
assert install_called["v"] is True
|
||||
|
||||
exited = {"v": False}
|
||||
monkeypatch.setattr(cherrypy, "engine", SimpleNamespace(exit=lambda: exited.__setitem__("v", True)), raising=False)
|
||||
monkeypatch.setattr(
|
||||
cherrypy,
|
||||
"engine",
|
||||
SimpleNamespace(exit=lambda: exited.__setitem__("v", True)),
|
||||
raising=False,
|
||||
)
|
||||
server.stop()
|
||||
assert exited["v"] is True
|
||||
|
||||
@@ -6,7 +6,7 @@ from repeater.identity_manager import IdentityManager
|
||||
|
||||
|
||||
class _FakeIdentity:
|
||||
def __init__(self, pubkey: bytes, addr: bytes = b"\xAA\xBB"):
|
||||
def __init__(self, pubkey: bytes, addr: bytes = b"\xaa\xbb"):
|
||||
self._pubkey = pubkey
|
||||
self._addr = addr
|
||||
|
||||
@@ -194,7 +194,7 @@ def test_cli_set_commands_apply_and_validate_ranges():
|
||||
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("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"
|
||||
@@ -251,7 +251,7 @@ def test_cli_setperm_region_neighbor_tempradio_log_paths():
|
||||
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 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"
|
||||
|
||||
@@ -28,11 +28,13 @@ def test_generate_meshcore_keypair_clamps_scalar_and_shapes_output():
|
||||
|
||||
def _fake_scalarmult(scalar_bytes):
|
||||
captured["scalar"] = scalar_bytes
|
||||
return b"\xAA" * 32
|
||||
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),
|
||||
patch(
|
||||
"repeater.keygen.crypto_scalarmult_ed25519_base_noclamp", side_effect=_fake_scalarmult
|
||||
),
|
||||
):
|
||||
pub, priv = keygen.generate_meshcore_keypair()
|
||||
|
||||
@@ -42,7 +44,7 @@ def test_generate_meshcore_keypair_clamps_scalar_and_shapes_output():
|
||||
expected[31] &= 63
|
||||
expected[31] |= 64
|
||||
|
||||
assert pub == b"\xAA" * 32
|
||||
assert pub == b"\xaa" * 32
|
||||
assert len(pub) == 32
|
||||
assert len(priv) == 64
|
||||
assert captured["scalar"] == bytes(expected)
|
||||
|
||||
@@ -166,7 +166,7 @@ async def test_deliver_control_data_filters_non_discovery_and_pushes_valid():
|
||||
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)
|
||||
await daemon.deliver_control_data(1.0, -70, 2, b"\xaa\xbb", payload)
|
||||
fs_ok.push_control_data.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ async def test_trace_complete_for_companions_requires_valid_lengths():
|
||||
fs.push_trace_data_async.assert_not_awaited()
|
||||
|
||||
parsed = {
|
||||
"trace_path_bytes": b"\xAA\xBB\xCC\xDD",
|
||||
"trace_path_bytes": b"\xaa\xbb\xcc\xdd",
|
||||
"flags": 0,
|
||||
"tag": 1,
|
||||
"auth_code": 2,
|
||||
@@ -218,7 +218,9 @@ async def test_send_advert_branches_and_success_path():
|
||||
# Missing dispatcher/local identity
|
||||
assert await daemon.send_advert() is False
|
||||
|
||||
daemon.dispatcher = SimpleNamespace(send_packet=AsyncMock(), packet_filter=SimpleNamespace(track_packet=MagicMock()))
|
||||
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
|
||||
@@ -229,7 +231,7 @@ async def test_send_advert_branches_and_success_path():
|
||||
get_repeater_location=lambda: {"latitude": 9.1, "longitude": 8.2, "source": "gps"}
|
||||
)
|
||||
|
||||
packet = SimpleNamespace(calculate_packet_hash=lambda: b"\xAB" * 16)
|
||||
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()
|
||||
|
||||
@@ -254,10 +256,14 @@ def test_update_repeater_location_from_gps_branches():
|
||||
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"}))
|
||||
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}))
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class _FakeLocalIdentity:
|
||||
return bytes([self._seed[0]]) + (b"P" * 31)
|
||||
|
||||
def get_address_bytes(self):
|
||||
return b"\xAB\xCD"
|
||||
return b"\xab\xcd"
|
||||
|
||||
|
||||
def _base_config():
|
||||
@@ -60,7 +60,9 @@ async def test_run_starts_http_and_handles_dispatcher_cancelled_gracefully():
|
||||
|
||||
async def _init_stub():
|
||||
daemon.local_identity = SimpleNamespace(get_public_key=lambda: b"\x22" * 32)
|
||||
daemon.dispatcher = SimpleNamespace(run_forever=AsyncMock(side_effect=asyncio.CancelledError()))
|
||||
daemon.dispatcher = SimpleNamespace(
|
||||
run_forever=AsyncMock(side_effect=asyncio.CancelledError())
|
||||
)
|
||||
|
||||
daemon.initialize = _init_stub
|
||||
|
||||
|
||||
@@ -75,9 +75,7 @@ def _attach_capturing_client(conn) -> list:
|
||||
captured: list = []
|
||||
|
||||
def _fake_publish(topic, payload, retain=False, qos=0):
|
||||
captured.append(
|
||||
{"topic": topic, "payload": payload, "retain": retain, "qos": qos}
|
||||
)
|
||||
captured.append({"topic": topic, "payload": payload, "retain": retain, "qos": qos})
|
||||
return None
|
||||
|
||||
conn._running = True
|
||||
@@ -150,9 +148,7 @@ def test_mqtt_published_packet_carries_semtech_duration_end_to_end():
|
||||
payload_dict = json.loads(publish["payload"])
|
||||
assert payload_dict["duration"] == expected_duration
|
||||
assert payload_dict["duration"] != "0", "duration must not be hard-coded zero"
|
||||
assert 0 < int(payload_dict["duration"]) < 10_000, (
|
||||
"duration should be a sane time-on-air in ms"
|
||||
)
|
||||
assert 0 < int(payload_dict["duration"]) < 10_000, "duration should be a sane time-on-air in ms"
|
||||
|
||||
# Sanity: other key fields flowed through correctly.
|
||||
assert payload_dict["origin"] == "test-node"
|
||||
|
||||
@@ -19,7 +19,7 @@ def _semtech_airtime_ms(payload_len: int, sf: int, bw_hz: int, cr: int, preamble
|
||||
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_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)
|
||||
|
||||
+19
-25
@@ -38,11 +38,11 @@ from repeater.packet_router import (
|
||||
_is_direct_final_hop,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal daemon stub
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_daemon():
|
||||
"""Minimal daemon that satisfies PacketRouter without touching hardware."""
|
||||
daemon = MagicMock()
|
||||
@@ -84,8 +84,8 @@ def _make_bridge():
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
# ── 1. Cap enforcement ──────────────────────────────────────────────────
|
||||
|
||||
async def test_cap_drops_packets_when_full(self):
|
||||
@@ -100,7 +100,7 @@ class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
barrier = asyncio.Event()
|
||||
|
||||
async def slow_route(pkt):
|
||||
await barrier.wait() # blocks until we release
|
||||
await barrier.wait() # blocks until we release
|
||||
|
||||
routed = []
|
||||
|
||||
@@ -115,7 +115,7 @@ class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
# Fill the cap
|
||||
for _ in range(3):
|
||||
await router.enqueue(_make_packet())
|
||||
await asyncio.sleep(0.05) # let queue drain into tasks
|
||||
await asyncio.sleep(0.05) # let queue drain into tasks
|
||||
self.assertEqual(router._in_flight, 3)
|
||||
|
||||
# These should be dropped
|
||||
@@ -123,12 +123,10 @@ class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
await router.enqueue(_make_packet())
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
self.assertEqual(router._in_flight, 3,
|
||||
"In-flight count exceeded cap")
|
||||
self.assertEqual(router._cap_drop_count, 5,
|
||||
"Expected 5 cap-drops, got different count")
|
||||
self.assertEqual(router._in_flight, 3, "In-flight count exceeded cap")
|
||||
self.assertEqual(router._cap_drop_count, 5, "Expected 5 cap-drops, got different count")
|
||||
|
||||
barrier.set() # release blocked tasks
|
||||
barrier.set() # release blocked tasks
|
||||
await router.stop()
|
||||
|
||||
# ── 2. Drop counter ─────────────────────────────────────────────────────
|
||||
@@ -218,7 +216,7 @@ class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def slow_route(pkt):
|
||||
started.set()
|
||||
await asyncio.sleep(0.2) # finishes well within 5 s timeout
|
||||
await asyncio.sleep(0.2) # finishes well within 5 s timeout
|
||||
completed.append(pkt)
|
||||
|
||||
router._route_packet = slow_route
|
||||
@@ -233,8 +231,7 @@ class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
await router.stop()
|
||||
|
||||
# Task should have completed, not been cancelled
|
||||
self.assertEqual(len(completed), 1,
|
||||
"In-flight task was cancelled instead of drained")
|
||||
self.assertEqual(len(completed), 1, "In-flight task was cancelled instead of drained")
|
||||
|
||||
async def test_stop_cancels_tasks_that_exceed_timeout(self):
|
||||
"""
|
||||
@@ -250,16 +247,13 @@ class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
async def hanging_route(pkt):
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.sleep(999) # will not finish within 5 s
|
||||
await asyncio.sleep(999) # will not finish within 5 s
|
||||
except asyncio.CancelledError:
|
||||
cancelled.append(pkt)
|
||||
raise
|
||||
|
||||
router._route_packet = hanging_route
|
||||
|
||||
# Patch the timeout to 0.1 s so the test runs fast
|
||||
original_stop = router.stop
|
||||
|
||||
async def fast_stop():
|
||||
router.running = False
|
||||
if router.router_task:
|
||||
@@ -283,8 +277,7 @@ class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
await router.stop()
|
||||
|
||||
self.assertEqual(len(cancelled), 1,
|
||||
"Hanging task was not cancelled on shutdown")
|
||||
self.assertEqual(len(cancelled), 1, "Hanging task was not cancelled on shutdown")
|
||||
|
||||
# ── 4. Route-tasks set stays in sync with counter ───────────────────────
|
||||
|
||||
@@ -296,7 +289,7 @@ class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
router = PacketRouter(_make_daemon())
|
||||
|
||||
async def fast_route(pkt):
|
||||
await asyncio.sleep(0) # yield, then done
|
||||
await asyncio.sleep(0) # yield, then done
|
||||
|
||||
router._route_packet = fast_route
|
||||
|
||||
@@ -308,10 +301,10 @@ class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
# Give tasks time to complete
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
self.assertEqual(len(router._route_tasks), 0,
|
||||
"_route_tasks not cleaned up after task completion")
|
||||
self.assertEqual(router._in_flight, 0,
|
||||
"_in_flight counter not back to 0 after completion")
|
||||
self.assertEqual(
|
||||
len(router._route_tasks), 0, "_route_tasks not cleaned up after task completion"
|
||||
)
|
||||
self.assertEqual(router._in_flight, 0, "_in_flight counter not back to 0 after completion")
|
||||
|
||||
await router.stop()
|
||||
|
||||
@@ -339,8 +332,9 @@ class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
self.assertEqual(
|
||||
router._in_flight, len(router._route_tasks),
|
||||
f"Counter ({router._in_flight}) != set size ({len(router._route_tasks)})"
|
||||
router._in_flight,
|
||||
len(router._route_tasks),
|
||||
f"Counter ({router._in_flight}) != set size ({len(router._route_tasks)})",
|
||||
)
|
||||
|
||||
barrier.set()
|
||||
|
||||
@@ -11,11 +11,12 @@ rather than mocking the protocol layer. Covers:
|
||||
- PacketBuilder.create_trace payload structure + TraceHandler parsing
|
||||
- Max-hop boundary enforcement per hash size
|
||||
"""
|
||||
|
||||
import struct
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pymc_core.node.handlers.trace import TraceHandler
|
||||
from pymc_core.protocol import Packet, PacketBuilder, PathUtils
|
||||
from pymc_core.protocol.constants import (
|
||||
MAX_PATH_SIZE,
|
||||
@@ -25,8 +26,6 @@ from pymc_core.protocol.constants import (
|
||||
ROUTE_TYPE_DIRECT,
|
||||
ROUTE_TYPE_FLOOD,
|
||||
)
|
||||
from pymc_core.node.handlers.trace import TraceHandler
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -35,8 +34,9 @@ from pymc_core.node.handlers.trace import TraceHandler
|
||||
LOCAL_HASH_BYTES = bytes([0xAB, 0xCD, 0xEF])
|
||||
|
||||
|
||||
def _make_flood_packet(path_bytes: bytes, hash_size: int, hash_count: int,
|
||||
payload: bytes = b"\x01\x02\x03\x04") -> Packet:
|
||||
def _make_flood_packet(
|
||||
path_bytes: bytes, hash_size: int, hash_count: int, payload: bytes = b"\x01\x02\x03\x04"
|
||||
) -> Packet:
|
||||
"""Create a real flood Packet with the given multi-byte path encoding."""
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_FLOOD
|
||||
@@ -47,8 +47,9 @@ def _make_flood_packet(path_bytes: bytes, hash_size: int, hash_count: int,
|
||||
return pkt
|
||||
|
||||
|
||||
def _make_direct_packet(path_bytes: bytes, hash_size: int, hash_count: int,
|
||||
payload: bytes = b"\x01\x02\x03\x04") -> Packet:
|
||||
def _make_direct_packet(
|
||||
path_bytes: bytes, hash_size: int, hash_count: int, payload: bytes = b"\x01\x02\x03\x04"
|
||||
) -> Packet:
|
||||
"""Create a real direct-routed Packet."""
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_DIRECT
|
||||
@@ -87,8 +88,12 @@ def _make_handler(path_hash_mode=0, local_hash_bytes=None):
|
||||
}
|
||||
dispatcher = MagicMock()
|
||||
dispatcher.radio = MagicMock(
|
||||
spreading_factor=8, bandwidth=125000, coding_rate=8,
|
||||
preamble_length=17, frequency=915000000, tx_power=14,
|
||||
spreading_factor=8,
|
||||
bandwidth=125000,
|
||||
coding_rate=8,
|
||||
preamble_length=17,
|
||||
frequency=915000000,
|
||||
tx_power=14,
|
||||
)
|
||||
dispatcher.local_identity = MagicMock()
|
||||
with (
|
||||
@@ -96,6 +101,7 @@ def _make_handler(path_hash_mode=0, local_hash_bytes=None):
|
||||
patch("repeater.engine.RepeaterHandler._start_background_tasks"),
|
||||
):
|
||||
from repeater.engine import RepeaterHandler
|
||||
|
||||
h = RepeaterHandler(config, dispatcher, lhb[0], local_hash_bytes=lhb)
|
||||
return h
|
||||
|
||||
@@ -114,11 +120,20 @@ class TestPathUtilsRoundTrip:
|
||||
assert PathUtils.get_path_hash_size(encoded) == hash_size
|
||||
assert PathUtils.get_path_hash_count(encoded) == 0
|
||||
|
||||
@pytest.mark.parametrize("hash_size,count", [
|
||||
(1, 1), (1, 10), (1, 63),
|
||||
(2, 1), (2, 15), (2, 32),
|
||||
(3, 1), (3, 10), (3, 21),
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"hash_size,count",
|
||||
[
|
||||
(1, 1),
|
||||
(1, 10),
|
||||
(1, 63),
|
||||
(2, 1),
|
||||
(2, 15),
|
||||
(2, 32),
|
||||
(3, 1),
|
||||
(3, 10),
|
||||
(3, 21),
|
||||
],
|
||||
)
|
||||
def test_encode_decode_round_trip(self, hash_size, count):
|
||||
encoded = PathUtils.encode_path_len(hash_size, count)
|
||||
assert PathUtils.get_path_hash_size(encoded) == hash_size
|
||||
@@ -186,16 +201,16 @@ class TestPacketMultiBytePath:
|
||||
"""Verify Packet write_to/read_from preserves multi-byte path encoding."""
|
||||
|
||||
def test_1_byte_path_round_trip(self):
|
||||
pkt = _make_flood_packet(b"\xAA\xBB\xCC", hash_size=1, hash_count=3)
|
||||
pkt = _make_flood_packet(b"\xaa\xbb\xcc", hash_size=1, hash_count=3)
|
||||
wire = pkt.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_size() == 1
|
||||
assert pkt2.get_path_hash_count() == 3
|
||||
assert bytes(pkt2.path) == b"\xAA\xBB\xCC"
|
||||
assert bytes(pkt2.path) == b"\xaa\xbb\xcc"
|
||||
|
||||
def test_2_byte_path_round_trip(self):
|
||||
path = b"\xAA\xBB\xCC\xDD" # 2 hops of 2 bytes
|
||||
path = b"\xaa\xbb\xcc\xdd" # 2 hops of 2 bytes
|
||||
pkt = _make_flood_packet(path, hash_size=2, hash_count=2)
|
||||
wire = pkt.write_to()
|
||||
pkt2 = Packet()
|
||||
@@ -205,7 +220,7 @@ class TestPacketMultiBytePath:
|
||||
assert bytes(pkt2.path) == path
|
||||
|
||||
def test_3_byte_path_round_trip(self):
|
||||
path = b"\xAA\xBB\xCC\xDD\xEE\xFF" # 2 hops of 3 bytes
|
||||
path = b"\xaa\xbb\xcc\xdd\xee\xff" # 2 hops of 3 bytes
|
||||
pkt = _make_flood_packet(path, hash_size=3, hash_count=2)
|
||||
wire = pkt.write_to()
|
||||
pkt2 = Packet()
|
||||
@@ -225,7 +240,7 @@ class TestPacketMultiBytePath:
|
||||
|
||||
def test_payload_preserved_after_multibyte_path(self):
|
||||
"""Payload bytes after a multi-byte path are correctly sliced."""
|
||||
payload = b"\xDE\xAD\xBE\xEF"
|
||||
payload = b"\xde\xad\xbe\xef"
|
||||
path = b"\x11\x22\x33\x44\x55\x66"
|
||||
pkt = _make_flood_packet(path, hash_size=3, hash_count=2, payload=payload)
|
||||
wire = pkt.write_to()
|
||||
@@ -248,28 +263,26 @@ class TestPacketGetPathHashes:
|
||||
"""Verify Packet.get_path_hashes splits path into per-hop byte entries."""
|
||||
|
||||
def test_1_byte_hashes(self):
|
||||
pkt = _make_flood_packet(b"\xAA\xBB\xCC", hash_size=1, hash_count=3)
|
||||
pkt = _make_flood_packet(b"\xaa\xbb\xcc", hash_size=1, hash_count=3)
|
||||
hashes = pkt.get_path_hashes()
|
||||
assert hashes == [b"\xAA", b"\xBB", b"\xCC"]
|
||||
assert hashes == [b"\xaa", b"\xbb", b"\xcc"]
|
||||
|
||||
def test_2_byte_hashes(self):
|
||||
pkt = _make_flood_packet(b"\xAA\xBB\xCC\xDD", hash_size=2, hash_count=2)
|
||||
pkt = _make_flood_packet(b"\xaa\xbb\xcc\xdd", hash_size=2, hash_count=2)
|
||||
hashes = pkt.get_path_hashes()
|
||||
assert hashes == [b"\xAA\xBB", b"\xCC\xDD"]
|
||||
assert hashes == [b"\xaa\xbb", b"\xcc\xdd"]
|
||||
|
||||
def test_3_byte_hashes(self):
|
||||
pkt = _make_flood_packet(
|
||||
b"\xAA\xBB\xCC\xDD\xEE\xFF", hash_size=3, hash_count=2
|
||||
)
|
||||
pkt = _make_flood_packet(b"\xaa\xbb\xcc\xdd\xee\xff", hash_size=3, hash_count=2)
|
||||
hashes = pkt.get_path_hashes()
|
||||
assert hashes == [b"\xAA\xBB\xCC", b"\xDD\xEE\xFF"]
|
||||
assert hashes == [b"\xaa\xbb\xcc", b"\xdd\xee\xff"]
|
||||
|
||||
def test_empty_path(self):
|
||||
pkt = _make_flood_packet(b"", hash_size=2, hash_count=0)
|
||||
assert pkt.get_path_hashes() == []
|
||||
|
||||
def test_hashes_hex_output(self):
|
||||
pkt = _make_flood_packet(b"\x0A\x0B\x0C\x0D", hash_size=2, hash_count=2)
|
||||
pkt = _make_flood_packet(b"\x0a\x0b\x0c\x0d", hash_size=2, hash_count=2)
|
||||
hex_hashes = pkt.get_path_hashes_hex()
|
||||
assert hex_hashes == ["0A0B", "0C0D"]
|
||||
|
||||
@@ -289,7 +302,7 @@ class TestPacketApplyPathHashMode:
|
||||
|
||||
def test_apply_mode_skips_nonzero_hop_count(self):
|
||||
"""Mode should not be re-applied if path already has hops."""
|
||||
pkt = _make_flood_packet(b"\xAA\xBB", hash_size=2, hash_count=1)
|
||||
pkt = _make_flood_packet(b"\xaa\xbb", hash_size=2, hash_count=1)
|
||||
original_path_len = pkt.path_len
|
||||
pkt.apply_path_hash_mode(0) # try to override to 1-byte
|
||||
assert pkt.path_len == original_path_len # unchanged
|
||||
@@ -314,7 +327,7 @@ class TestPacketSetPath:
|
||||
def test_set_path_with_encoded_len(self):
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_FLOOD
|
||||
path = b"\xAA\xBB\xCC\xDD"
|
||||
path = b"\xaa\xbb\xcc\xdd"
|
||||
encoded = PathUtils.encode_path_len(2, 2)
|
||||
pkt.set_path(path, path_len_encoded=encoded)
|
||||
assert pkt.get_path_hash_size() == 2
|
||||
@@ -325,7 +338,7 @@ class TestPacketSetPath:
|
||||
"""Without explicit path_len_encoded, defaults to 1-byte hash_size."""
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_FLOOD
|
||||
pkt.set_path(b"\xAA\xBB\xCC")
|
||||
pkt.set_path(b"\xaa\xbb\xcc")
|
||||
assert pkt.get_path_hash_size() == 1
|
||||
assert pkt.get_path_hash_count() == 3
|
||||
|
||||
@@ -347,7 +360,7 @@ class TestFloodForwardMultiByte:
|
||||
assert result.get_path_hash_size() == 1
|
||||
hashes = result.get_path_hashes()
|
||||
assert hashes[0] == b"\x11"
|
||||
assert hashes[1] == b"\xAB" # first byte of local_hash_bytes
|
||||
assert hashes[1] == b"\xab" # first byte of local_hash_bytes
|
||||
|
||||
def test_2_byte_mode_appends_two_bytes(self):
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
@@ -358,7 +371,7 @@ class TestFloodForwardMultiByte:
|
||||
assert result.get_path_hash_size() == 2
|
||||
hashes = result.get_path_hashes()
|
||||
assert hashes[0] == b"\x11\x22"
|
||||
assert hashes[1] == b"\xAB\xCD"
|
||||
assert hashes[1] == b"\xab\xcd"
|
||||
|
||||
def test_3_byte_mode_appends_three_bytes(self):
|
||||
h = _make_handler(path_hash_mode=2, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
@@ -369,7 +382,7 @@ class TestFloodForwardMultiByte:
|
||||
assert result.get_path_hash_size() == 3
|
||||
hashes = result.get_path_hashes()
|
||||
assert hashes[0] == b"\x11\x22\x33"
|
||||
assert hashes[1] == b"\xAB\xCD\xEF"
|
||||
assert hashes[1] == b"\xab\xcd\xef"
|
||||
|
||||
def test_empty_path_gets_local_hash_appended(self):
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
@@ -378,7 +391,7 @@ class TestFloodForwardMultiByte:
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 1
|
||||
hashes = result.get_path_hashes()
|
||||
assert hashes[0] == b"\xAB\xCD"
|
||||
assert hashes[0] == b"\xab\xcd"
|
||||
|
||||
def test_path_len_re_encoded_after_forward(self):
|
||||
"""After appending, path_len byte should encode (hash_size, count+1)."""
|
||||
@@ -400,7 +413,7 @@ class TestFloodForwardMultiByte:
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_size() == 2
|
||||
assert pkt2.get_path_hash_count() == 2
|
||||
assert pkt2.get_path_hashes() == [b"\x11\x22", b"\xAB\xCD"]
|
||||
assert pkt2.get_path_hashes() == [b"\x11\x22", b"\xab\xcd"]
|
||||
|
||||
def test_flood_rejects_at_max_hops_2_byte(self):
|
||||
"""At 32 hops (2-byte mode), flood_forward should drop the packet."""
|
||||
@@ -449,7 +462,7 @@ class TestDirectForwardMultiByte:
|
||||
def test_1_byte_match_strips_first_hop(self):
|
||||
h = _make_handler(path_hash_mode=0, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Path: [0xAB, 0x11] — first hop matches local_hash_bytes[0]
|
||||
pkt = _make_direct_packet(b"\xAB\x11", hash_size=1, hash_count=2)
|
||||
pkt = _make_direct_packet(b"\xab\x11", hash_size=1, hash_count=2)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 1
|
||||
@@ -459,7 +472,7 @@ class TestDirectForwardMultiByte:
|
||||
def test_2_byte_match_strips_first_hop(self):
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Path: [0xAB,0xCD, 0x11,0x22] — first 2-byte hop matches local_hash_bytes[:2]
|
||||
pkt = _make_direct_packet(b"\xAB\xCD\x11\x22", hash_size=2, hash_count=2)
|
||||
pkt = _make_direct_packet(b"\xab\xcd\x11\x22", hash_size=2, hash_count=2)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 1
|
||||
@@ -468,9 +481,7 @@ class TestDirectForwardMultiByte:
|
||||
|
||||
def test_3_byte_match_strips_first_hop(self):
|
||||
h = _make_handler(path_hash_mode=2, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_direct_packet(
|
||||
b"\xAB\xCD\xEF\x11\x22\x33", hash_size=3, hash_count=2
|
||||
)
|
||||
pkt = _make_direct_packet(b"\xab\xcd\xef\x11\x22\x33", hash_size=3, hash_count=2)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 1
|
||||
@@ -480,14 +491,14 @@ class TestDirectForwardMultiByte:
|
||||
def test_2_byte_mismatch_rejects(self):
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Path: [0xFF,0xEE, ...] — first 2-byte hop doesn't match
|
||||
pkt = _make_direct_packet(b"\xFF\xEE\x11\x22", hash_size=2, hash_count=2)
|
||||
pkt = _make_direct_packet(b"\xff\xee\x11\x22", hash_size=2, hash_count=2)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is None
|
||||
assert "not for us" in (pkt.drop_reason or "")
|
||||
|
||||
def test_path_len_re_encoded_after_strip(self):
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_direct_packet(b"\xAB\xCD\x11\x22\x33\x44", hash_size=2, hash_count=3)
|
||||
pkt = _make_direct_packet(b"\xab\xcd\x11\x22\x33\x44", hash_size=2, hash_count=3)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is not None
|
||||
expected_path_len = PathUtils.encode_path_len(2, 2)
|
||||
@@ -496,7 +507,7 @@ class TestDirectForwardMultiByte:
|
||||
def test_last_hop_strips_to_empty(self):
|
||||
"""When only one hop remains and it matches, path becomes empty."""
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_direct_packet(b"\xAB\xCD", hash_size=2, hash_count=1)
|
||||
pkt = _make_direct_packet(b"\xab\xcd", hash_size=2, hash_count=1)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 0
|
||||
@@ -506,7 +517,7 @@ class TestDirectForwardMultiByte:
|
||||
"""After stripping, the packet should serialize/deserialize cleanly."""
|
||||
h = _make_handler(path_hash_mode=2, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_direct_packet(
|
||||
b"\xAB\xCD\xEF\x11\x22\x33\x44\x55\x66", hash_size=3, hash_count=3
|
||||
b"\xab\xcd\xef\x11\x22\x33\x44\x55\x66", hash_size=3, hash_count=3
|
||||
)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is not None
|
||||
@@ -527,7 +538,7 @@ class TestDirectForwardMultiByte:
|
||||
def test_path_too_short_for_hash_size(self):
|
||||
"""If path has fewer bytes than hash_size, reject."""
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_direct_packet(b"\xAB", hash_size=2, hash_count=1)
|
||||
pkt = _make_direct_packet(b"\xab", hash_size=2, hash_count=1)
|
||||
# path has 1 byte but hash_size is 2
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is None
|
||||
@@ -546,7 +557,6 @@ class TestMultiHopForwardingChain:
|
||||
Simulate: node_A floods → repeater_1 forwards → repeater_2 forwards
|
||||
Then the return direct packet strips hops in reverse order.
|
||||
"""
|
||||
node_a_hash = bytes([0x11, 0x22, 0x33])
|
||||
rep1_hash = bytes([0xAA, 0xBB, 0xCC])
|
||||
rep2_hash = bytes([0xDD, 0xEE, 0xFF])
|
||||
|
||||
@@ -576,8 +586,7 @@ class TestMultiHopForwardingChain:
|
||||
# The path should be [rep1, rep2] — direct packet addressed to rep1 first
|
||||
# (Direct packets strip from the front)
|
||||
direct_pkt = _make_direct_packet(
|
||||
bytes(pkt_rx.path), hash_size=2, hash_count=2,
|
||||
payload=b"\xFE\xED"
|
||||
bytes(pkt_rx.path), hash_size=2, hash_count=2, payload=b"\xfe\xed"
|
||||
)
|
||||
|
||||
# repeater_1 strips its hop
|
||||
@@ -616,9 +625,7 @@ class TestTracePacketStructure:
|
||||
def test_create_trace_with_path_bytes(self):
|
||||
"""Trace path goes into payload, not routing path."""
|
||||
path_bytes = [0xAA, 0xBB, 0xCC, 0xDD]
|
||||
pkt = PacketBuilder.create_trace(
|
||||
tag=1, auth_code=2, flags=0, path=path_bytes
|
||||
)
|
||||
pkt = PacketBuilder.create_trace(tag=1, auth_code=2, flags=0, path=path_bytes)
|
||||
payload = pkt.get_payload()
|
||||
assert len(payload) == 9 + 4
|
||||
# Routing path stays empty
|
||||
@@ -699,9 +706,7 @@ class TestTracePayloadParsing:
|
||||
"""Create a trace with PacketBuilder, serialize, deserialize, then parse."""
|
||||
th = self._make_trace_handler()
|
||||
trace_path = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]
|
||||
pkt = PacketBuilder.create_trace(
|
||||
tag=100, auth_code=200, flags=0, path=trace_path
|
||||
)
|
||||
pkt = PacketBuilder.create_trace(tag=100, auth_code=200, flags=0, path=trace_path)
|
||||
wire = pkt.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
@@ -725,10 +730,10 @@ class TestTraceHelperMultibyte:
|
||||
"""TraceHelper._should_forward_trace with 2-byte TRACE payload hashes."""
|
||||
|
||||
def test_should_forward_when_next_hop_matches_pubkey_prefix(self):
|
||||
from repeater.handler_helpers.trace import TraceHelper
|
||||
|
||||
from pymc_core.protocol import LocalIdentity
|
||||
|
||||
from repeater.handler_helpers.trace import TraceHelper
|
||||
|
||||
identity = LocalIdentity()
|
||||
pub = bytes(identity.get_public_key())
|
||||
rh = MagicMock()
|
||||
@@ -747,10 +752,10 @@ class TestTraceHelperMultibyte:
|
||||
assert th._should_forward_trace(pkt, trace_bytes, flags, hash_width)
|
||||
|
||||
def test_should_not_forward_when_next_hop_mismatch(self):
|
||||
from repeater.handler_helpers.trace import TraceHelper
|
||||
|
||||
from pymc_core.protocol import LocalIdentity
|
||||
|
||||
from repeater.handler_helpers.trace import TraceHelper
|
||||
|
||||
identity = LocalIdentity()
|
||||
pub = bytes(identity.get_public_key())
|
||||
rh = MagicMock()
|
||||
@@ -782,22 +787,18 @@ class TestWireLevelEncoding:
|
||||
ROUTE_TYPE_FLOOD (no transport codes):
|
||||
[header(1)] [path_len(1)] [path(N)] [payload(M)]
|
||||
"""
|
||||
pkt = _make_flood_packet(
|
||||
b"\xAA\xBB\xCC\xDD", hash_size=2, hash_count=2,
|
||||
payload=b"\xFE"
|
||||
)
|
||||
pkt = _make_flood_packet(b"\xaa\xbb\xcc\xdd", hash_size=2, hash_count=2, payload=b"\xfe")
|
||||
wire = pkt.write_to()
|
||||
assert wire[0] == ROUTE_TYPE_FLOOD # header
|
||||
path_len = wire[1]
|
||||
assert PathUtils.get_path_hash_size(path_len) == 2
|
||||
assert PathUtils.get_path_hash_count(path_len) == 2
|
||||
assert wire[2:6] == b"\xAA\xBB\xCC\xDD" # path bytes
|
||||
assert wire[6:] == b"\xFE" # payload
|
||||
assert wire[2:6] == b"\xaa\xbb\xcc\xdd" # path bytes
|
||||
assert wire[6:] == b"\xfe" # payload
|
||||
|
||||
def test_3_byte_mode_wire_format(self):
|
||||
pkt = _make_flood_packet(
|
||||
b"\x11\x22\x33\x44\x55\x66", hash_size=3, hash_count=2,
|
||||
payload=b"\xAA"
|
||||
b"\x11\x22\x33\x44\x55\x66", hash_size=3, hash_count=2, payload=b"\xaa"
|
||||
)
|
||||
wire = pkt.write_to()
|
||||
assert wire[0] == ROUTE_TYPE_FLOOD
|
||||
@@ -805,11 +806,11 @@ class TestWireLevelEncoding:
|
||||
assert PathUtils.get_path_hash_size(path_len) == 3
|
||||
assert PathUtils.get_path_hash_count(path_len) == 2
|
||||
assert wire[2:8] == b"\x11\x22\x33\x44\x55\x66"
|
||||
assert wire[8:] == b"\xAA"
|
||||
assert wire[8:] == b"\xaa"
|
||||
|
||||
def test_1_byte_mode_backward_compat_wire(self):
|
||||
"""1-byte mode: path_len byte on wire == hop count (legacy format)."""
|
||||
pkt = _make_flood_packet(b"\xAA\xBB", hash_size=1, hash_count=2)
|
||||
pkt = _make_flood_packet(b"\xaa\xbb", hash_size=1, hash_count=2)
|
||||
wire = pkt.write_to()
|
||||
assert wire[1] == 2 # path_len == hop_count for 1-byte mode
|
||||
|
||||
@@ -817,10 +818,10 @@ class TestWireLevelEncoding:
|
||||
"""Manually construct wire bytes and verify read_from parses correctly."""
|
||||
# header=ROUTE_TYPE_FLOOD, path_len=encode(2, 2), path=4 bytes, payload=2 bytes
|
||||
path_len = PathUtils.encode_path_len(2, 2)
|
||||
wire = bytes([ROUTE_TYPE_FLOOD, path_len]) + b"\xAA\xBB\xCC\xDD" + b"\xFE\xED"
|
||||
wire = bytes([ROUTE_TYPE_FLOOD, path_len]) + b"\xaa\xbb\xcc\xdd" + b"\xfe\xed"
|
||||
pkt = Packet()
|
||||
pkt.read_from(wire)
|
||||
assert pkt.get_path_hash_size() == 2
|
||||
assert pkt.get_path_hash_count() == 2
|
||||
assert pkt.get_path_hashes() == [b"\xAA\xBB", b"\xCC\xDD"]
|
||||
assert pkt.get_payload() == b"\xFE\xED"
|
||||
assert pkt.get_path_hashes() == [b"\xaa\xbb", b"\xcc\xdd"]
|
||||
assert pkt.get_payload() == b"\xfe\xed"
|
||||
|
||||
@@ -187,4 +187,4 @@ def test_get_radio_for_board_pymc_usb_requires_port(monkeypatch):
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Missing 'port'"):
|
||||
get_radio_for_board(board_config)
|
||||
get_radio_for_board(board_config)
|
||||
|
||||
+17
-2
@@ -64,7 +64,9 @@ def _load_hardware_stats_sensor_module(monkeypatch):
|
||||
setattr(fake_hardware_stats, "HardwareStatsCollector", MagicMock)
|
||||
|
||||
monkeypatch.setitem(sys.modules, "repeater.data_acquisition", fake_data_acquisition)
|
||||
monkeypatch.setitem(sys.modules, "repeater.data_acquisition.hardware_stats", fake_hardware_stats)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "repeater.data_acquisition.hardware_stats", fake_hardware_stats
|
||||
)
|
||||
|
||||
module_name = "repeater.sensors._hardware_stats_test"
|
||||
module_path = Path(__file__).resolve().parents[1] / "repeater" / "sensors" / "hardware_stats.py"
|
||||
@@ -289,7 +291,20 @@ def test_waveshare_ups_e_sensor_reads_pack_state(monkeypatch):
|
||||
values = {
|
||||
waveshare_ups_e_module._REG_STATUS: [waveshare_ups_e_module._FLAG_CHARGING],
|
||||
waveshare_ups_e_module._REG_VBUS: [0xA0, 0x0F, 0x2C, 0x01, 0x58, 0x1B],
|
||||
waveshare_ups_e_module._REG_BATT: [0x80, 0x3E, 0xFA, 0x00, 0x4E, 0x00, 0x98, 0x08, 0x2D, 0x00, 0x5A, 0x00],
|
||||
waveshare_ups_e_module._REG_BATT: [
|
||||
0x80,
|
||||
0x3E,
|
||||
0xFA,
|
||||
0x00,
|
||||
0x4E,
|
||||
0x00,
|
||||
0x98,
|
||||
0x08,
|
||||
0x2D,
|
||||
0x00,
|
||||
0x5A,
|
||||
0x00,
|
||||
],
|
||||
waveshare_ups_e_module._REG_CELLS: [0x80, 0x0C, 0x6C, 0x0C, 0x1C, 0x0C, 0x76, 0x0C],
|
||||
}
|
||||
return values[register]
|
||||
|
||||
@@ -63,8 +63,12 @@ def test_is_container_detection_paths(monkeypatch):
|
||||
(b"abc", "1:name=systemd:/", False, False),
|
||||
],
|
||||
)
|
||||
def test_is_container_proc_and_host_paths(monkeypatch, environ_bytes, cgroup_text, host_path, expected):
|
||||
monkeypatch.setattr(su.os.path, "exists", lambda p: p == "/run/host/container-manager" and host_path)
|
||||
def test_is_container_proc_and_host_paths(
|
||||
monkeypatch, environ_bytes, cgroup_text, host_path, expected
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
su.os.path, "exists", lambda p: p == "/run/host/container-manager" and host_path
|
||||
)
|
||||
monkeypatch.delenv("container", raising=False)
|
||||
|
||||
def _open(path, mode="r", encoding=None):
|
||||
|
||||
@@ -176,9 +176,7 @@ def test_verify_api_token_last_used_throttle(tmp_path, monkeypatch):
|
||||
|
||||
now = {"v": 1000.0}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"repeater.data_acquisition.sqlite_handler.time.time", lambda: now["v"]
|
||||
)
|
||||
monkeypatch.setattr("repeater.data_acquisition.sqlite_handler.time.time", lambda: now["v"])
|
||||
|
||||
token_id = h.create_api_token("svc-throttle", "hash-throttle")
|
||||
assert token_id > 0
|
||||
|
||||
@@ -3,6 +3,8 @@ import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from repeater.data_acquisition.storage_collector import StorageCollector
|
||||
|
||||
sys.modules.setdefault("psutil", types.ModuleType("psutil"))
|
||||
|
||||
nacl_module = types.ModuleType("nacl")
|
||||
@@ -19,8 +21,6 @@ nacl_module.signing = nacl_signing_module
|
||||
sys.modules.setdefault("nacl", nacl_module)
|
||||
sys.modules.setdefault("nacl.signing", nacl_signing_module)
|
||||
|
||||
from repeater.data_acquisition.storage_collector import StorageCollector
|
||||
|
||||
|
||||
def _make_collector() -> StorageCollector:
|
||||
with (
|
||||
|
||||
+16
-17
@@ -24,6 +24,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
# Minimal handler factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_handler():
|
||||
"""
|
||||
Return a RepeaterHandler instance with all external I/O mocked.
|
||||
@@ -49,11 +50,9 @@ def _make_handler():
|
||||
|
||||
h = RepeaterHandler.__new__(RepeaterHandler)
|
||||
h.config = {
|
||||
"repeater": {"mode": "forward", "cache_ttl": 3600,
|
||||
"send_advert_interval_hours": 0},
|
||||
"repeater": {"mode": "forward", "cache_ttl": 3600, "send_advert_interval_hours": 0},
|
||||
"delays": {"tx_delay_factor": 1.0, "direct_tx_delay_factor": 0.5},
|
||||
"duty_cycle": {"enforcement_enabled": True,
|
||||
"max_airtime_per_minute": 3600},
|
||||
"duty_cycle": {"enforcement_enabled": True, "max_airtime_per_minute": 3600},
|
||||
"storage": {},
|
||||
"mesh": {},
|
||||
}
|
||||
@@ -80,8 +79,8 @@ def _make_packet(size: int = 50) -> MagicMock:
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTxLockSerialisation(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
class TestTxLockSerialisation(unittest.IsolatedAsyncioTestCase):
|
||||
# ── Test 1: no interleaving ─────────────────────────────────────────────
|
||||
|
||||
async def test_concurrent_sends_do_not_interleave(self):
|
||||
@@ -100,7 +99,7 @@ class TestTxLockSerialisation(unittest.IsolatedAsyncioTestCase):
|
||||
if in_flight[0]:
|
||||
overlap_detected[0] = True
|
||||
in_flight[0] = True
|
||||
await asyncio.sleep(0.05) # simulate ~50ms radio TX
|
||||
await asyncio.sleep(0.05) # simulate ~50ms radio TX
|
||||
in_flight[0] = False
|
||||
|
||||
h.dispatcher.send_packet.side_effect = send_with_overlap_check
|
||||
@@ -115,8 +114,9 @@ class TestTxLockSerialisation(unittest.IsolatedAsyncioTestCase):
|
||||
"send_packet was entered while another call was already in-flight "
|
||||
"— _tx_lock is not serialising correctly",
|
||||
)
|
||||
self.assertEqual(h.dispatcher.send_packet.call_count, 2,
|
||||
"Expected exactly 2 send_packet calls")
|
||||
self.assertEqual(
|
||||
h.dispatcher.send_packet.call_count, 2, "Expected exactly 2 send_packet calls"
|
||||
)
|
||||
|
||||
# ── Test 2: TOCTOU duty-cycle fix ──────────────────────────────────────
|
||||
|
||||
@@ -150,7 +150,8 @@ class TestTxLockSerialisation(unittest.IsolatedAsyncioTestCase):
|
||||
await asyncio.gather(t1, t2, return_exceptions=True)
|
||||
|
||||
self.assertEqual(
|
||||
h.dispatcher.send_packet.call_count, 1,
|
||||
h.dispatcher.send_packet.call_count,
|
||||
1,
|
||||
"Both packets were sent — duty-cycle TOCTOU race was NOT fixed",
|
||||
)
|
||||
|
||||
@@ -193,10 +194,8 @@ class TestTxLockSerialisation(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
await asyncio.gather(t_local, t_other, return_exceptions=True)
|
||||
|
||||
self.assertIn(id(pkt_other), send_times,
|
||||
"pkt_other was never sent")
|
||||
self.assertIn(id(pkt_local), send_times,
|
||||
"pkt_local retry was never sent")
|
||||
self.assertIn(id(pkt_other), send_times, "pkt_other was never sent")
|
||||
self.assertIn(id(pkt_local), send_times, "pkt_local retry was never sent")
|
||||
|
||||
# pkt_other fires at ~0.1s; pkt_local retry fires at ~1.0s.
|
||||
# If the lock were held during backoff, pkt_other would block until ~1.0s
|
||||
@@ -218,8 +217,7 @@ class TestTxLockSerialisation(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
h.dispatcher.send_packet.side_effect = RuntimeError("radio error")
|
||||
|
||||
task = await h.schedule_retransmit(pkt, delay=0.0, airtime_ms=0,
|
||||
local_transmission=False)
|
||||
task = await h.schedule_retransmit(pkt, delay=0.0, airtime_ms=0, local_transmission=False)
|
||||
with self.assertRaises(RuntimeError):
|
||||
await task
|
||||
|
||||
@@ -259,8 +257,9 @@ class TestTxLockSerialisation(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
await task # should complete without error (gate returns silently)
|
||||
|
||||
self.assertEqual(send_calls[0], 1,
|
||||
"send_packet called on retry despite duty-cycle rejection")
|
||||
self.assertEqual(
|
||||
send_calls[0], 1, "send_packet called on retry despite duty-cycle rejection"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -54,7 +54,7 @@ def test_fetch_url_success_and_rate_limit(monkeypatch):
|
||||
return b"ok"
|
||||
|
||||
monkeypatch.setattr(ue.urllib.request, "urlopen", lambda *args, **kwargs: _Resp())
|
||||
assert ue._fetch_url("https://example.com") == "ok"
|
||||
assert ue._fetch_url("https://api.github.com/test") == "ok"
|
||||
|
||||
reset = int((datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp())
|
||||
hdrs = {"X-RateLimit-Reset": str(reset)}
|
||||
@@ -242,7 +242,9 @@ def test_channels_set_channel_and_changelog(cherrypy_ctx, isolated_state, monkey
|
||||
assert ok["channel"] == "dev"
|
||||
|
||||
request.method = "GET"
|
||||
monkeypatch.setattr(ue, "_fetch_changelog", lambda channel, installed, max_commits: [{"title": "t"}])
|
||||
monkeypatch.setattr(
|
||||
ue, "_fetch_changelog", lambda channel, installed, max_commits: [{"title": "t"}]
|
||||
)
|
||||
c = api.changelog(channel="dev", max="5")
|
||||
assert c["success"] is True
|
||||
assert c["commits"][0]["title"] == "t"
|
||||
@@ -376,9 +378,7 @@ def test_do_install_root_install_command_failure_sets_error(isolated_state, monk
|
||||
self.cmd = cmd
|
||||
self.stdout = []
|
||||
self.returncode = (
|
||||
1
|
||||
if any(isinstance(x, str) and "git+https://github.com" in x for x in cmd)
|
||||
else 0
|
||||
1 if any(isinstance(x, str) and "git+https://github.com" in x for x in cmd) else 0
|
||||
)
|
||||
|
||||
def wait(self):
|
||||
@@ -402,7 +402,9 @@ def test_do_install_wrapper_success_then_restart_failure(isolated_state, monkeyp
|
||||
monkeypatch.setattr(ue, "_cleanup_stale_dist_info", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(ue.time, "sleep", lambda _s: None)
|
||||
monkeypatch.setattr(ue.os.path, "isfile", lambda p: p == "/usr/local/bin/pymc-do-upgrade")
|
||||
monkeypatch.setattr("repeater.service_utils.restart_service", lambda: (False, "systemctl failed"))
|
||||
monkeypatch.setattr(
|
||||
"repeater.service_utils.restart_service", lambda: (False, "systemctl failed")
|
||||
)
|
||||
|
||||
class _Proc:
|
||||
def __init__(self, cmd):
|
||||
|
||||
Reference in New Issue
Block a user