mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-06-26 13:01:06 +02:00
434 lines
15 KiB
Python
434 lines
15 KiB
Python
import asyncio
|
|
import time
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from openhop_core.protocol.constants import PAYLOAD_TYPE_ANON_REQ, ROUTE_TYPE_DIRECT
|
|
from repeater.handler_helpers.discovery import DiscoveryHelper
|
|
from repeater.handler_helpers.login import LoginHelper
|
|
from repeater.handler_helpers.trace import TraceHelper
|
|
|
|
|
|
class DummyPacket:
|
|
def __init__(
|
|
self, *, route=ROUTE_TYPE_DIRECT, path=b"", payload=b"\x01\x02", snr=2.5, rssi=-70
|
|
):
|
|
self.header = route
|
|
self.path = bytearray(path)
|
|
self.path_len = len(self.path)
|
|
self.payload = bytearray(payload)
|
|
self.snr = snr
|
|
self.rssi = rssi
|
|
|
|
def get_route_type(self):
|
|
return self.header
|
|
|
|
def get_payload_type(self):
|
|
return 0x09
|
|
|
|
def get_snr(self):
|
|
return self.snr
|
|
|
|
def calculate_packet_hash(self):
|
|
return bytes.fromhex("A1B2C3D4E5F6A7B8")
|
|
|
|
def write_to(self):
|
|
return b"\x01\x02\x03"
|
|
|
|
|
|
class FakeIdentity:
|
|
def __init__(self, first_byte=0x42):
|
|
self._pk = bytes([first_byte]) + bytes(range(1, 33))
|
|
|
|
def get_public_key(self):
|
|
return self._pk
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trace_helper_should_forward_matching_next_hop_only():
|
|
repeater_handler = MagicMock()
|
|
repeater_handler.is_duplicate.return_value = False
|
|
helper = TraceHelper(
|
|
local_hash=0x42,
|
|
local_identity=FakeIdentity(0x42),
|
|
repeater_handler=repeater_handler,
|
|
)
|
|
packet = DummyPacket(path=b"\x00")
|
|
|
|
assert helper._should_forward_trace(packet, b"", flags=0, hash_width=1) is False
|
|
assert helper._should_forward_trace(packet, b"\x01", flags=0, hash_width=0) is False
|
|
|
|
# offset = len(path)=1 for hash_width=1, so this trace is complete and not forwarded
|
|
assert helper._should_forward_trace(packet, b"\x42", flags=0, hash_width=1) is False
|
|
|
|
# next hop mismatch
|
|
packet.path = bytearray()
|
|
assert helper._should_forward_trace(packet, b"\x99", flags=0, hash_width=1) is False
|
|
|
|
# match + non-duplicate forwards
|
|
assert helper._should_forward_trace(packet, b"\x42", flags=0, hash_width=1) is True
|
|
|
|
repeater_handler.is_duplicate.return_value = True
|
|
assert helper._should_forward_trace(packet, b"\x42", flags=0, hash_width=1) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trace_helper_process_sets_pending_ping_and_forwards():
|
|
repeater_handler = MagicMock()
|
|
repeater_handler.is_duplicate.return_value = False
|
|
repeater_handler.calculate_packet_score.return_value = 0.9
|
|
helper = TraceHelper(
|
|
local_hash=0x42,
|
|
local_identity=FakeIdentity(0x42),
|
|
repeater_handler=repeater_handler,
|
|
)
|
|
|
|
tag = 77
|
|
evt = helper.register_ping(tag, 0x42)
|
|
|
|
packet = DummyPacket(path=b"\x01", payload=b"\xaa\xbb\xcc")
|
|
helper._forward_trace_packet = AsyncMock()
|
|
helper._extract_path_info = MagicMock(return_value=([], []))
|
|
helper._should_forward_trace = MagicMock(return_value=True)
|
|
helper.trace_handler._parse_trace_payload = MagicMock(
|
|
return_value={
|
|
"valid": True,
|
|
"trace_path_bytes": b"\x42",
|
|
"flags": 0,
|
|
"trace_hops": [b"\x42"],
|
|
"trace_path": [0x42],
|
|
"tag": tag,
|
|
}
|
|
)
|
|
helper.trace_handler._format_trace_response = MagicMock(return_value="trace ok")
|
|
|
|
await helper.process_trace_packet(packet)
|
|
|
|
assert evt.is_set()
|
|
assert helper.pending_pings[tag]["result"]["rssi"] == -70
|
|
repeater_handler.log_trace_record.assert_called_once()
|
|
helper._forward_trace_packet.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trace_helper_ignores_zero_rssi_pending_ping_response():
|
|
helper = TraceHelper(
|
|
local_hash=0x42, local_identity=FakeIdentity(0x42), repeater_handler=MagicMock()
|
|
)
|
|
tag = 9
|
|
evt = helper.register_ping(tag, 0x42)
|
|
|
|
packet = DummyPacket(path=b"\x01", rssi=0)
|
|
helper.trace_handler._parse_trace_payload = MagicMock(
|
|
return_value={
|
|
"valid": True,
|
|
"trace_path_bytes": b"\x42",
|
|
"flags": 0,
|
|
"trace_hops": [b"\x42"],
|
|
"trace_path": [0x42],
|
|
"tag": tag,
|
|
}
|
|
)
|
|
|
|
await helper.process_trace_packet(packet)
|
|
|
|
assert not evt.is_set()
|
|
assert helper.pending_pings[tag]["result"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trace_helper_forward_trace_packet_updates_recent_record_and_injects():
|
|
packet_injector = AsyncMock(return_value=True)
|
|
repeater_handler = MagicMock()
|
|
pkt = DummyPacket(path=b"", snr=3.5)
|
|
pkt_hash = pkt.calculate_packet_hash().hex().upper()[:16]
|
|
repeater_handler.recent_packets = [{"packet_hash": pkt_hash, "transmitted": False}]
|
|
|
|
helper = TraceHelper(
|
|
local_hash=0x42,
|
|
local_identity=FakeIdentity(0x42),
|
|
repeater_handler=repeater_handler,
|
|
packet_injector=packet_injector,
|
|
)
|
|
|
|
await helper._forward_trace_packet(pkt, num_hops=1)
|
|
|
|
assert repeater_handler.recent_packets[0]["transmitted"] is True
|
|
assert repeater_handler.recent_packets[0]["drop_reason"] == "trace_forwarded"
|
|
assert pkt.path_len == 1
|
|
packet_injector.assert_awaited_once()
|
|
|
|
|
|
def test_trace_helper_cleanup_stale_pings():
|
|
helper = TraceHelper(
|
|
local_hash=0x42, local_identity=FakeIdentity(0x42), repeater_handler=MagicMock()
|
|
)
|
|
helper.pending_pings = {
|
|
1: {"sent_at": time.time() - 100, "event": asyncio.Event(), "result": None, "target": 1},
|
|
2: {"sent_at": time.time(), "event": asyncio.Event(), "result": None, "target": 2},
|
|
}
|
|
|
|
helper.cleanup_stale_pings(max_age_seconds=10)
|
|
|
|
assert 1 not in helper.pending_pings
|
|
assert 2 in helper.pending_pings
|
|
|
|
|
|
def test_discovery_request_filter_match_and_mismatch():
|
|
helper = DiscoveryHelper(
|
|
local_identity=FakeIdentity(0x42), packet_injector=AsyncMock(), node_type=2
|
|
)
|
|
helper._send_discovery_response = MagicMock()
|
|
|
|
helper._on_discovery_request(
|
|
{"tag": 1, "filter": 0x00, "prefix_only": False, "snr": 1.2, "rssi": -80}
|
|
)
|
|
helper._send_discovery_response.assert_not_called()
|
|
|
|
helper._on_discovery_request(
|
|
{"tag": 2, "filter": 0x04, "prefix_only": True, "snr": 2.3, "rssi": -70}
|
|
)
|
|
helper._send_discovery_response.assert_called_once_with(2, 2, 2.3, True)
|
|
|
|
|
|
def test_discovery_request_without_identity_does_not_send():
|
|
helper = DiscoveryHelper(local_identity=None, packet_injector=AsyncMock(), node_type=2)
|
|
helper._send_discovery_response = MagicMock()
|
|
|
|
helper._on_discovery_request(
|
|
{"tag": 7, "filter": 0x04, "prefix_only": False, "snr": 0.0, "rssi": -90}
|
|
)
|
|
|
|
helper._send_discovery_response.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_send_packet_async_success_failure_and_exception():
|
|
injector = AsyncMock(side_effect=[True, False, RuntimeError("send fail")])
|
|
# jitter disabled so the test doesn't sleep
|
|
helper = DiscoveryHelper(
|
|
local_identity=FakeIdentity(0x42), packet_injector=injector, response_jitter_ms=0
|
|
)
|
|
|
|
await helper._send_packet_async(packet=object(), tag=0x11)
|
|
await helper._send_packet_async(packet=object(), tag=0x12)
|
|
await helper._send_packet_async(packet=object(), tag=0x13)
|
|
|
|
assert injector.await_count == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_response_applies_bounded_jitter_before_send():
|
|
injector = AsyncMock(return_value=True)
|
|
helper = DiscoveryHelper(
|
|
local_identity=FakeIdentity(0x42), packet_injector=injector, response_jitter_ms=2000
|
|
)
|
|
|
|
slept = []
|
|
|
|
async def fake_sleep(secs):
|
|
slept.append(secs)
|
|
|
|
with patch("repeater.handler_helpers.discovery.asyncio.sleep", side_effect=fake_sleep):
|
|
await helper._send_packet_async(packet=object(), tag=0x55)
|
|
|
|
# Jitter applied exactly once, bounded to [0, 2.0]s, before the injection.
|
|
assert len(slept) == 1
|
|
assert 0.0 <= slept[0] <= 2.0
|
|
injector.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_response_jitter_disabled_does_not_sleep():
|
|
injector = AsyncMock(return_value=True)
|
|
helper = DiscoveryHelper(
|
|
local_identity=FakeIdentity(0x42), packet_injector=injector, response_jitter_ms=0
|
|
)
|
|
|
|
with patch("repeater.handler_helpers.discovery.asyncio.sleep") as sleep_mock:
|
|
await helper._send_packet_async(packet=object(), tag=0x56)
|
|
|
|
sleep_mock.assert_not_called()
|
|
injector.assert_awaited_once()
|
|
|
|
|
|
def test_discovery_send_response_without_injector_is_safe():
|
|
helper = DiscoveryHelper(local_identity=FakeIdentity(0x42), packet_injector=None)
|
|
|
|
with patch(
|
|
"openhop_core.protocol.packet_builder.PacketBuilder.create_discovery_response",
|
|
return_value=object(),
|
|
):
|
|
helper._send_discovery_response(tag=5, node_type=2, inbound_snr=1.0, prefix_only=False)
|
|
|
|
|
|
def test_login_register_identity_room_server_requires_passwords():
|
|
helper = LoginHelper(identity_manager=MagicMock(), packet_injector=AsyncMock())
|
|
identity = FakeIdentity(0x51)
|
|
|
|
with (
|
|
patch("repeater.handler_helpers.acl.ACL") as acl_cls,
|
|
patch("repeater.handler_helpers.login.LoginServerHandler") as handler_cls,
|
|
):
|
|
helper.register_identity(
|
|
name="room-a",
|
|
identity=identity,
|
|
identity_type="room_server",
|
|
config={"settings": {}},
|
|
)
|
|
|
|
acl_cls.assert_not_called()
|
|
handler_cls.assert_not_called()
|
|
assert 0x51 not in helper.handlers
|
|
|
|
|
|
def test_login_register_identity_repeater_creates_acl_and_handler():
|
|
helper = LoginHelper(identity_manager=MagicMock(), packet_injector=AsyncMock())
|
|
identity = FakeIdentity(0x52)
|
|
acl_obj = MagicMock()
|
|
handler_obj = MagicMock()
|
|
anon_obj = MagicMock()
|
|
|
|
with (
|
|
patch("repeater.handler_helpers.acl.ACL", return_value=acl_obj) as acl_cls,
|
|
patch(
|
|
"repeater.handler_helpers.login.LoginServerHandler", return_value=handler_obj
|
|
) as handler_cls,
|
|
patch(
|
|
"repeater.handler_helpers.login.AnonRequestHandler", return_value=anon_obj
|
|
) as anon_cls,
|
|
):
|
|
helper.register_identity(
|
|
name="repeater-main",
|
|
identity=identity,
|
|
identity_type="repeater",
|
|
config={
|
|
"repeater": {
|
|
"security": {"max_clients": 3, "admin_password": "a", "guest_password": "g"}
|
|
}
|
|
},
|
|
)
|
|
|
|
acl_cls.assert_called_once()
|
|
handler_cls.assert_called_once()
|
|
# The login handler is wrapped in an AnonRequestHandler, and that wrapper is
|
|
# what gets stored + wired with the send callback.
|
|
anon_cls.assert_called_once()
|
|
assert anon_cls.call_args.kwargs["login_handler"] is handler_obj
|
|
anon_obj.set_send_packet_callback.assert_called_once()
|
|
assert helper.handlers[0x52] is anon_obj
|
|
assert helper.acls[0x52] is acl_obj
|
|
|
|
|
|
class _FakeSqlite:
|
|
def __init__(self, keys):
|
|
self._keys = keys
|
|
|
|
def get_transport_keys(self):
|
|
return self._keys
|
|
|
|
|
|
def test_format_region_names_filters_and_strips():
|
|
keys = [
|
|
{"name": "#VHF", "flood_policy": "allow"},
|
|
{"name": "USA", "flood_policy": "allow"},
|
|
{"name": "secret", "flood_policy": "deny"},
|
|
{"name": "*", "flood_policy": "allow"}, # duplicate wildcard ignored
|
|
{"name": "", "flood_policy": "allow"},
|
|
]
|
|
# Default config => unscoped flood allowed => wildcard '*' present.
|
|
helper = LoginHelper(identity_manager=MagicMock(), sqlite_handler=_FakeSqlite(keys))
|
|
# Wildcard first (from policy), '#' stripped, deny + empty + literal '*' excluded.
|
|
assert helper._format_region_names() == "*,VHF,USA"
|
|
|
|
|
|
def test_format_region_names_wildcard_suppressed_when_unscoped_denied():
|
|
keys = [{"name": "USA", "flood_policy": "allow"}]
|
|
helper = LoginHelper(
|
|
identity_manager=MagicMock(),
|
|
sqlite_handler=_FakeSqlite(keys),
|
|
config={"mesh": {"unscoped_flood_allow": False}},
|
|
)
|
|
# No wildcard when unscoped flood is denied (firmware: wildcard deny-flood).
|
|
assert helper._format_region_names() == "USA"
|
|
|
|
|
|
def test_format_region_names_without_storage_is_just_wildcard():
|
|
# No named regions, but unscoped flood allowed by default => bare wildcard.
|
|
helper = LoginHelper(identity_manager=MagicMock(), sqlite_handler=None)
|
|
assert helper._format_region_names() == "*"
|
|
|
|
|
|
def test_owner_and_features_callbacks_from_config():
|
|
config = {"repeater": {"node_name": "node-x", "owner_info": "me", "mode": "monitor"}}
|
|
helper = LoginHelper(identity_manager=MagicMock(), config=config)
|
|
|
|
assert helper._make_owner_info_fn("fallback", config)() == ("node-x", "me")
|
|
# Non-forward mode sets the forwarding-disabled bit (0x80).
|
|
assert helper._make_features_fn(config)() == 0x80
|
|
# Forwarding mode clears it.
|
|
assert helper._make_features_fn({"repeater": {"mode": "forward"}})() == 0x00
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_process_packet_routes_to_registered_handler_and_marks_no_retransmit():
|
|
helper = LoginHelper(identity_manager=MagicMock(), packet_injector=AsyncMock())
|
|
login_handler = AsyncMock()
|
|
helper.handlers[0x62] = login_handler
|
|
|
|
packet = SimpleNamespace(
|
|
payload=bytearray([0x62, 0xAA]),
|
|
get_payload_type=lambda: 0x01,
|
|
mark_do_not_retransmit=MagicMock(),
|
|
)
|
|
|
|
handled = await helper.process_login_packet(packet)
|
|
|
|
assert handled is True
|
|
login_handler.assert_awaited_once_with(packet)
|
|
packet.mark_do_not_retransmit.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_process_packet_unknown_and_short_payload_are_not_handled():
|
|
helper = LoginHelper(identity_manager=MagicMock(), packet_injector=AsyncMock())
|
|
|
|
short_packet = SimpleNamespace(payload=bytearray())
|
|
assert await helper.process_login_packet(short_packet) is False
|
|
|
|
unknown_packet = SimpleNamespace(
|
|
payload=bytearray([0x63]),
|
|
get_payload_type=lambda: PAYLOAD_TYPE_ANON_REQ,
|
|
)
|
|
assert await helper.process_login_packet(unknown_packet) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_delayed_send_success_and_error_paths():
|
|
injector = AsyncMock(side_effect=[True, RuntimeError("send failed")])
|
|
helper = LoginHelper(identity_manager=MagicMock(), packet_injector=injector)
|
|
|
|
with patch("repeater.handler_helpers.login.asyncio.sleep", new_callable=AsyncMock):
|
|
await helper._delayed_send(packet=object(), delay_ms=10)
|
|
await helper._delayed_send(packet=object(), delay_ms=10)
|
|
|
|
assert injector.await_count == 2
|
|
|
|
|
|
def test_login_acl_access_and_client_listing():
|
|
helper = LoginHelper(identity_manager=MagicMock(), packet_injector=AsyncMock())
|
|
acl_a = MagicMock()
|
|
acl_b = MagicMock()
|
|
acl_a.get_all_clients.return_value = [{"id": "a1"}]
|
|
acl_b.get_all_clients.return_value = [{"id": "b1"}, {"id": "b2"}]
|
|
helper.acls = {0x70: acl_a, 0x71: acl_b}
|
|
|
|
assert helper.get_acl_for_identity(0x70) is acl_a
|
|
assert helper.get_acl_for_identity(0x99) is None
|
|
assert helper.list_authenticated_clients(0x71) == [{"id": "b1"}, {"id": "b2"}]
|
|
|
|
all_clients = helper.list_authenticated_clients()
|
|
assert {c["id"] for c in all_clients} == {"a1", "b1", "b2"}
|