mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-27 03:52:47 +02:00
1324 lines
55 KiB
Python
1324 lines
55 KiB
Python
"""
|
|
Tests for PacketRouter in-flight cap and shutdown behaviour.
|
|
Addresses the three concerns raised in PR 191 review:
|
|
|
|
1. Cap enforcement: packets beyond _max_in_flight are dropped, not queued.
|
|
2. Drop counter: _cap_drop_count increments on each cap-drop so operators
|
|
have visibility into how often the safety valve fires.
|
|
3. Shutdown drain: stop() waits for in-flight tasks to finish (up to 5 s),
|
|
then cancels any that remain — tasks are never silently abandoned.
|
|
|
|
Run with:
|
|
python -m pytest tests/test_packet_router.py -v
|
|
or:
|
|
python -m unittest tests.test_packet_router -v
|
|
"""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import time
|
|
import unittest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from openhop_core.companion import CompanionBridge
|
|
from openhop_core.node.handlers.ack import AckHandler
|
|
from openhop_core.node.handlers.advert import AdvertHandler
|
|
from openhop_core.node.handlers.control import ControlHandler
|
|
from openhop_core.node.handlers.group_text import GroupTextHandler
|
|
from openhop_core.node.handlers.login_response import LoginResponseHandler
|
|
from openhop_core.node.handlers.login_server import LoginServerHandler
|
|
from openhop_core.node.handlers.multipart import MultipartAckHandler
|
|
from openhop_core.node.handlers.path import PathHandler
|
|
from openhop_core.node.handlers.protocol_request import ProtocolRequestHandler
|
|
from openhop_core.node.handlers.protocol_response import ProtocolResponseHandler
|
|
from openhop_core.node.handlers.result import HandlerResult
|
|
from openhop_core.node.handlers.text import TextMessageHandler
|
|
from openhop_core.node.handlers.trace import TraceHandler
|
|
from openhop_core.protocol.constants import (
|
|
PAYLOAD_TYPE_GRP_DATA,
|
|
ROUTE_TYPE_DIRECT,
|
|
ROUTE_TYPE_FLOOD,
|
|
ROUTE_TYPE_TRANSPORT_DIRECT,
|
|
)
|
|
from openhop_core.protocol import LocalIdentity, Packet, PacketBuilder
|
|
|
|
from repeater.packet_router import (
|
|
PacketRouter,
|
|
_companion_dedup_key,
|
|
_is_direct_final_hop,
|
|
)
|
|
from repeater.policy_engine import PolicyEngine
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Minimal daemon stub
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_daemon():
|
|
"""Minimal daemon that satisfies PacketRouter without touching hardware."""
|
|
daemon = MagicMock()
|
|
daemon.repeater_handler = AsyncMock(return_value=True)
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
daemon.trace_helper = None
|
|
daemon.discovery_helper = None
|
|
daemon.advert_helper = None
|
|
daemon.companion_bridges = {}
|
|
daemon.login_helper = None
|
|
daemon.text_helper = None
|
|
daemon.path_helper = None
|
|
daemon.protocol_request_helper = None
|
|
daemon.dispatcher = MagicMock()
|
|
daemon.dispatcher._register_ack_received = AsyncMock()
|
|
daemon.dispatcher.wait_for_ack = AsyncMock(return_value=True)
|
|
return daemon
|
|
|
|
|
|
def _make_packet(payload_type: int = 0xFF):
|
|
"""Minimal packet stub."""
|
|
pkt = MagicMock()
|
|
pkt.get_payload_type.return_value = payload_type
|
|
pkt.payload = b"\xff"
|
|
pkt.header = 0x00
|
|
pkt.rssi = -80
|
|
pkt.snr = 5.0
|
|
pkt.timestamp = time.time()
|
|
pkt._injected_for_tx = False
|
|
pkt.path = bytearray()
|
|
pkt.calculate_packet_hash.return_value = b"\x01" * 32
|
|
pkt.mark_do_not_retransmit = MagicMock()
|
|
return pkt
|
|
|
|
|
|
def _make_bridge():
|
|
bridge = MagicMock()
|
|
bridge.process_received_packet = AsyncMock()
|
|
return bridge
|
|
|
|
|
|
class _SlottedPacket:
|
|
__slots__ = (
|
|
"payload",
|
|
"header",
|
|
"rssi",
|
|
"snr",
|
|
"timestamp",
|
|
"_injected_for_tx",
|
|
"path",
|
|
"calculate_packet_hash",
|
|
"mark_do_not_retransmit",
|
|
"_payload_type",
|
|
)
|
|
|
|
def __init__(self, payload_type: int = 1):
|
|
self.payload = b"\x00"
|
|
self.header = 0x00
|
|
self.rssi = -80
|
|
self.snr = 5.0
|
|
self.timestamp = time.time()
|
|
self._injected_for_tx = False
|
|
self.path = bytearray()
|
|
self.calculate_packet_hash = MagicMock(return_value=b"\x01" * 32)
|
|
self.mark_do_not_retransmit = MagicMock()
|
|
self._payload_type = payload_type
|
|
|
|
def get_payload_type(self):
|
|
return self._payload_type
|
|
|
|
def get_path_hash_size(self):
|
|
return 0
|
|
|
|
def get_path_hash_count(self):
|
|
return 0
|
|
|
|
def get_path_hashes_hex(self):
|
|
return []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
|
# ── 1. Cap enforcement ──────────────────────────────────────────────────
|
|
|
|
async def test_cap_drops_packets_when_full(self):
|
|
"""
|
|
When _in_flight reaches _max_in_flight, new packets from the queue
|
|
must be dropped (not passed to _route_packet).
|
|
"""
|
|
router = PacketRouter(_make_daemon())
|
|
router._max_in_flight = 3
|
|
|
|
# Manually occupy all slots with long-sleeping tasks
|
|
barrier = asyncio.Event()
|
|
|
|
async def slow_route(pkt):
|
|
await barrier.wait() # blocks until we release
|
|
|
|
routed = []
|
|
|
|
async def counting_route(pkt):
|
|
routed.append(pkt)
|
|
await barrier.wait()
|
|
|
|
router._route_packet = counting_route
|
|
|
|
await router.start()
|
|
|
|
# Fill the cap
|
|
for _ in range(3):
|
|
await router.enqueue(_make_packet())
|
|
await asyncio.sleep(0.05) # let queue drain into tasks
|
|
self.assertEqual(router._in_flight, 3)
|
|
|
|
# These should be dropped
|
|
for _ in range(5):
|
|
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")
|
|
|
|
barrier.set() # release blocked tasks
|
|
await router.stop()
|
|
|
|
# ── 2. Drop counter ─────────────────────────────────────────────────────
|
|
|
|
async def test_cap_drop_count_increments(self):
|
|
"""_cap_drop_count must increment by exactly 1 for each dropped packet."""
|
|
router = PacketRouter(_make_daemon())
|
|
router._max_in_flight = 1
|
|
|
|
barrier = asyncio.Event()
|
|
|
|
async def blocking_route(pkt):
|
|
await barrier.wait()
|
|
|
|
router._route_packet = blocking_route
|
|
|
|
await router.start()
|
|
|
|
# Fill the single slot
|
|
await router.enqueue(_make_packet())
|
|
await asyncio.sleep(0.05)
|
|
self.assertEqual(router._in_flight, 1)
|
|
|
|
# Drop three packets
|
|
for _ in range(3):
|
|
await router.enqueue(_make_packet())
|
|
await asyncio.sleep(0.05)
|
|
|
|
self.assertEqual(router._cap_drop_count, 3)
|
|
|
|
barrier.set()
|
|
await router.stop()
|
|
|
|
async def test_cap_drop_count_zero_when_cap_not_reached(self):
|
|
"""_cap_drop_count must stay 0 when the cap is never reached."""
|
|
router = PacketRouter(_make_daemon())
|
|
router._max_in_flight = 30
|
|
|
|
completed = []
|
|
|
|
async def fast_route(pkt):
|
|
completed.append(pkt)
|
|
|
|
router._route_packet = fast_route
|
|
|
|
await router.start()
|
|
|
|
for _ in range(10):
|
|
await router.enqueue(_make_packet())
|
|
await asyncio.sleep(0.1)
|
|
|
|
self.assertEqual(router._cap_drop_count, 0)
|
|
await router.stop()
|
|
|
|
async def test_injected_trace_packet_skips_inbound_trace_processing(self):
|
|
"""Locally injected TRACE packets must not be re-parsed as inbound trace responses."""
|
|
daemon = _make_daemon()
|
|
daemon.trace_helper = MagicMock()
|
|
daemon.trace_helper.process_trace_packet = AsyncMock()
|
|
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(payload_type=TraceHandler.payload_type())
|
|
|
|
await router.start()
|
|
try:
|
|
injected = await router.inject_packet(pkt)
|
|
self.assertTrue(injected)
|
|
await asyncio.sleep(0.05)
|
|
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
daemon.trace_helper.process_trace_packet.assert_not_awaited()
|
|
finally:
|
|
await router.stop()
|
|
|
|
async def test_policy_companion_precheck_handles_slotted_packet(self):
|
|
"""Policy companion precheck must not attach attributes to slotted Packet objects."""
|
|
daemon = _make_daemon()
|
|
daemon.companion_bridges = {"bridge": _make_bridge()}
|
|
daemon.repeater_handler.policy_engine = PolicyEngine({"enabled": True, "rules": []})
|
|
router = PacketRouter(daemon)
|
|
pkt = _SlottedPacket(payload_type=1)
|
|
metadata = {"rssi": pkt.rssi, "snr": pkt.snr}
|
|
|
|
bridges = router._companion_bridges_for_packet(pkt, metadata)
|
|
|
|
self.assertEqual(bridges, daemon.companion_bridges)
|
|
self.assertIn("_policy_precheck_decision", metadata)
|
|
|
|
async def test_route_grp_txt_reuses_policy_precheck_metadata(self):
|
|
"""GRP_TXT should not force a second policy evaluation when the router already pre-checked it."""
|
|
daemon = _make_daemon()
|
|
daemon.repeater_handler = AsyncMock(return_value=True)
|
|
daemon.repeater_handler.storage = MagicMock()
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
daemon.repeater_handler.policy_engine = PolicyEngine({"enabled": True, "rules": []})
|
|
evaluate_spy = patch.object(
|
|
daemon.repeater_handler.policy_engine,
|
|
"evaluate",
|
|
wraps=daemon.repeater_handler.policy_engine.evaluate,
|
|
)
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(GroupTextHandler.payload_type())
|
|
|
|
with evaluate_spy as mock_evaluate:
|
|
await router._route_packet(pkt)
|
|
|
|
self.assertEqual(mock_evaluate.call_count, 1)
|
|
bridge.process_received_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_route_grp_data_fans_out_to_companions_and_forwards(self):
|
|
"""GRP_DATA reaches each companion for channel matching, then reaches the engine."""
|
|
daemon = _make_daemon()
|
|
first_bridge = _make_bridge()
|
|
second_bridge = _make_bridge()
|
|
daemon.companion_bridges = {0x01: first_bridge, 0x02: second_bridge}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(PAYLOAD_TYPE_GRP_DATA)
|
|
# Firmware wire vector: GRP_DATA (0x06), version 0, FLOOD (0x01).
|
|
pkt.header = (PAYLOAD_TYPE_GRP_DATA << 2) | ROUTE_TYPE_FLOOD
|
|
self.assertEqual(pkt.header, 0x19)
|
|
|
|
await router._route_packet(pkt)
|
|
|
|
first_bridge.process_received_packet.assert_awaited_once_with(pkt)
|
|
second_bridge.process_received_packet.assert_awaited_once_with(pkt)
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_route_grp_data_decrypts_in_a_companion_and_forwards(self):
|
|
"""A firmware-format GRP_DATA packet is both delivered locally and forwarded."""
|
|
channel_secret = b"\x11" * 32
|
|
|
|
async def inject_packet(packet, wait_for_ack=False):
|
|
return True
|
|
|
|
bridge = CompanionBridge(LocalIdentity(), inject_packet, node_name="Test")
|
|
assert bridge.set_channel(0, "Public", channel_secret)
|
|
packet = PacketBuilder.create_group_data_packet(
|
|
PAYLOAD_TYPE_GRP_DATA,
|
|
channel_hash=hashlib.sha256(channel_secret).digest()[0],
|
|
channel_secret=channel_secret,
|
|
plaintext=b"\x34\x12\x02\xaa\xbb",
|
|
secret=channel_secret,
|
|
)
|
|
self.assertEqual(packet.header, 0x19)
|
|
|
|
daemon = _make_daemon()
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
router = PacketRouter(daemon)
|
|
|
|
await router._route_packet(packet)
|
|
|
|
queued = bridge.sync_next_message()
|
|
self.assertIsNotNone(queued)
|
|
self.assertTrue(queued.is_channel)
|
|
self.assertEqual(queued.channel_idx, 0)
|
|
self.assertEqual(queued.channel_data_type, 0x1234)
|
|
self.assertEqual(queued.channel_data_payload, b"\xaa\xbb")
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_route_direct_grp_data_intermediate_skips_companion_delivery(self):
|
|
"""A direct GRP_DATA packet with a remaining hop is only offered to the engine."""
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
router = PacketRouter(daemon)
|
|
packet = _make_packet(PAYLOAD_TYPE_GRP_DATA)
|
|
packet.header = (PAYLOAD_TYPE_GRP_DATA << 2) | ROUTE_TYPE_DIRECT
|
|
packet.path = bytearray([0x42])
|
|
|
|
await router._route_packet(packet)
|
|
|
|
bridge.process_received_packet.assert_not_awaited()
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_non_injected_handler_false_is_logged(self):
|
|
"""Inbound packets should log when repeater_handler reports TX failure."""
|
|
daemon = _make_daemon()
|
|
daemon.repeater_handler = AsyncMock(return_value=False)
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(payload_type=0xFF)
|
|
|
|
with patch("repeater.packet_router.logger.warning") as mock_warn:
|
|
await router._route_packet(pkt)
|
|
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
mock_warn.assert_called()
|
|
|
|
async def test_expected_drop_reason_is_debug_not_warning(self):
|
|
"""Policy drops should log as debug to avoid false-alarm warnings."""
|
|
daemon = _make_daemon()
|
|
|
|
async def _handler(packet, metadata):
|
|
metadata["_repeater_drop_reason"] = "Max flood hops limit reached"
|
|
return False
|
|
|
|
daemon.repeater_handler = AsyncMock(side_effect=_handler)
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(payload_type=0x04)
|
|
pkt.header = 0x11 # type=4, route=FLOOD
|
|
|
|
with (
|
|
patch("repeater.packet_router.logger.debug") as mock_debug,
|
|
patch("repeater.packet_router.logger.warning") as mock_warn,
|
|
):
|
|
await router._route_packet(pkt)
|
|
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
mock_debug.assert_called()
|
|
mock_warn.assert_not_called()
|
|
|
|
# ── 3. Shutdown: in-flight tasks drained ────────────────────────────────
|
|
|
|
async def test_stop_waits_for_in_flight_tasks(self):
|
|
"""
|
|
stop() must wait for in-flight tasks to complete before returning.
|
|
Tasks that finish within the 5-second timeout must complete normally,
|
|
not be cancelled.
|
|
"""
|
|
router = PacketRouter(_make_daemon())
|
|
|
|
completed = []
|
|
started = asyncio.Event()
|
|
|
|
async def slow_route(pkt):
|
|
started.set()
|
|
await asyncio.sleep(0.2) # finishes well within 5 s timeout
|
|
completed.append(pkt)
|
|
|
|
router._route_packet = slow_route
|
|
|
|
await router.start()
|
|
pkt = _make_packet()
|
|
await router.enqueue(pkt)
|
|
|
|
# Wait until the task has actually started
|
|
await asyncio.wait_for(started.wait(), timeout=1.0)
|
|
|
|
await router.stop()
|
|
|
|
# Task should have completed, not been cancelled
|
|
self.assertEqual(len(completed), 1, "In-flight task was cancelled instead of drained")
|
|
|
|
async def test_stop_cancels_tasks_that_exceed_timeout(self):
|
|
"""
|
|
Tasks that don't finish within the 5-second timeout must be cancelled,
|
|
not left running indefinitely.
|
|
"""
|
|
router = PacketRouter(_make_daemon())
|
|
router._max_in_flight = 5
|
|
|
|
cancelled = []
|
|
started = asyncio.Event()
|
|
|
|
async def hanging_route(pkt):
|
|
started.set()
|
|
try:
|
|
await asyncio.sleep(999) # will not finish within 5 s
|
|
except asyncio.CancelledError:
|
|
cancelled.append(pkt)
|
|
raise
|
|
|
|
router._route_packet = hanging_route
|
|
|
|
async def fast_stop():
|
|
router.running = False
|
|
if router.router_task:
|
|
router.router_task.cancel()
|
|
try:
|
|
await router.router_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
if router._route_tasks:
|
|
snapshot = set(router._route_tasks)
|
|
_, still_pending = await asyncio.wait(snapshot, timeout=0.1)
|
|
for task in still_pending:
|
|
task.cancel()
|
|
await asyncio.gather(*still_pending, return_exceptions=True)
|
|
|
|
router.stop = fast_stop
|
|
|
|
await router.start()
|
|
await router.enqueue(_make_packet())
|
|
await asyncio.wait_for(started.wait(), timeout=1.0)
|
|
|
|
await router.stop()
|
|
|
|
self.assertEqual(len(cancelled), 1, "Hanging task was not cancelled on shutdown")
|
|
|
|
# ── 4. Route-tasks set stays in sync with counter ───────────────────────
|
|
|
|
async def test_route_tasks_set_cleaned_up_on_completion(self):
|
|
"""
|
|
_route_tasks must be empty after all tasks complete — the done-callback
|
|
must discard each task so the set doesn't grow unboundedly.
|
|
"""
|
|
router = PacketRouter(_make_daemon())
|
|
|
|
async def fast_route(pkt):
|
|
await asyncio.sleep(0) # yield, then done
|
|
|
|
router._route_packet = fast_route
|
|
|
|
await router.start()
|
|
|
|
for _ in range(10):
|
|
await router.enqueue(_make_packet())
|
|
|
|
# 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")
|
|
|
|
await router.stop()
|
|
|
|
# ── 5. Counter and set always agree ─────────────────────────────────────
|
|
|
|
async def test_counter_matches_set_size_under_load(self):
|
|
"""
|
|
_in_flight must always equal len(_route_tasks) while tasks are running.
|
|
Checked at steady state when the cap is saturated.
|
|
"""
|
|
router = PacketRouter(_make_daemon())
|
|
router._max_in_flight = 5
|
|
|
|
barrier = asyncio.Event()
|
|
|
|
async def blocking_route(pkt):
|
|
await barrier.wait()
|
|
|
|
router._route_packet = blocking_route
|
|
|
|
await router.start()
|
|
|
|
for _ in range(5):
|
|
await router.enqueue(_make_packet())
|
|
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)})",
|
|
)
|
|
|
|
barrier.set()
|
|
await router.stop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
|
|
class TestPacketRouterRoutingBranches(unittest.IsolatedAsyncioTestCase):
|
|
def test_companion_dedup_key_handles_hash_exceptions(self):
|
|
pkt = MagicMock()
|
|
pkt.calculate_packet_hash.side_effect = RuntimeError("bad packet")
|
|
self.assertIsNone(_companion_dedup_key(pkt))
|
|
|
|
def test_is_direct_final_hop_helper(self):
|
|
pkt = _make_packet()
|
|
pkt.header = ROUTE_TYPE_DIRECT
|
|
pkt.path = bytearray()
|
|
self.assertTrue(_is_direct_final_hop(pkt))
|
|
pkt.path = bytearray(b"\x01")
|
|
self.assertFalse(_is_direct_final_hop(pkt))
|
|
|
|
async def test_should_deliver_path_to_companions_dedupes(self):
|
|
router = PacketRouter(_make_daemon())
|
|
pkt = _make_packet(PathHandler.payload_type())
|
|
self.assertTrue(router._should_deliver_path_to_companions(pkt))
|
|
self.assertFalse(router._should_deliver_path_to_companions(pkt))
|
|
key = _companion_dedup_key(pkt)
|
|
router._companion_delivered[key] = time.time() - 1.0
|
|
# Expired entries are only pruned once the dict grows beyond 200 entries.
|
|
for i in range(205):
|
|
router._companion_delivered[f"K{i}"] = time.time() + 60.0
|
|
self.assertTrue(router._should_deliver_path_to_companions(pkt))
|
|
|
|
async def test_enqueue_drops_oldest_when_queue_full(self):
|
|
router = PacketRouter(_make_daemon())
|
|
router.queue = asyncio.Queue(maxsize=1)
|
|
p1 = _make_packet()
|
|
p2 = _make_packet()
|
|
await router.queue.put(p1)
|
|
await router.enqueue(p2)
|
|
got = await router.queue.get()
|
|
self.assertIs(got, p2)
|
|
|
|
async def test_inject_packet_returns_false_on_engine_error(self):
|
|
daemon = _make_daemon()
|
|
daemon.repeater_handler = AsyncMock(side_effect=RuntimeError("boom"))
|
|
router = PacketRouter(daemon)
|
|
ok = await router.inject_packet(_make_packet())
|
|
self.assertFalse(ok)
|
|
|
|
async def test_on_route_done_handles_task_exception(self):
|
|
router = PacketRouter(_make_daemon())
|
|
|
|
async def _fails():
|
|
raise RuntimeError("route fail")
|
|
|
|
task = asyncio.create_task(_fails())
|
|
with self.assertRaises(RuntimeError):
|
|
await task
|
|
router._in_flight = 1
|
|
router._route_tasks.add(task)
|
|
router._on_route_done(task)
|
|
self.assertEqual(router._in_flight, 0)
|
|
self.assertEqual(len(router._route_tasks), 0)
|
|
|
|
async def test_route_trace_inbound_uses_trace_helper_and_skips_engine(self):
|
|
daemon = _make_daemon()
|
|
daemon.trace_helper = MagicMock()
|
|
daemon.trace_helper.process_trace_packet = AsyncMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(TraceHandler.payload_type())
|
|
await router._route_packet(pkt)
|
|
daemon.trace_helper.process_trace_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_route_control_calls_discovery_and_delivery_and_engine(self):
|
|
daemon = _make_daemon()
|
|
daemon.discovery_helper = MagicMock()
|
|
daemon.discovery_helper.control_handler = AsyncMock()
|
|
daemon.deliver_control_data = AsyncMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(ControlHandler.payload_type())
|
|
pkt.path_len = 0
|
|
await router._route_packet(pkt)
|
|
daemon.discovery_helper.control_handler.assert_awaited_once()
|
|
pkt.mark_do_not_retransmit.assert_called_once()
|
|
daemon.deliver_control_data.assert_awaited_once()
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_route_control_marks_do_not_retransmit_when_discovery_disabled(self):
|
|
"""With discovery disabled (discovery_helper is None), control packets must
|
|
still be marked do-not-retransmit so the engine does not relay them. MeshCore
|
|
never forwards control packets regardless of whether discovery is enabled."""
|
|
daemon = _make_daemon()
|
|
daemon.discovery_helper = None
|
|
daemon.deliver_control_data = AsyncMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(ControlHandler.payload_type())
|
|
pkt.path_len = 0
|
|
await router._route_packet(pkt)
|
|
pkt.mark_do_not_retransmit.assert_called_once()
|
|
|
|
async def test_route_advert_delivers_to_helpers_and_engine(self):
|
|
daemon = _make_daemon()
|
|
daemon.advert_helper = MagicMock()
|
|
daemon.advert_helper.process_advert_packet = AsyncMock()
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0x42: bridge}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(AdvertHandler.payload_type())
|
|
await router._route_packet(pkt)
|
|
daemon.advert_helper.process_advert_packet.assert_awaited_once()
|
|
bridge.process_received_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_route_advert_policy_drop_blocks_companion_delivery(self):
|
|
daemon = _make_daemon()
|
|
daemon.advert_helper = MagicMock()
|
|
daemon.advert_helper.process_advert_packet = AsyncMock()
|
|
daemon.repeater_handler.policy_engine = PolicyEngine(
|
|
{
|
|
"enabled": True,
|
|
"default_action": "drop",
|
|
"rules": [],
|
|
}
|
|
)
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0x42: bridge}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(AdvertHandler.payload_type())
|
|
|
|
await router._route_packet(pkt)
|
|
|
|
daemon.advert_helper.process_advert_packet.assert_awaited_once()
|
|
bridge.process_received_packet.assert_not_awaited()
|
|
|
|
async def test_route_login_server_to_companion_marks_processed(self):
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0x7A: bridge}
|
|
daemon.repeater_handler = AsyncMock()
|
|
daemon.repeater_handler.storage = MagicMock()
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(LoginServerHandler.payload_type())
|
|
pkt.payload = bytes([0x7A, 0x99])
|
|
await router._route_packet(pkt)
|
|
bridge.process_received_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_route_login_server_hash_collision_offers_to_both(self):
|
|
"""A one-byte dest hash shared by a companion and a room-server identity
|
|
must be offered to BOTH handlers; only the one whose key decrypts replies.
|
|
Regression: previously the companion shadowed the room server, so the
|
|
room-server login never ran and failed with Invalid HMAC."""
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0xF5: bridge}
|
|
daemon.login_helper = MagicMock()
|
|
daemon.login_helper.handlers = {0xF5: MagicMock()}
|
|
daemon.login_helper.process_login_packet = AsyncMock(return_value=True)
|
|
daemon.repeater_handler = AsyncMock()
|
|
daemon.repeater_handler.storage = MagicMock()
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(LoginServerHandler.payload_type())
|
|
pkt.payload = bytes([0xF5, 0x99])
|
|
await router._route_packet(pkt)
|
|
bridge.process_received_packet.assert_awaited_once()
|
|
daemon.login_helper.process_login_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_route_login_server_companion_only_skips_login_helper(self):
|
|
"""No collision: a companion-owned hash with no room server registered
|
|
there must not also invoke login_helper."""
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0x7A: bridge}
|
|
daemon.login_helper = MagicMock()
|
|
daemon.login_helper.handlers = {} # no room-server identity at this hash
|
|
daemon.login_helper.process_login_packet = AsyncMock(return_value=False)
|
|
daemon.repeater_handler = AsyncMock()
|
|
daemon.repeater_handler.storage = MagicMock()
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(LoginServerHandler.payload_type())
|
|
pkt.payload = bytes([0x7A, 0x99])
|
|
await router._route_packet(pkt)
|
|
bridge.process_received_packet.assert_awaited_once()
|
|
daemon.login_helper.process_login_packet.assert_not_awaited()
|
|
|
|
async def test_route_login_server_room_server_without_companion(self):
|
|
"""No local companion claims the hash: login_helper handles the
|
|
room-server (or forwards a remote) login as before."""
|
|
daemon = _make_daemon()
|
|
daemon.companion_bridges = {}
|
|
daemon.login_helper = MagicMock()
|
|
daemon.login_helper.handlers = {0xF5: MagicMock()}
|
|
daemon.login_helper.process_login_packet = AsyncMock(return_value=True)
|
|
daemon.repeater_handler = AsyncMock()
|
|
daemon.repeater_handler.storage = MagicMock()
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(LoginServerHandler.payload_type())
|
|
pkt.payload = bytes([0xF5, 0x99])
|
|
await router._route_packet(pkt)
|
|
daemon.login_helper.process_login_packet.assert_awaited_once()
|
|
|
|
async def test_route_text_to_helper_marks_processed(self):
|
|
daemon = _make_daemon()
|
|
daemon.text_helper = MagicMock()
|
|
daemon.text_helper.process_text_packet = AsyncMock(return_value=True)
|
|
daemon.repeater_handler.storage = MagicMock()
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(TextMessageHandler.payload_type())
|
|
pkt.payload = bytes([0xEE, 0x01])
|
|
await router._route_packet(pkt)
|
|
daemon.text_helper.process_text_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_route_text_hash_collision_offers_to_both(self):
|
|
"""A dest hash shared by a companion and a room-server text identity must
|
|
reach both handlers so a companion never shadows a room-server message."""
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0xF5: bridge}
|
|
daemon.text_helper = MagicMock()
|
|
daemon.text_helper.handlers = {0xF5: MagicMock()}
|
|
daemon.text_helper.process_text_packet = AsyncMock(return_value=True)
|
|
daemon.repeater_handler = AsyncMock()
|
|
daemon.repeater_handler.storage = MagicMock()
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(TextMessageHandler.payload_type())
|
|
pkt.payload = bytes([0xF5, 0x01])
|
|
await router._route_packet(pkt)
|
|
bridge.process_received_packet.assert_awaited_once()
|
|
daemon.text_helper.process_text_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_route_text_companion_only_skips_text_helper(self):
|
|
"""No collision: a companion-owned text hash must not also hit text_helper."""
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0xEE: bridge}
|
|
daemon.text_helper = MagicMock()
|
|
daemon.text_helper.handlers = {} # no room-server text identity here
|
|
daemon.text_helper.process_text_packet = AsyncMock(return_value=False)
|
|
daemon.repeater_handler = AsyncMock()
|
|
daemon.repeater_handler.storage = MagicMock()
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(TextMessageHandler.payload_type())
|
|
pkt.payload = bytes([0xEE, 0x01])
|
|
await router._route_packet(pkt)
|
|
bridge.process_received_packet.assert_awaited_once()
|
|
daemon.text_helper.process_text_packet.assert_not_awaited()
|
|
|
|
async def test_route_ack_delivers_to_all_bridges_and_engine(self):
|
|
daemon = _make_daemon()
|
|
b1 = _make_bridge()
|
|
b2 = _make_bridge()
|
|
daemon.companion_bridges = {0x01: b1, 0x02: b2}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(AckHandler.payload_type())
|
|
await router._route_packet(pkt)
|
|
b1.process_received_packet.assert_awaited_once()
|
|
b2.process_received_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_route_ack_registers_crc_with_dispatcher(self):
|
|
"""Discrete ACKs must feed dispatcher ACK matching or wait_for_ack never resolves."""
|
|
daemon = _make_daemon()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(AckHandler.payload_type())
|
|
# 6-byte firmware ACK: 4-byte CRC (LE) + ext-attempt + random byte
|
|
pkt.payload = bytes.fromhex("4dabaf95") + b"\x00\x7f"
|
|
await router._route_packet(pkt)
|
|
daemon.dispatcher._register_ack_received.assert_awaited_once_with(0x95AFAB4D)
|
|
|
|
async def test_route_ack_short_payload_not_registered(self):
|
|
daemon = _make_daemon()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(AckHandler.payload_type())
|
|
pkt.payload = b"\x01\x02"
|
|
await router._route_packet(pkt)
|
|
daemon.dispatcher._register_ack_received.assert_not_awaited()
|
|
|
|
async def test_route_locally_injected_ack_registers_crc(self):
|
|
"""ACKs from same-instance companions never cross the radio: the injected
|
|
packet re-entering the router is the only chance to resolve local waiters
|
|
(e.g. a room server pushing to a virtual companion on this repeater)."""
|
|
daemon = _make_daemon()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(AckHandler.payload_type())
|
|
pkt.payload = b"\xaa\xbb\xcc\xdd"
|
|
pkt._injected_for_tx = True
|
|
await router._route_packet(pkt)
|
|
daemon.dispatcher._register_ack_received.assert_awaited_once_with(0xDDCCBBAA)
|
|
daemon.repeater_handler.assert_not_awaited() # already transmitted
|
|
|
|
async def test_inject_packet_waits_on_expected_ack_crc(self):
|
|
"""Callers that know the crypto ACK CRC pass it; packet.get_crc() is a
|
|
packet-hash CRC no ACK sender ever produces."""
|
|
daemon = _make_daemon()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(TextMessageHandler.payload_type())
|
|
ok = await router.inject_packet(
|
|
pkt, wait_for_ack=True, expected_crc=0x1234ABCD, ack_timeout_s=12.0
|
|
)
|
|
self.assertTrue(ok)
|
|
daemon.dispatcher.wait_for_ack.assert_awaited_once_with(0x1234ABCD, timeout=12.0)
|
|
pkt.get_crc.assert_not_called()
|
|
|
|
async def test_route_path_to_local_identity_runs_path_helper_despite_bridges(self):
|
|
"""PATH addressed to a room server/repeater identity must reach the path
|
|
helper (out_path update + embedded ACK) even when companion bridges exist;
|
|
previously the all-bridges branch swallowed it."""
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
daemon.path_helper = MagicMock()
|
|
daemon.path_helper.acl_dict = {0x48: MagicMock()}
|
|
daemon.path_helper.process_path_packet = AsyncMock(return_value=False)
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(PathHandler.payload_type())
|
|
pkt.payload = bytes([0x48, 0x77, 0xAA, 0xBB])
|
|
await router._route_packet(pkt)
|
|
daemon.path_helper.process_path_packet.assert_awaited_once_with(pkt)
|
|
|
|
async def test_authenticated_flood_path_skips_engine(self):
|
|
daemon = _make_daemon()
|
|
daemon.path_helper = MagicMock()
|
|
daemon.path_helper.process_path_packet = AsyncMock(return_value=True)
|
|
bridge = _make_bridge()
|
|
bridge.process_received_packet = AsyncMock(return_value=HandlerResult.consumed())
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(PathHandler.payload_type())
|
|
pkt.payload = bytes([0x01, 0x22])
|
|
|
|
await router._route_packet(pkt)
|
|
|
|
bridge.process_received_packet.assert_awaited_once_with(pkt)
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_unauthenticated_flood_path_reaches_engine(self):
|
|
daemon = _make_daemon()
|
|
daemon.path_helper = MagicMock()
|
|
daemon.path_helper.process_path_packet = AsyncMock(return_value=False)
|
|
bridge = _make_bridge()
|
|
bridge.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(PathHandler.payload_type())
|
|
pkt.payload = bytes([0x01, 0x22])
|
|
|
|
await router._route_packet(pkt)
|
|
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_route_path_dedupes_companion_delivery(self):
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(PathHandler.payload_type())
|
|
pkt.payload = bytes([0x01, 0xAA])
|
|
await router._route_packet(pkt)
|
|
await router._route_packet(pkt)
|
|
bridge.process_received_packet.assert_awaited_once()
|
|
self.assertEqual(daemon.repeater_handler.await_count, 2)
|
|
|
|
async def test_route_login_response_final_hop_skips_engine(self):
|
|
daemon = _make_daemon()
|
|
b1 = _make_bridge()
|
|
daemon.companion_bridges = {0x01: b1}
|
|
daemon.local_hash = 0xFF
|
|
daemon.repeater_handler.storage = MagicMock()
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(LoginResponseHandler.payload_type())
|
|
pkt.header = ROUTE_TYPE_DIRECT
|
|
pkt.path = bytearray()
|
|
pkt.payload = bytes([0xFF, 0x22])
|
|
await router._route_packet(pkt)
|
|
b1.process_received_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_authenticated_flood_response_skips_engine(self):
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
bridge.process_received_packet = AsyncMock(return_value=HandlerResult.consumed())
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(LoginResponseHandler.payload_type())
|
|
pkt.payload = bytes([0x01, 0x22])
|
|
|
|
await router._route_packet(pkt)
|
|
|
|
bridge.process_received_packet.assert_awaited_once_with(pkt)
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_unauthenticated_flood_response_reaches_engine(self):
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
bridge.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(LoginResponseHandler.payload_type())
|
|
pkt.payload = bytes([0x01, 0x22])
|
|
|
|
await router._route_packet(pkt)
|
|
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_route_protocol_response_final_hop_skips_engine(self):
|
|
daemon = _make_daemon()
|
|
b1 = _make_bridge()
|
|
daemon.companion_bridges = {0x01: b1}
|
|
daemon.repeater_handler.storage = MagicMock()
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(ProtocolResponseHandler.payload_type())
|
|
pkt.header = ROUTE_TYPE_DIRECT
|
|
pkt.path = bytearray()
|
|
# PathHandler and ProtocolResponseHandler currently share payload type=8.
|
|
# Patch PathHandler type here so ProtocolResponse branch is reachable.
|
|
with patch("repeater.packet_router.PathHandler.payload_type", return_value=0x55):
|
|
await router._route_packet(pkt)
|
|
self.assertGreaterEqual(b1.process_received_packet.await_count, 1)
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_route_protocol_request_final_hop_skips_engine(self):
|
|
daemon = _make_daemon()
|
|
b1 = _make_bridge()
|
|
daemon.companion_bridges = {0x01: b1}
|
|
daemon.repeater_handler.storage = MagicMock()
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(ProtocolRequestHandler.payload_type())
|
|
pkt.header = ROUTE_TYPE_DIRECT
|
|
pkt.path = bytearray()
|
|
pkt.payload = bytes([0xAA, 0xBB])
|
|
await router._route_packet(pkt)
|
|
b1.process_received_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_route_protocol_request_companion_collision_forwards(self):
|
|
"""A REQ whose dest prefix matches a companion but does not decrypt for
|
|
it must be forwarded, not swallowed."""
|
|
daemon = _make_daemon()
|
|
b1 = _make_bridge()
|
|
# Collision: the packet is not really for this companion.
|
|
b1.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
|
|
daemon.companion_bridges = {0x01: b1}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(ProtocolRequestHandler.payload_type())
|
|
pkt.header = 0x00 # transport-flood: not a direct final hop
|
|
pkt.path = bytearray([0xAA])
|
|
pkt.payload = bytes([0x01, 0xBB])
|
|
await router._route_packet(pkt)
|
|
b1.process_received_packet.assert_awaited_once()
|
|
# Not consumed -> handed to the forwarding engine.
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_route_text_companion_collision_forwards(self):
|
|
"""A TXT_MSG whose dest prefix matches a companion but does not decrypt
|
|
for it must be forwarded, not swallowed."""
|
|
daemon = _make_daemon()
|
|
b1 = _make_bridge()
|
|
b1.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
|
|
daemon.companion_bridges = {0x01: b1}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(TextMessageHandler.payload_type())
|
|
pkt.header = 0x00
|
|
pkt.path = bytearray([0xAA])
|
|
pkt.payload = bytes([0x01, 0xBB])
|
|
await router._route_packet(pkt)
|
|
b1.process_received_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_route_text_tries_all_candidates_room_server_wins(self):
|
|
"""A companion and a room-server text identity share a hash. The router
|
|
must try both; when the companion fails to decrypt but the room server
|
|
authenticates, the packet is consumed (not forwarded)."""
|
|
daemon = _make_daemon()
|
|
companion = _make_bridge()
|
|
companion.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
|
|
daemon.companion_bridges = {0x42: companion}
|
|
daemon.text_helper = MagicMock()
|
|
daemon.text_helper.handlers = {0x42: {"name": "room-a"}}
|
|
daemon.text_helper.process_text_packet = AsyncMock(return_value=True)
|
|
daemon.repeater_handler.storage = MagicMock()
|
|
daemon.repeater_handler.record_packet_only = MagicMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(TextMessageHandler.payload_type())
|
|
pkt.header = 0x00
|
|
pkt.path = bytearray([0xAA])
|
|
pkt.payload = bytes([0x42, 0xBB])
|
|
await router._route_packet(pkt)
|
|
# Both local candidates were tried; the room server consumed it.
|
|
companion.process_received_packet.assert_awaited_once()
|
|
daemon.text_helper.process_text_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_route_text_all_local_candidates_fail_forwards(self):
|
|
"""When neither the companion nor the room-server identity at the hash
|
|
authenticates, the packet is left for the forwarding engine."""
|
|
daemon = _make_daemon()
|
|
companion = _make_bridge()
|
|
companion.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
|
|
daemon.companion_bridges = {0x42: companion}
|
|
daemon.text_helper = MagicMock()
|
|
daemon.text_helper.handlers = {0x42: {"name": "room-a"}}
|
|
daemon.text_helper.process_text_packet = AsyncMock(return_value=False)
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(TextMessageHandler.payload_type())
|
|
pkt.header = 0x00
|
|
pkt.path = bytearray([0xAA])
|
|
pkt.payload = bytes([0x42, 0xBB])
|
|
await router._route_packet(pkt)
|
|
companion.process_received_packet.assert_awaited_once()
|
|
daemon.text_helper.process_text_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_route_group_text_delivers_and_forwards(self):
|
|
daemon = _make_daemon()
|
|
b1 = _make_bridge()
|
|
daemon.companion_bridges = {0x01: b1}
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet(GroupTextHandler.payload_type())
|
|
await router._route_packet(pkt)
|
|
b1.process_received_packet.assert_awaited_once()
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_direct_intermediate_bypasses_all_local_payload_handlers(self):
|
|
"""Direct intermediate hops must reach the engine before local payload handling."""
|
|
payload_types = (
|
|
AdvertHandler.payload_type(),
|
|
LoginServerHandler.payload_type(),
|
|
TextMessageHandler.payload_type(),
|
|
PathHandler.payload_type(),
|
|
LoginResponseHandler.payload_type(),
|
|
ProtocolRequestHandler.payload_type(),
|
|
GroupTextHandler.payload_type(),
|
|
PAYLOAD_TYPE_GRP_DATA,
|
|
)
|
|
for route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
|
|
for payload_type in payload_types:
|
|
with self.subTest(route_type=route_type, payload_type=payload_type):
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
daemon.advert_helper = MagicMock(process_advert_packet=AsyncMock())
|
|
daemon.login_helper = MagicMock(
|
|
handlers={0x01: object()}, process_login_packet=AsyncMock(return_value=True)
|
|
)
|
|
daemon.text_helper = MagicMock(
|
|
handlers={0x01: object()}, process_text_packet=AsyncMock(return_value=True)
|
|
)
|
|
daemon.path_helper = MagicMock(process_path_packet=AsyncMock(return_value=True))
|
|
daemon.protocol_request_helper = MagicMock(
|
|
handlers={0x01: object()},
|
|
process_request_packet=AsyncMock(return_value=True),
|
|
)
|
|
router = PacketRouter(daemon)
|
|
packet = _make_packet(payload_type)
|
|
packet.header = (payload_type << 2) | route_type
|
|
packet.path = bytearray([0x42])
|
|
packet.payload = bytes([0x01, 0x02, 0x03, 0x04])
|
|
|
|
await router._route_packet(packet)
|
|
|
|
bridge.process_received_packet.assert_not_awaited()
|
|
daemon.advert_helper.process_advert_packet.assert_not_awaited()
|
|
daemon.login_helper.process_login_packet.assert_not_awaited()
|
|
daemon.text_helper.process_text_packet.assert_not_awaited()
|
|
daemon.path_helper.process_path_packet.assert_not_awaited()
|
|
daemon.protocol_request_helper.process_request_packet.assert_not_awaited()
|
|
daemon.repeater_handler.assert_awaited_once_with(packet, unittest.mock.ANY)
|
|
|
|
async def test_direct_intermediate_firmware_vectors_bypass_local_text_candidates(self):
|
|
"""Decoded direct and transport-direct wire vectors skip local text handling."""
|
|
# header | [transport codes] | path_len | remaining path | payload
|
|
vectors = (
|
|
b"\x0a\x01\x42\x01\x02\x03\x04",
|
|
b"\x0b\x34\x12\x78\x56\x01\x42\x01\x02\x03\x04",
|
|
)
|
|
for wire in vectors:
|
|
with self.subTest(wire=wire.hex()):
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
daemon.text_helper = MagicMock(
|
|
handlers={0x01: object()}, process_text_packet=AsyncMock(return_value=True)
|
|
)
|
|
router = PacketRouter(daemon)
|
|
packet = Packet()
|
|
packet.read_from(wire)
|
|
|
|
await router._route_packet(packet)
|
|
|
|
bridge.process_received_packet.assert_not_awaited()
|
|
daemon.text_helper.process_text_packet.assert_not_awaited()
|
|
daemon.repeater_handler.assert_awaited_once_with(packet, unittest.mock.ANY)
|
|
|
|
async def test_final_direct_firmware_vectors_deliver_to_local_companion(self):
|
|
"""Decoded zero-hop direct vectors reach their local companion rather than forwarding."""
|
|
# header | [transport codes] | path_len=0 | dest hash | source hash | payload
|
|
vectors = (
|
|
b"\x0a\x00\x01\x02\x03\x04",
|
|
b"\x0b\x34\x12\x78\x56\x00\x01\x02\x03\x04",
|
|
)
|
|
for wire in vectors:
|
|
with self.subTest(wire=wire.hex()):
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
bridge.process_received_packet = AsyncMock(return_value=HandlerResult.consumed())
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
router = PacketRouter(daemon)
|
|
packet = Packet()
|
|
packet.read_from(wire)
|
|
|
|
await router._route_packet(packet)
|
|
|
|
bridge.process_received_packet.assert_awaited_once_with(packet)
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_direct_intermediate_ack_notifies_waiter_without_companion_delivery(self):
|
|
"""MeshCore's early ACK notification does not make an intermediate hop a recipient."""
|
|
daemon = _make_daemon()
|
|
bridge = _make_bridge()
|
|
daemon.companion_bridges = {0x01: bridge}
|
|
router = PacketRouter(daemon)
|
|
packet = _make_packet(AckHandler.payload_type())
|
|
packet.header = (AckHandler.payload_type() << 2) | ROUTE_TYPE_DIRECT
|
|
packet.path = bytearray([0x42])
|
|
packet.payload = bytes.fromhex("4dabaf95") + b"\x00\x7f"
|
|
|
|
await router._route_packet(packet)
|
|
|
|
daemon.dispatcher._register_ack_received.assert_awaited_once_with(0x95AFAB4D)
|
|
bridge.process_received_packet.assert_not_awaited()
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_direct_intermediate_control_high_bit_is_released_without_local_delivery(self):
|
|
"""A high-bit CONTROL packet with remaining direct hops is not locally processed."""
|
|
daemon = _make_daemon()
|
|
daemon.discovery_helper = MagicMock(control_handler=AsyncMock())
|
|
daemon.deliver_control_data = AsyncMock()
|
|
router = PacketRouter(daemon)
|
|
packet = _make_packet(ControlHandler.payload_type())
|
|
packet.header = (ControlHandler.payload_type() << 2) | ROUTE_TYPE_DIRECT
|
|
packet.path = bytearray([0x42])
|
|
packet.payload = b"\x90\x00\x01\x00\x00\x00"
|
|
|
|
await router._route_packet(packet)
|
|
|
|
daemon.discovery_helper.control_handler.assert_not_awaited()
|
|
daemon.deliver_control_data.assert_not_awaited()
|
|
packet.mark_do_not_retransmit.assert_not_called()
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_direct_intermediate_control_without_high_bit_reaches_engine(self):
|
|
"""Only high-bit CONTROL is zero-hop-only; other direct control remains routing traffic."""
|
|
daemon = _make_daemon()
|
|
daemon.discovery_helper = MagicMock(control_handler=AsyncMock())
|
|
daemon.deliver_control_data = AsyncMock()
|
|
router = PacketRouter(daemon)
|
|
packet = _make_packet(ControlHandler.payload_type())
|
|
packet.header = (ControlHandler.payload_type() << 2) | ROUTE_TYPE_DIRECT
|
|
packet.path = bytearray([0x42])
|
|
packet.payload = b"\x01\x00"
|
|
|
|
await router._route_packet(packet)
|
|
|
|
daemon.discovery_helper.control_handler.assert_not_awaited()
|
|
daemon.deliver_control_data.assert_not_awaited()
|
|
packet.mark_do_not_retransmit.assert_not_called()
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_direct_intermediate_multipart_ack_does_not_notify_local_waiter(self):
|
|
"""A direct intermediate MULTIPART ACK remains forwarding traffic, not a local ACK."""
|
|
daemon = _make_daemon()
|
|
router = PacketRouter(daemon)
|
|
packet = _make_packet(MultipartAckHandler.payload_type())
|
|
packet.header = (MultipartAckHandler.payload_type() << 2) | ROUTE_TYPE_DIRECT
|
|
packet.path = bytearray([0x42])
|
|
packet.payload = b"\x03" + bytes.fromhex("4dabaf95")
|
|
|
|
await router._route_packet(packet)
|
|
|
|
daemon.dispatcher._register_ack_received.assert_not_awaited()
|
|
daemon.repeater_handler.assert_awaited_once()
|
|
|
|
async def test_direct_intermediate_trace_uses_trace_handler(self):
|
|
"""TRACE keeps its MeshCore-specific forwarding path ahead of generic direct routing."""
|
|
daemon = _make_daemon()
|
|
daemon.trace_helper = MagicMock(process_trace_packet=AsyncMock())
|
|
router = PacketRouter(daemon)
|
|
packet = _make_packet(TraceHandler.payload_type())
|
|
packet.header = (TraceHandler.payload_type() << 2) | ROUTE_TYPE_DIRECT
|
|
packet.path = bytearray([0x42])
|
|
|
|
await router._route_packet(packet)
|
|
|
|
daemon.trace_helper.process_trace_packet.assert_awaited_once_with(packet)
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
async def test_direct_intermediate_trace_without_helper_is_released(self):
|
|
"""TRACE remains MeshCore-owned even if a helper is unavailable during startup."""
|
|
daemon = _make_daemon()
|
|
daemon.trace_helper = None
|
|
router = PacketRouter(daemon)
|
|
packet = _make_packet(TraceHandler.payload_type())
|
|
packet.header = (TraceHandler.payload_type() << 2) | ROUTE_TYPE_DIRECT
|
|
packet.path = bytearray([0x42])
|
|
|
|
await router._route_packet(packet)
|
|
|
|
daemon.repeater_handler.assert_not_awaited()
|
|
|
|
|
|
class TestInjectedTxRawEcho(unittest.IsolatedAsyncioTestCase):
|
|
"""inject_packet echoes local TX to companion clients as raw RX (0x88)."""
|
|
|
|
async def test_inject_packet_echoes_raw_tx_to_companions(self):
|
|
"""Successful local TX is pushed via _on_raw_rx_for_companions with snr=0/rssi=0."""
|
|
daemon = _make_daemon()
|
|
daemon._on_raw_rx_for_companions = AsyncMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet()
|
|
pkt.write_to.return_value = b"\x10\x20\x30"
|
|
|
|
ok = await router.inject_packet(pkt)
|
|
|
|
self.assertTrue(ok)
|
|
daemon._on_raw_rx_for_companions.assert_awaited_once_with(
|
|
b"\x10\x20\x30", 0, 0.0, exclude_hash=None
|
|
)
|
|
|
|
async def test_inject_packet_excludes_originating_companion(self):
|
|
"""A companion's own TX is echoed with its hash excluded (no self-echo)."""
|
|
daemon = _make_daemon()
|
|
daemon._on_raw_rx_for_companions = AsyncMock()
|
|
router = PacketRouter(daemon)
|
|
pkt = _make_packet()
|
|
pkt.write_to.return_value = b"\xaa\xbb"
|
|
|
|
ok = await router.inject_packet(pkt, origin_hash="0x1a")
|
|
|
|
self.assertTrue(ok)
|
|
daemon._on_raw_rx_for_companions.assert_awaited_once_with(
|
|
b"\xaa\xbb", 0, 0.0, exclude_hash="0x1a"
|
|
)
|
|
|
|
async def test_inject_packet_no_echo_when_tx_fails(self):
|
|
"""A failed local transmission must not echo a raw RX frame."""
|
|
daemon = _make_daemon()
|
|
daemon.repeater_handler = AsyncMock(return_value=False)
|
|
daemon._on_raw_rx_for_companions = AsyncMock()
|
|
router = PacketRouter(daemon)
|
|
|
|
ok = await router.inject_packet(_make_packet())
|
|
|
|
self.assertFalse(ok)
|
|
daemon._on_raw_rx_for_companions.assert_not_awaited()
|
|
|
|
async def test_inject_packet_survives_echo_failure(self):
|
|
"""An error while echoing must not fail the injection."""
|
|
daemon = _make_daemon()
|
|
daemon._on_raw_rx_for_companions = AsyncMock(side_effect=RuntimeError("boom"))
|
|
router = PacketRouter(daemon)
|
|
|
|
ok = await router.inject_packet(_make_packet())
|
|
|
|
self.assertTrue(ok)
|
|
daemon._on_raw_rx_for_companions.assert_awaited_once()
|
|
|
|
async def test_inject_packet_without_echo_hook(self):
|
|
"""Injection succeeds even if the daemon has no raw-RX companion hook."""
|
|
daemon = _make_daemon()
|
|
daemon._on_raw_rx_for_companions = None
|
|
router = PacketRouter(daemon)
|
|
|
|
ok = await router.inject_packet(_make_packet())
|
|
|
|
self.assertTrue(ok)
|