diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7005a0e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,111 @@ +"""Shared fixtures and RNS/meshcore mock scaffolding for unit tests. + +The production code imports ``RNS`` and ``meshcore`` at module level. Neither +library is available in the test environment, so we inject lightweight stubs +into ``sys.modules`` *before* any production module is imported. +""" + +import sys +import types +from unittest.mock import MagicMock + +import pytest + + +# --------------------------------------------------------------------------- +# RNS stub +# --------------------------------------------------------------------------- + +def _build_rns_stub(): + rns = types.ModuleType("RNS") + rns.LOG_CRITICAL = 50 + rns.LOG_ERROR = 40 + rns.LOG_WARNING = 30 + rns.LOG_INFO = 20 + rns.LOG_DEBUG = 10 + rns.log = MagicMock() + rns.panic = MagicMock() + + # RNS.Interfaces.Interface.Interface base class + interfaces_pkg = types.ModuleType("RNS.Interfaces") + interface_mod = types.ModuleType("RNS.Interfaces.Interface") + + class _StubInterface: + DEFAULT_IFAC_SIZE = 8 + DEFAULT_IFAC_NAME = "" + DEFAULT_IFAC_NETKEY = b"" + HW_MTU = 500 + + def __init__(self): + self.online = False + self.detached = False + self.txb = 0 + self.rxb = 0 + self.owner = None + self.name = "" + + @staticmethod + def get_config_obj(cfg): + return cfg + + def processIncoming(self, data): + pass + + interface_mod.Interface = _StubInterface + interfaces_pkg.Interface = interface_mod + + rns.Interfaces = interfaces_pkg + + sys.modules["RNS"] = rns + sys.modules["RNS.Interfaces"] = interfaces_pkg + sys.modules["RNS.Interfaces.Interface"] = interface_mod + return rns + + +# --------------------------------------------------------------------------- +# meshcore stub +# --------------------------------------------------------------------------- + +def _build_meshcore_stub(): + mc = types.ModuleType("meshcore") + + class _EventType: + SELF_INFO = "SELF_INFO" + OK = "OK" + ERROR = "ERROR" + CHANNEL_MSG_RECV = "CHANNEL_MSG_RECV" + CONTACT_MSG_RECV = "CONTACT_MSG_RECV" + NEW_CONTACT = "NEW_CONTACT" + RX_LOG_DATA = "RX_LOG_DATA" + MSG_SENT = "MSG_SENT" + ACK = "ACK" + + mc.EventType = _EventType + mc.MeshCore = MagicMock() + sys.modules["meshcore"] = mc + return mc + + +# --------------------------------------------------------------------------- +# Install stubs once at import time so production modules can be imported +# --------------------------------------------------------------------------- + +_rns_stub = _build_rns_stub() +_mc_stub = _build_meshcore_stub() + +# MeshCore_Interface.py expects bare ``Interface`` and ``RNS`` names injected +# by Reticulum's exec() loader. We make them importable by patching builtins. +import builtins +builtins.RNS = _rns_stub +builtins.Interface = _rns_stub.Interfaces.Interface.Interface + + +# --------------------------------------------------------------------------- +# Pytest fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _reset_rns_log(): + """Clear RNS.log call history between tests.""" + _rns_stub.log.reset_mock() + yield diff --git a/tests/test_channel_interface.py b/tests/test_channel_interface.py new file mode 100644 index 0000000..e25f485 --- /dev/null +++ b/tests/test_channel_interface.py @@ -0,0 +1,298 @@ +"""Tests for MeshCore_Channel_Interface tunnel pipeline, deduplication, +fragment reassembly, and outgoing path. +""" + +import asyncio +import base64 +import hashlib +import queue +import socket +import threading +import time +from collections import OrderedDict +from unittest.mock import MagicMock + +import pytest + +from Interface.MeshCore_Channel_Interface import ( + MeshCore_Channel_Interface, + _PacketHandler, +) + + +def _make_interface(**overrides): + """Build a MeshCore_Channel_Interface with __init__ bypassed.""" + iface = object.__new__(MeshCore_Channel_Interface) + + # Copy class-level constants + for attr in ("MAGIC", "HEADER_SIZE", "MSG_PREFIX", "OUTQUEUE_MAXSIZE", + "WORKER_POLL_S", "SETUP_TIMEOUT_S"): + setattr(iface, attr, getattr(MeshCore_Channel_Interface, attr)) + + iface.owner = MagicMock() + iface.name = "test-chan" + iface.online = True + iface.detached = False + iface.txb = 0 + iface.rxb = 0 + iface.debug_level = overrides.get("debug_level", "info") + iface.channel_secret_hex = overrides.get( + "channel_secret_hex", "c4d2b6c8254e3b11200f57e95dcb1197" + ) + iface.fragment_timeout_s = 3600 + iface.rate_limit_bps = 0 + iface.fragment_delay_s = 0.0 + iface.channel_idx = 39 + + # Derive src_id same way as production + raw = f"{iface.channel_secret_hex}:{socket.gethostname()}" + iface._own_src_id = hashlib.sha256(raw.encode()).digest()[:4] + + iface._pkt_id = 0 + iface._pkt_id_lock = threading.Lock() + iface._outqueue = queue.Queue(maxsize=512) + + iface._assembly = {} + iface._assembly_meta = {} + iface._asm_lock = threading.Lock() + + iface._seen_pkts = OrderedDict() + iface._seen_lock = threading.Lock() + + return iface + + +def _make_channel_fragment(data, src_id, pkt_id, frag_idx=0, frag_total=1): + """Build a channel-format fragment string (9-byte header).""" + magic = b"RN" + header = ( + magic + + bytes([frag_idx & 0xFF]) + + src_id + + bytes([pkt_id & 0xFF, frag_total & 0xFF]) + ) + encoded = base64.urlsafe_b64encode(header + data).rstrip(b"=").decode() + return "RNS:" + encoded + + +# ═══════════════════════════════════════════════════════════════════════════ +# _derive_local_src_id +# ═══════════════════════════════════════════════════════════════════════════ + +class TestDeriveLocalSrcId: + + def test_deterministic(self): + iface = _make_interface() + expected = hashlib.sha256( + f"{iface.channel_secret_hex}:{socket.gethostname()}".encode() + ).digest()[:4] + assert iface._own_src_id == expected + + def test_different_secret_different_id(self): + iface1 = _make_interface(channel_secret_hex="aaaa" * 8) + iface2 = _make_interface(channel_secret_hex="bbbb" * 8) + assert iface1._own_src_id != iface2._own_src_id + + +# ═══════════════════════════════════════════════════════════════════════════ +# _process_tunnel_text +# ═══════════════════════════════════════════════════════════════════════════ + +class TestChannelProcessTunnelText: + + def _run(self, coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + def test_single_fragment_delivery(self): + iface = _make_interface() + remote_src = b"\xDE\xAD\xBE\xEF" + data = b"\x01\x02\x03\x04\x05" + text = _make_channel_fragment(data, remote_src, pkt_id=1) + + self._run(iface._process_tunnel_text(text)) + iface.owner.inbound.assert_called_once() + assert iface.owner.inbound.call_args[0][0] == data + + def test_multi_fragment_reassembly(self): + iface = _make_interface() + remote_src = b"\x01\x02\x03\x04" + original = bytes(range(100)) + chunk1 = original[:50] + chunk2 = original[50:] + + frag1 = _make_channel_fragment(chunk1, remote_src, pkt_id=5, frag_idx=0, frag_total=2) + frag2 = _make_channel_fragment(chunk2, remote_src, pkt_id=5, frag_idx=1, frag_total=2) + + self._run(iface._process_tunnel_text(frag1)) + iface.owner.inbound.assert_not_called() + + self._run(iface._process_tunnel_text(frag2)) + iface.owner.inbound.assert_called_once() + assert iface.owner.inbound.call_args[0][0] == original + + def test_own_echo_dropped(self): + iface = _make_interface() + data = b"\x01\x02" + text = _make_channel_fragment(data, iface._own_src_id, pkt_id=1) + + self._run(iface._process_tunnel_text(text)) + iface.owner.inbound.assert_not_called() + + def test_bad_magic_dropped(self): + iface = _make_interface() + # Use wrong magic bytes + header = b"XX" + bytes([0, 0xDE, 0xAD, 0xBE, 0xEF, 1, 1]) + encoded = base64.urlsafe_b64encode(header + b"\x01").rstrip(b"=").decode() + text = "RNS:" + encoded + + self._run(iface._process_tunnel_text(text)) + iface.owner.inbound.assert_not_called() + + def test_duplicate_packet_deduplicated(self): + iface = _make_interface() + remote_src = b"\xAA\xBB\xCC\xDD" + data = b"\x01" + text = _make_channel_fragment(data, remote_src, pkt_id=10) + + self._run(iface._process_tunnel_text(text)) + assert iface.owner.inbound.call_count == 1 + + self._run(iface._process_tunnel_text(text)) + assert iface.owner.inbound.call_count == 1 + + def test_sender_prefix_stripped(self): + iface = _make_interface() + remote_src = b"\x11\x22\x33\x44" + data = b"\x05\x06\x07" + base_frag = _make_channel_fragment(data, remote_src, pkt_id=2) + # Simulate firmware-prepended sender name + text = "NodeName: " + base_frag + + self._run(iface._process_tunnel_text(text)) + iface.owner.inbound.assert_called_once() + assert iface.owner.inbound.call_args[0][0] == data + + def test_no_rns_prefix_silently_dropped(self): + iface = _make_interface() + self._run(iface._process_tunnel_text("Hello, this is not RNS")) + iface.owner.inbound.assert_not_called() + + def test_invalid_base64_dropped(self): + iface = _make_interface() + self._run(iface._process_tunnel_text("RNS:not_valid_base64!!!")) + iface.owner.inbound.assert_not_called() + + def test_frame_too_short_dropped(self): + iface = _make_interface() + short = base64.urlsafe_b64encode(b"\x00\x01").rstrip(b"=").decode() + self._run(iface._process_tunnel_text("RNS:" + short)) + iface.owner.inbound.assert_not_called() + + def test_zero_frag_total_dropped(self): + iface = _make_interface() + header = b"RN" + bytes([0, 0xAA, 0xBB, 0xCC, 0xDD, 1, 0]) # total=0 + encoded = base64.urlsafe_b64encode(header + b"\x01").rstrip(b"=").decode() + self._run(iface._process_tunnel_text("RNS:" + encoded)) + iface.owner.inbound.assert_not_called() + + def test_frag_idx_exceeding_total_dropped(self): + iface = _make_interface() + header = b"RN" + bytes([5, 0xAA, 0xBB, 0xCC, 0xDD, 1, 3]) # idx=5, total=3 + encoded = base64.urlsafe_b64encode(header + b"\x01").rstrip(b"=").decode() + self._run(iface._process_tunnel_text("RNS:" + encoded)) + iface.owner.inbound.assert_not_called() + + def test_empty_reassembled_packet_dropped(self): + iface = _make_interface() + remote_src = b"\xEE\xFF\x00\x11" + # Single fragment with empty payload + text = _make_channel_fragment(b"", remote_src, pkt_id=7) + self._run(iface._process_tunnel_text(text)) + iface.owner.inbound.assert_not_called() + + def test_duplicate_fragment_ignored(self): + iface = _make_interface() + remote_src = b"\x12\x34\x56\x78" + chunk1 = bytes(range(50)) + frag = _make_channel_fragment(chunk1, remote_src, pkt_id=3, frag_idx=0, frag_total=2) + + # Send same fragment twice — assembly should still work + self._run(iface._process_tunnel_text(frag)) + self._run(iface._process_tunnel_text(frag)) + + # Still waiting for frag_idx=1 + iface.owner.inbound.assert_not_called() + key = (remote_src.hex(), 3) + assert len(iface._assembly.get(key, {})) == 1 + + +# ═══════════════════════════════════════════════════════════════════════════ +# processOutgoing +# ═══════════════════════════════════════════════════════════════════════════ + +class TestChannelProcessOutgoing: + + def test_outgoing_enqueues_fragments(self): + iface = _make_interface() + data = b"\x01\x02\x03\x04\x05" + iface.processOutgoing(data) + + assert not iface._outqueue.empty() + assert iface.txb == len(data) + assert iface._pkt_id == 1 + + def test_outgoing_offline_drops_packet(self): + iface = _make_interface() + iface.online = False + iface.processOutgoing(b"\x01\x02\x03") + assert iface._outqueue.empty() + assert iface.txb == 0 + + def test_outgoing_multiple_fragments(self): + iface = _make_interface() + # 200 bytes at 64 byte payload → ceil(200/64) = 4 fragments + data = bytes(range(200)) + iface.processOutgoing(data) + + count = 0 + while not iface._outqueue.empty(): + iface._outqueue.get_nowait() + count += 1 + assert count == 4 + + def test_outgoing_pkt_id_wraps(self): + iface = _make_interface() + iface._pkt_id = 255 + iface.processOutgoing(b"\x00") + # Channel interface wraps pkt_id at 0xFF (8-bit) + assert iface._pkt_id == 0 + + +# ═══════════════════════════════════════════════════════════════════════════ +# processIncoming +# ═══════════════════════════════════════════════════════════════════════════ + +class TestChannelProcessIncoming: + + def test_delivers_to_owner(self): + iface = _make_interface() + data = b"\x01\x02\x03" + iface.processIncoming(data) + iface.owner.inbound.assert_called_once_with(data, iface) + assert iface.rxb == 3 + + def test_offline_does_not_deliver(self): + iface = _make_interface() + iface.online = False + iface.processIncoming(b"\x01") + iface.owner.inbound.assert_not_called() + + def test_detached_does_not_deliver(self): + iface = _make_interface() + iface.detached = True + iface.processIncoming(b"\x01") + iface.owner.inbound.assert_not_called() diff --git a/tests/test_dynamic_interface.py b/tests/test_dynamic_interface.py new file mode 100644 index 0000000..fb849cc --- /dev/null +++ b/tests/test_dynamic_interface.py @@ -0,0 +1,437 @@ +"""Tests for peer discovery, rate limiting, routing, and tunnel text processing +in MeshCore_Dynamic_Interface. + +These test methods on the interface class without starting real asyncio loops +or hardware connections. +""" + +import asyncio +import base64 +import hashlib +import struct +import threading +import time +from collections import OrderedDict +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from Interface.MeshCore_Dynamic_Interface import ( + MeshCore_Dynamic_Interface, + _PacketHandler, +) + + +def _make_interface(**overrides): + """Build a MeshCore_Dynamic_Interface with __init__ bypassed.""" + iface = object.__new__(MeshCore_Dynamic_Interface) + + # Copy all class-level constants + for attr in dir(MeshCore_Dynamic_Interface): + if not attr.startswith("__") and not callable(getattr(MeshCore_Dynamic_Interface, attr, None)): + try: + setattr(iface, attr, getattr(MeshCore_Dynamic_Interface, attr)) + except (AttributeError, TypeError): + pass + + # Instance state + iface.owner = MagicMock() + iface.name = "test" + iface.online = True + iface.detached = False + iface.txb = 0 + iface.rxb = 0 + iface.debug_level = overrides.get("debug_level", "info") + iface.can_route = overrides.get("can_route", True) + iface.allow_direct = overrides.get("allow_direct", True) + iface.payload_size = overrides.get("payload_size", 64) + iface.fragment_delay_s = 0.0 + iface.direct_frag_delay_s = 0.0 + iface.fragment_timeout_s = 300.0 + iface.rate_limit_bps = 0 + iface._announce_rate_s = overrides.get("announce_rate", 600) + iface._path_req_rate_s = overrides.get("path_req_rate", 1800) + iface._path_req_burst_window_s = overrides.get("path_req_burst_window", 60) + iface.peer_ttl_s = 86400 + iface.channel_idx = 0 + + iface._own_node_name = overrides.get("own_node_name", "TestNode") + iface._own_mc_key = overrides.get("own_mc_key", "abcdef0123456789") + + iface._mc = MagicMock() + iface._EventType = MagicMock() + iface._has_direct_api = overrides.get("has_direct_api", True) + iface._loop = None + iface._pending_resp_task = None + + iface._pkt_id = 0 + iface._pkt_id_lock = threading.Lock() + + iface._assembly = {} + iface._assembly_meta = {} + iface._asm_lock = threading.Lock() + + iface._seen_pkts = {} + iface._seen_lock = threading.Lock() + + iface._peer_table = {} + iface._reverse_peers = {} + iface._peer_last_seen = {} + iface._peer_caps = {} + iface._rns_to_mc_map = {} + iface._peer_lock = threading.Lock() + + iface._announce_sent_times = {} + iface._announce_sent_lock = threading.Lock() + iface._path_req_sent_times = {} + iface._path_req_sent_lock = threading.Lock() + + import queue + iface._outqueue = queue.Queue(maxsize=512) + + return iface + + +# ═══════════════════════════════════════════════════════════════════════════ +# _handle_bind — peer discovery parsing +# ═══════════════════════════════════════════════════════════════════════════ + +class TestHandleBind: + + def _run(self, coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + def test_bind_response_registers_peer(self): + iface = _make_interface() + text = "RemoteNode: RNSBIND:deadbeef01234567:R" + self._run(iface._handle_bind(text, bind_idx=12, req_idx=-1)) + + assert "RemoteNode" in iface._peer_table + assert iface._peer_table["RemoteNode"] == "deadbeef01234567" + assert iface._peer_caps["RemoteNode"] is True # router + + def test_bind_req_registers_peer_as_edge(self): + iface = _make_interface() + text = "EdgeNode: RNSBIND_REQ:aabbccdd11223344:E" + self._run(iface._handle_bind(text, bind_idx=-1, req_idx=10)) + + assert "EdgeNode" in iface._peer_table + assert iface._peer_caps["EdgeNode"] is False # edge + + def test_bind_ignores_own_node(self): + iface = _make_interface(own_node_name="MyNode") + text = "MyNode: RNSBIND:deadbeef01234567:R" + self._run(iface._handle_bind(text, bind_idx=8, req_idx=-1)) + + assert "MyNode" not in iface._peer_table + + def test_bind_without_capability_defaults_router(self): + iface = _make_interface() + text = "OldNode: RNSBIND:aabbccdd11223344" + self._run(iface._handle_bind(text, bind_idx=9, req_idx=-1)) + + assert iface._peer_caps["OldNode"] is True + + def test_bind_updates_reverse_peers(self): + iface = _make_interface() + mc_key = "deadbeef01234567890abcdef0000000" + text = f"Relay1: RNSBIND:{mc_key}:R" + self._run(iface._handle_bind(text, bind_idx=8, req_idx=-1)) + + assert iface._reverse_peers[mc_key] == "Relay1" + # Also stored by prefix lengths + assert iface._reverse_peers[mc_key[:8]] == "Relay1" + assert iface._reverse_peers[mc_key[:12]] == "Relay1" + + def test_bind_empty_pubkey_ignored(self): + iface = _make_interface() + text = "Node: RNSBIND::R" + self._run(iface._handle_bind(text, bind_idx=6, req_idx=-1)) + assert "Node" not in iface._peer_table + + +# ═══════════════════════════════════════════════════════════════════════════ +# _resolve_sender_key +# ═══════════════════════════════════════════════════════════════════════════ + +class TestResolveSenderKey: + + def test_exact_match(self): + iface = _make_interface() + iface._reverse_peers["abcdef01"] = "NodeA" + assert iface._resolve_sender_key("abcdef01") == "NodeA" + + def test_prefix_match(self): + iface = _make_interface() + iface._reverse_peers["abcdef0123456789"] = "NodeB" + assert iface._resolve_sender_key("abcdef01") == "NodeB" + + def test_no_match_returns_key(self): + iface = _make_interface() + assert iface._resolve_sender_key("unknown") == "unknown" + + def test_empty_returns_empty(self): + iface = _make_interface() + assert iface._resolve_sender_key("") == "" + + +# ═══════════════════════════════════════════════════════════════════════════ +# Tunnel text processing (_process_tunnel_text) +# ═══════════════════════════════════════════════════════════════════════════ + +class TestProcessTunnelText: + + def _run(self, coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + def _make_fragment(self, data, pkt_id=1, frag_idx=0, frag_total=1): + header = struct.pack(">BIB", frag_idx & 0xFF, pkt_id & 0xFFFFFFFF, frag_total & 0xFF) + encoded = base64.urlsafe_b64encode(header + data).rstrip(b"=").decode() + return "RNS:" + encoded + + def test_single_fragment_delivered(self): + iface = _make_interface() + data = b"\x01" + b"\x00" * 30 # minimal RNS-like packet + frag = self._make_fragment(data, pkt_id=42) + + self._run(iface._process_tunnel_text(frag, sender="PeerA")) + iface.owner.inbound.assert_called_once() + delivered = iface.owner.inbound.call_args[0][0] + assert delivered == data + + def test_multi_fragment_reassembly(self): + iface = _make_interface() + original = bytes(range(100)) + chunk1 = original[:50] + chunk2 = original[50:] + + frag1 = self._make_fragment(chunk1, pkt_id=7, frag_idx=0, frag_total=2) + frag2 = self._make_fragment(chunk2, pkt_id=7, frag_idx=1, frag_total=2) + + self._run(iface._process_tunnel_text(frag1, sender="PeerB")) + iface.owner.inbound.assert_not_called() + + self._run(iface._process_tunnel_text(frag2, sender="PeerB")) + iface.owner.inbound.assert_called_once() + delivered = iface.owner.inbound.call_args[0][0] + assert delivered == original + + def test_own_echo_ignored(self): + iface = _make_interface(own_node_name="MyNode") + frag = self._make_fragment(b"\x00" * 10, pkt_id=1) + + self._run(iface._process_tunnel_text(frag, sender="MyNode")) + iface.owner.inbound.assert_not_called() + + def test_duplicate_packet_rejected(self): + iface = _make_interface() + data = b"\x01" + b"\x00" * 10 + frag = self._make_fragment(data, pkt_id=99) + + self._run(iface._process_tunnel_text(frag, sender="PeerC")) + assert iface.owner.inbound.call_count == 1 + + # Send same fragment again — should be deduplicated + self._run(iface._process_tunnel_text(frag, sender="PeerC")) + assert iface.owner.inbound.call_count == 1 + + def test_invalid_base64_silently_dropped(self): + iface = _make_interface() + self._run(iface._process_tunnel_text("RNS:!!!invalid!!!", sender="PeerD")) + iface.owner.inbound.assert_not_called() + + def test_too_short_payload_dropped(self): + iface = _make_interface() + # Encode only 2 bytes — less than HEADER_SIZE (6) + short = base64.urlsafe_b64encode(b"\x00\x01").rstrip(b"=").decode() + self._run(iface._process_tunnel_text("RNS:" + short, sender="PeerE")) + iface.owner.inbound.assert_not_called() + + def test_zero_frag_total_dropped(self): + iface = _make_interface() + header = struct.pack(">BIB", 0, 1, 0) # frag_total=0 + encoded = base64.urlsafe_b64encode(header + b"\x00" * 10).rstrip(b"=").decode() + self._run(iface._process_tunnel_text("RNS:" + encoded, sender="PeerF")) + iface.owner.inbound.assert_not_called() + + def test_frag_idx_exceeding_total_dropped(self): + iface = _make_interface() + header = struct.pack(">BIB", 5, 1, 3) # idx=5, total=3 + encoded = base64.urlsafe_b64encode(header + b"\x00" * 10).rstrip(b"=").decode() + self._run(iface._process_tunnel_text("RNS:" + encoded, sender="PeerG")) + iface.owner.inbound.assert_not_called() + + +# ═══════════════════════════════════════════════════════════════════════════ +# Rate limiting in processOutgoing +# ═══════════════════════════════════════════════════════════════════════════ + +class TestRateLimiting: + + def test_announce_rate_suppresses_duplicate(self): + iface = _make_interface(announce_rate=600) + # Announce packet: ptype=0x01 + dest = bytes(range(10)) + packet = bytes([0x01, 0x00]) + dest + b"\x00" * 20 + + # Pre-seed with old timestamp so the first send goes through + iface._announce_sent_times[bytes(dest)] = time.monotonic() - 9999 + + iface.processOutgoing(packet) + first_txb = iface.txb + assert first_txb > 0 + + # Second announce to same dest within window → suppressed + iface.processOutgoing(packet) + assert iface.txb == first_txb + + def test_announce_rate_allows_different_dest(self): + iface = _make_interface(announce_rate=600) + dest1 = bytes([0x01] * 10) + dest2 = bytes([0x02] * 10) + pkt1 = bytes([0x01, 0x00]) + dest1 + b"\x00" * 20 + pkt2 = bytes([0x01, 0x00]) + dest2 + b"\x00" * 20 + + # Pre-seed both dests with old timestamps so rate limiter allows them + old_ts = time.monotonic() - 9999 + iface._announce_sent_times[bytes(dest1)] = old_ts + iface._announce_sent_times[bytes(dest2)] = old_ts + + iface.processOutgoing(pkt1) + txb_after_first = iface.txb + iface.processOutgoing(pkt2) + assert iface.txb > txb_after_first + + def test_path_req_burst_window_allows_initial(self): + iface = _make_interface(path_req_rate=1800, path_req_burst_window=60) + # Path request: ptype=DATA(0x00), dest_type=PLAIN(0x02) → flags=0x08 + dest = bytes(range(10)) + packet = bytes([0x08, 0x00]) + dest + b"\x00" * 20 + + iface.processOutgoing(packet) + assert iface.txb > 0 + + def test_path_req_within_burst_window_allowed(self): + iface = _make_interface(path_req_rate=1800, path_req_burst_window=60) + dest = bytes(range(10)) + packet = bytes([0x08, 0x00]) + dest + b"\x00" * 20 + + iface.processOutgoing(packet) + first_txb = iface.txb + # Second request within burst window should be allowed + iface.processOutgoing(packet) + assert iface.txb > first_txb + + def test_announce_rate_zero_disables_limiting(self): + iface = _make_interface(announce_rate=0) + dest = bytes(range(10)) + packet = bytes([0x01, 0x00]) + dest + b"\x00" * 20 + + iface.processOutgoing(packet) + first_txb = iface.txb + iface.processOutgoing(packet) + assert iface.txb > first_txb + + def test_offline_interface_drops_packet(self): + iface = _make_interface() + iface.online = False + packet = bytes([0x00]) + b"\x00" * 30 + iface.processOutgoing(packet) + assert iface.txb == 0 + + +# ═══════════════════════════════════════════════════════════════════════════ +# processOutgoing routing decisions +# ═══════════════════════════════════════════════════════════════════════════ + +class TestProcessOutgoingRouting: + + def test_broadcast_packet_routed_to_channel(self): + iface = _make_interface(announce_rate=0) + # Announce packet → broadcast + packet = bytes([0x01, 0x00]) + b"\x00" * 30 + iface.processOutgoing(packet) + + item = iface._outqueue.get_nowait() + assert item[0] == "channel" + + def test_unicast_with_known_route_routed_direct(self): + iface = _make_interface() + # DATA+SINGLE (flags=0x00), single-header + dest_token = bytes(range(16)) + mc_key = "deadbeef01234567" + iface._rns_to_mc_map[dest_token] = mc_key + + packet = bytes([0x00, 0x00]) + dest_token + b"\x00" * 20 + iface.processOutgoing(packet) + + item = iface._outqueue.get_nowait() + assert item[0] == "direct" + assert item[1] == mc_key + + def test_unicast_without_route_falls_back_to_channel(self): + iface = _make_interface() + packet = bytes([0x00, 0x00]) + b"\xFF" * 16 + b"\x00" * 20 + iface.processOutgoing(packet) + + item = iface._outqueue.get_nowait() + assert item[0] == "channel" + + def test_no_direct_api_forces_channel(self): + iface = _make_interface(has_direct_api=False) + dest_token = bytes(range(16)) + iface._rns_to_mc_map[dest_token] = "somekey" + + packet = bytes([0x00, 0x00]) + dest_token + b"\x00" * 20 + iface.processOutgoing(packet) + + item = iface._outqueue.get_nowait() + assert item[0] == "channel" + + def test_pkt_id_increments(self): + iface = _make_interface(announce_rate=0) + assert iface._pkt_id == 0 + packet = bytes([0x01, 0x00]) + b"\x00" * 30 + iface.processOutgoing(packet) + assert iface._pkt_id == 1 + + def test_pkt_id_wraps_at_32bit(self): + iface = _make_interface(announce_rate=0) + iface._pkt_id = 0xFFFFFFFF + packet = bytes([0x01, 0x00]) + b"\x00" * 30 + iface.processOutgoing(packet) + assert iface._pkt_id == 0 + + +# ═══════════════════════════════════════════════════════════════════════════ +# processIncoming +# ═══════════════════════════════════════════════════════════════════════════ + +class TestProcessIncoming: + + def test_delivers_to_owner(self): + iface = _make_interface() + data = b"\x01\x02\x03" + iface.processIncoming(data) + iface.owner.inbound.assert_called_once_with(data, iface) + assert iface.rxb == 3 + + def test_offline_does_not_deliver(self): + iface = _make_interface() + iface.online = False + iface.processIncoming(b"\x01") + iface.owner.inbound.assert_not_called() + + def test_detached_does_not_deliver(self): + iface = _make_interface() + iface.detached = True + iface.processIncoming(b"\x01") + iface.owner.inbound.assert_not_called() diff --git a/tests/test_legacy_packet_handler.py b/tests/test_legacy_packet_handler.py new file mode 100644 index 0000000..c93b970 --- /dev/null +++ b/tests/test_legacy_packet_handler.py @@ -0,0 +1,97 @@ +"""Tests for PacketHandler in MeshCore_Interface (legacy fragmentation).""" + +import struct + +import pytest + +from Interface.MeshCore_Interface import PacketHandler + + +class TestPacketHandler: + + def test_single_fragment_small_data(self): + data = b"small" + handler = PacketHandler(data, index=0, max_payload=200) + keys = handler.get_keys() + # Small data → 1 fragment; final fragment has negative pos + assert len(keys) == 1 + assert keys[0] < 0 # final fragment marker + + def test_fragment_metadata_format(self): + data = b"hello world" + handler = PacketHandler(data, index=5, max_payload=200) + keys = handler.get_keys() + frag = handler[keys[0]] + + # First 2 bytes are struct.pack("Bb", index, pos) + index, pos = struct.unpack("Bb", frag[:2]) + assert index == 5 + # Single fragment: pos is -(seq) where seq starts at 1 + assert pos == -1 + assert frag[2:] == data + + def test_multi_fragment_split(self): + data = bytes(range(100)) + handler = PacketHandler(data, index=1, max_payload=30) + keys = handler.get_keys() + + assert len(keys) > 1 + # Only the last key should be negative + for k in keys[:-1]: + assert k > 0 + assert keys[-1] < 0 + + def test_reassembly_from_fragments(self): + data = bytes(range(250)) + handler = PacketHandler(data, index=3, max_payload=50) + keys = handler.get_keys() + + reassembled = b"" + for k in sorted(keys, key=abs): + frag = handler[k] + # Strip 2-byte metadata header + reassembled += frag[2:] + + assert reassembled == data + + def test_index_wraps(self): + handler = PacketHandler(b"x", index=255, max_payload=200) + frag = handler[handler.get_keys()[0]] + idx, _ = struct.unpack("Bb", frag[:2]) + assert idx == 255 + + def test_getitem_positive_and_negative_lookup(self): + handler = PacketHandler(b"test", index=0, max_payload=200) + # Single frag stored under key -1 + assert handler[-1] is not None + # __getitem__ also checks -i so handler[1] should find handler[-1] + assert handler[1] is not None + assert handler[-1] == handler[1] + + def test_getitem_missing_key_returns_none(self): + handler = PacketHandler(b"test", index=0, max_payload=200) + assert handler[999] is None + + def test_empty_data_no_fragments(self): + handler = PacketHandler(data=None, index=0, max_payload=200) + assert handler.get_keys() == [] + + def test_destination_id_stored(self): + handler = PacketHandler(b"x", index=0, max_payload=200, custom_destination_id="abc123") + assert handler.destination_id == "abc123" + + def test_fragment_sizes_are_nearly_even(self): + # Verify the algorithm creates nearly-even fragments + data = bytes(100) + handler = PacketHandler(data, index=0, max_payload=40) + keys = handler.get_keys() + sizes = [len(handler[k]) - 2 for k in keys] # subtract metadata + # Integer division may produce chunks differing by up to 2 bytes + assert max(sizes) - min(sizes) <= 2 + + def test_split_data_called_on_init(self): + handler = PacketHandler(b"abc", index=7) + assert len(handler.get_keys()) == 1 + frag = handler[handler.get_keys()[0]] + idx, pos = struct.unpack("Bb", frag[:2]) + assert idx == 7 diff --git a/tests/test_packet_handler.py b/tests/test_packet_handler.py new file mode 100644 index 0000000..4551e2b --- /dev/null +++ b/tests/test_packet_handler.py @@ -0,0 +1,191 @@ +"""Tests for _PacketHandler fragmentation in both Dynamic and Channel interfaces.""" + +import base64 +import struct + +import pytest + +from Interface.MeshCore_Dynamic_Interface import _PacketHandler as DynPacketHandler +from Interface.MeshCore_Channel_Interface import _PacketHandler as ChanPacketHandler + + +# ═══════════════════════════════════════════════════════════════════════════ +# Dynamic Interface _PacketHandler +# ═══════════════════════════════════════════════════════════════════════════ + +class TestDynPacketHandler: + """Tests for _PacketHandler in MeshCore_Dynamic_Interface.""" + + def test_single_fragment_small_payload(self): + data = b"\x01\x02\x03\x04" + pkt_id = 42 + handler = DynPacketHandler(data, pkt_id) + + assert len(handler) == 1 + frag = handler.fragments[0] + assert frag.startswith("RNS:") + + b64_part = frag[len("RNS:"):] + b64_part += "=" * (-len(b64_part) % 4) + raw = base64.urlsafe_b64decode(b64_part) + + # Header: 1B idx + 4B pkt_id + 1B total = 6 bytes + assert len(raw) == 6 + len(data) + frag_idx, decoded_pkt_id, frag_total = struct.unpack(">BIB", raw[:6]) + assert frag_idx == 0 + assert decoded_pkt_id == 42 + assert frag_total == 1 + assert raw[6:] == data + + def test_multiple_fragments(self): + # Default payload_size is 64; create data that needs 3 fragments + data = bytes(range(150)) + handler = DynPacketHandler(data, 7) + + assert len(handler) == 3 + for i, frag in enumerate(handler.fragments): + assert frag.startswith("RNS:") + b64 = frag[4:] + b64 += "=" * (-len(b64) % 4) + raw = base64.urlsafe_b64decode(b64) + idx, pkt_id, total = struct.unpack(">BIB", raw[:6]) + assert idx == i + assert pkt_id == 7 + assert total == 3 + + def test_reassembly_round_trip(self): + original = b"Hello MeshCore world! " * 10 # 220 bytes + pkt_id = 99 + handler = DynPacketHandler(original, pkt_id) + + reassembled = b"" + for frag in handler.fragments: + b64 = frag[4:] + b64 += "=" * (-len(b64) % 4) + raw = base64.urlsafe_b64decode(b64) + reassembled += raw[6:] # strip header + + assert reassembled == original + + def test_custom_payload_size(self): + data = bytes(range(100)) + handler = DynPacketHandler(data, 1, payload_size=25) + assert len(handler) == 4 # ceil(100 / 25) + + def test_zero_payload_size_uses_default(self): + data = bytes(range(64)) + handler = DynPacketHandler(data, 1, payload_size=0) + # payload_size=0 falls back to default of 64 + assert len(handler) == 1 + + def test_pkt_id_wraps_at_32bit(self): + handler = DynPacketHandler(b"\x00", 0xFFFFFFFF) + b64 = handler.fragments[0][4:] + b64 += "=" * (-len(b64) % 4) + raw = base64.urlsafe_b64decode(b64) + _, pkt_id, _ = struct.unpack(">BIB", raw[:6]) + assert pkt_id == 0xFFFFFFFF + + def test_empty_data(self): + handler = DynPacketHandler(b"", 0) + # empty data → no chunks → no fragments + assert len(handler) == 0 + + def test_exact_payload_boundary(self): + data = bytes(64) # exactly one chunk at default size + handler = DynPacketHandler(data, 5) + assert len(handler) == 1 + + def test_one_byte_over_boundary(self): + data = bytes(65) # one byte over default payload_size + handler = DynPacketHandler(data, 5) + assert len(handler) == 2 + + def test_fragment_index_capped_at_255(self): + # frag_idx is masked to 0xFF; with payload_size=1, 260 bytes → 260 frags + data = bytes(260) + handler = DynPacketHandler(data, 0, payload_size=1) + assert len(handler) == 260 + + b64 = handler.fragments[255][4:] + b64 += "=" * (-len(b64) % 4) + raw = base64.urlsafe_b64decode(b64) + idx = raw[0] + assert idx == 255 + + # Fragment 256 wraps to 0 + b64 = handler.fragments[256][4:] + b64 += "=" * (-len(b64) % 4) + raw = base64.urlsafe_b64decode(b64) + idx = raw[0] + assert idx == 0 + + +# ═══════════════════════════════════════════════════════════════════════════ +# Channel Interface _PacketHandler +# ═══════════════════════════════════════════════════════════════════════════ + +class TestChanPacketHandler: + """Tests for _PacketHandler in MeshCore_Channel_Interface.""" + + def test_single_fragment(self): + src_id = b"\xAA\xBB\xCC\xDD" + data = b"test payload" + handler = ChanPacketHandler(data, src_id, pkt_id=10) + + assert len(handler) == 1 + frag = handler.fragments[0] + assert frag.startswith("RNS:") + + b64 = frag[4:] + b64 += "=" * (-len(b64) % 4) + raw = base64.urlsafe_b64decode(b64) + + # Header: magic(2) + idx(1) + src_id(4) + pkt_id(1) + total(1) = 9 bytes + assert raw[:2] == b"RN" # magic + assert raw[2] == 0 # frag_idx + assert raw[3:7] == src_id + assert raw[7] == 10 # pkt_id + assert raw[8] == 1 # frag_total + assert raw[9:] == data + + def test_multi_fragment_round_trip(self): + src_id = b"\x01\x02\x03\x04" + original = bytes(range(200)) + handler = ChanPacketHandler(original, src_id, pkt_id=55) + + assert len(handler) > 1 + + reassembled = b"" + for frag in handler.fragments: + b64 = frag[4:] + b64 += "=" * (-len(b64) % 4) + raw = base64.urlsafe_b64decode(b64) + # Verify magic + assert raw[:2] == b"RN" + # Verify src_id + assert raw[3:7] == src_id + reassembled += raw[9:] # strip 9-byte header + + assert reassembled == original + + def test_pkt_id_wraps_at_byte(self): + handler = ChanPacketHandler(b"\x00", b"\x00\x00\x00\x00", pkt_id=255) + b64 = handler.fragments[0][4:] + b64 += "=" * (-len(b64) % 4) + raw = base64.urlsafe_b64decode(b64) + assert raw[7] == 255 + + def test_fragment_count_matches_data_size(self): + # 128 bytes at default 64-byte payload → 2 fragments + handler = ChanPacketHandler(bytes(128), b"\x00" * 4, pkt_id=0) + assert len(handler) == 2 + + def test_all_fragments_have_consistent_total(self): + handler = ChanPacketHandler(bytes(200), b"\xAB" * 4, pkt_id=3) + expected_total = len(handler) + for frag in handler.fragments: + b64 = frag[4:] + b64 += "=" * (-len(b64) % 4) + raw = base64.urlsafe_b64decode(b64) + assert raw[8] == expected_total diff --git a/tests/test_payload_helpers.py b/tests/test_payload_helpers.py new file mode 100644 index 0000000..496f65a --- /dev/null +++ b/tests/test_payload_helpers.py @@ -0,0 +1,169 @@ +"""Tests for payload encoding/decoding helpers in MeshCore_Interface.""" + +import base64 + +import pytest + +from Interface.MeshCore_Interface import MeshCoreInterface + + +def _make_interface(): + """Build a MeshCoreInterface with __init__ bypassed.""" + iface = object.__new__(MeshCoreInterface) + iface.debug_level = "off" + iface._LOG_DEBUG = 10 + iface._LOG_INFO = 20 + iface._LOG_WARNING = 30 + iface._LOG_ERROR = 40 + return iface + + +class TestPayloadForSend: + + def test_basic_encoding(self): + iface = _make_interface() + data = b"\x01\x02\x03\x04" + result = iface._payload_for_send(data) + assert result == base64.b64encode(data).decode("ascii") + + def test_round_trip(self): + iface = _make_interface() + original = bytes(range(256)) + encoded = iface._payload_for_send(original) + decoded = base64.b64decode(encoded) + assert decoded == original + + def test_empty_data(self): + iface = _make_interface() + result = iface._payload_for_send(b"") + assert result == "" # base64 of empty is empty + + +class TestPayloadFromReceived: + + def test_bytes_passthrough(self): + iface = _make_interface() + data = b"\x01\x02\x03" + assert iface._payload_from_received(data) == data + + def test_bytearray_passthrough(self): + iface = _make_interface() + data = bytearray(b"\x04\x05\x06") + assert iface._payload_from_received(data) == bytes(data) + + def test_base64_string(self): + iface = _make_interface() + original = b"hello world" + encoded = base64.b64encode(original).decode() + assert iface._payload_from_received(encoded) == original + + def test_double_base64_string(self): + iface = _make_interface() + original = b"test data" + single = base64.b64encode(original) + double = base64.b64encode(single).decode() + assert iface._payload_from_received(double) == original + + def test_dict_with_payload_key(self): + iface = _make_interface() + original = b"\x01\x02\x03" + encoded = base64.b64encode(original).decode() + payload = {"payload": encoded} + assert iface._payload_from_received(payload) == original + + def test_dict_with_data_key(self): + iface = _make_interface() + original = b"\x04\x05\x06" + encoded = base64.b64encode(original).decode() + payload = {"data": encoded} + assert iface._payload_from_received(payload) == original + + def test_dict_with_text_key(self): + iface = _make_interface() + original = b"\x07\x08\x09" + encoded = base64.b64encode(original).decode() + payload = {"text": encoded} + assert iface._payload_from_received(payload) == original + + def test_dict_with_bytes_value(self): + iface = _make_interface() + original = b"\xAA\xBB" + payload = {"payload": original} + assert iface._payload_from_received(payload) == original + + def test_dict_empty_returns_none(self): + iface = _make_interface() + assert iface._payload_from_received({}) is None + + def test_none_returns_none(self): + iface = _make_interface() + assert iface._payload_from_received(None) is None + + def test_non_base64_string_fallback(self): + iface = _make_interface() + # "hello" is valid base64 but may not decode cleanly to valid base64 again + result = iface._payload_from_received("not!valid!base64!") + # Should fall back to latin-1/utf-8 encoding + assert result is not None + assert isinstance(result, bytes) + + +class TestResolveDestination: + + def test_dict_passthrough(self): + iface = _make_interface() + iface.dest_to_node_dict = {} + contact = {"public_key": "abc123", "adv_name": "relay"} + assert iface._resolve_destination(contact) is contact + + def test_full_pubkey_passthrough(self): + iface = _make_interface() + iface.dest_to_node_dict = {} + key = "a" * 64 + assert iface._resolve_destination(key) == key + + def test_short_prefix_lookup(self): + iface = _make_interface() + contact = {"public_key": "deadbeef01234567"} + iface.dest_to_node_dict = {"dead": contact} + assert iface._resolve_destination("dead") is contact + + def test_advert_name_lookup(self): + iface = _make_interface() + contact = {"public_key": "abcdef0123456789"} + iface.dest_to_node_dict = {"relay1": contact} + assert iface._resolve_destination("Relay1") is contact + + def test_unresolvable_returns_none(self): + iface = _make_interface() + iface.dest_to_node_dict = {} + assert iface._resolve_destination("unknown") is None + + +class TestShouldIngressLimit: + + def test_always_returns_false(self): + iface = _make_interface() + assert iface.should_ingress_limit() is False + assert iface.should_ingress_limit(dest="something") is False + + +class TestSetDebugLevel: + + def test_valid_levels(self): + iface = _make_interface() + for level in ("off", "info", "debug"): + iface.set_debug_level(level) + assert iface.debug_level == level + + def test_invalid_level_no_change(self): + iface = _make_interface() + iface.debug_level = "info" + iface.set_debug_level("invalid") + assert iface.debug_level == "info" + + def test_none_no_change(self): + iface = _make_interface() + iface.debug_level = "debug" + iface.set_debug_level(None) + assert iface.debug_level == "debug" diff --git a/tests/test_rns_header_parsing.py b/tests/test_rns_header_parsing.py new file mode 100644 index 0000000..1525407 --- /dev/null +++ b/tests/test_rns_header_parsing.py @@ -0,0 +1,191 @@ +"""Tests for RNS header parsing methods in MeshCore_Dynamic_Interface. + +These methods extract packet type, destination type, RNS tokens, and Link IDs +from raw RNS packets — they are critical for correct routing decisions. +""" + +import hashlib +import struct +from unittest.mock import MagicMock, patch + +import pytest + +from Interface.MeshCore_Dynamic_Interface import MeshCore_Dynamic_Interface + + +def _make_interface(**overrides): + """Build a MeshCore_Dynamic_Interface with __init__ bypassed.""" + iface = object.__new__(MeshCore_Dynamic_Interface) + # Set the class-level constants that the methods reference via self + for attr in dir(MeshCore_Dynamic_Interface): + if attr.startswith("_RNS_") or attr in ( + "HEADER_SIZE", "MSG_PREFIX", "BIND_PREFIX", "BIND_REQ_PREFIX", + "CAPABILITY_ROUTER", "CAPABILITY_EDGE", "DEDUPLICATION_TTL_S", + ): + setattr(iface, attr, getattr(MeshCore_Dynamic_Interface, attr)) + iface.name = "test" + iface.debug_level = "info" + iface.can_route = overrides.get("can_route", True) + return iface + + +# ═══════════════════════════════════════════════════════════════════════════ +# _is_broadcast_packet +# ═══════════════════════════════════════════════════════════════════════════ + +class TestIsBroadcastPacket: + + def setup_method(self): + self.iface = _make_interface() + + def test_empty_packet_is_broadcast(self): + assert self.iface._is_broadcast_packet(b"") is True + + def test_announce_packet(self): + # ptype ANNOUNCE = 0x01, any dest_type + hdr = bytes([0x01]) # flags byte: ptype=ANNOUNCE + assert self.iface._is_broadcast_packet(hdr + b"\x00" * 20) is True + + def test_data_plain_is_broadcast(self): + # ptype=DATA(0x00), dest_type=PLAIN(0x02) → flags = 0x08 + hdr = bytes([0x08]) + assert self.iface._is_broadcast_packet(hdr + b"\x00" * 20) is True + + def test_data_single_is_not_broadcast(self): + # ptype=DATA(0x00), dest_type=SINGLE(0x00) → flags = 0x00 + hdr = bytes([0x00]) + assert self.iface._is_broadcast_packet(hdr + b"\x00" * 20) is False + + def test_linkrequest_is_not_broadcast(self): + # ptype=LINKREQUEST(0x02) + hdr = bytes([0x02]) + assert self.iface._is_broadcast_packet(hdr + b"\x00" * 20) is False + + def test_proof_is_not_broadcast(self): + # ptype=PROOF(0x03) + hdr = bytes([0x03]) + assert self.iface._is_broadcast_packet(hdr + b"\x00" * 20) is False + + def test_data_group_not_broadcast(self): + # ptype=DATA(0x00), dest_type=GROUP(0x01) → flags = 0x04 + hdr = bytes([0x04]) + assert self.iface._is_broadcast_packet(hdr + b"\x00" * 20) is False + + def test_data_link_not_broadcast(self): + # ptype=DATA(0x00), dest_type=LINK(0x03) → flags = 0x0C + hdr = bytes([0x0C]) + assert self.iface._is_broadcast_packet(hdr + b"\x00" * 20) is False + + +# ═══════════════════════════════════════════════════════════════════════════ +# _extract_rns_token +# ═══════════════════════════════════════════════════════════════════════════ + +class TestExtractRnsToken: + + DST_LEN = 16 + + def setup_method(self): + self.iface = _make_interface() + + def test_too_short_returns_none(self): + assert self.iface._extract_rns_token(b"\x00") is None + + def test_single_header_extracts_destination(self): + # header_type = 0 (bit 6 clear) → single-header packet + # Token is data[2:2+DST_LEN] + flags = 0x00 # bit 6 clear + hops = 0x00 + dest = bytes(range(16)) + packet = bytes([flags, hops]) + dest + b"\x00" * 10 + + token = self.iface._extract_rns_token(packet) + assert token == dest + + def test_two_byte_header_extracts_second_dest(self): + # header_type = 1 (bit 6 set) → two-header packet + # Token is data[2+DST_LEN:2+2*DST_LEN] + flags = 0x40 # bit 6 set + hops = 0x00 + dest1 = bytes(range(16)) + dest2 = bytes(range(16, 32)) + packet = bytes([flags, hops]) + dest1 + dest2 + b"\x00" * 10 + + token = self.iface._extract_rns_token(packet) + assert token == dest2 + + def test_single_header_too_short_for_dest(self): + flags = 0x00 + # Only 2 header bytes + 10 bytes, need 18 + packet = bytes([flags, 0x00]) + b"\x00" * 10 + assert self.iface._extract_rns_token(packet) is None + + def test_two_byte_header_too_short_for_second_dest(self): + flags = 0x40 + # Only 2 header + 16 bytes, need 2+32 + packet = bytes([flags, 0x00]) + b"\x00" * 16 + assert self.iface._extract_rns_token(packet) is None + + +# ═══════════════════════════════════════════════════════════════════════════ +# _link_id_from_lr_packet +# ═══════════════════════════════════════════════════════════════════════════ + +class TestLinkIdFromLrPacket: + + DST_LEN = 16 + + def setup_method(self): + self.iface = _make_interface() + + def test_too_short_returns_none(self): + assert self.iface._link_id_from_lr_packet(b"\x00") is None + + def test_single_header_link_id(self): + # header_type=0, ptype=LINKREQUEST(0x02) + flags = 0x02 + hops = 0x01 + body = bytes(range(40)) + packet = bytes([flags, hops]) + body + + # Expected: hashable = (flags & 0x0F) + body + hashable = bytes([flags & 0x0F]) + body + expected = hashlib.sha256(hashable).digest()[:16] + + result = self.iface._link_id_from_lr_packet(packet) + assert result == expected + + def test_two_header_link_id(self): + # header_type=1 (bit 6 set) + flags = 0x42 # bit 6 set, ptype=LINKREQUEST + hops = 0x01 + dest1 = bytes(range(16)) + rest = bytes(range(30)) + packet = bytes([flags, hops]) + dest1 + rest + + # hashable = (flags & 0x0F) + data[2+DST_LEN:] + hashable = bytes([flags & 0x0F]) + rest + expected = hashlib.sha256(hashable).digest()[:16] + + result = self.iface._link_id_from_lr_packet(packet) + assert result == expected + + def test_two_header_too_short(self): + flags = 0x40 + packet = bytes([flags, 0x00]) + b"\x00" * 10 # less than 2+16 + assert self.iface._link_id_from_lr_packet(packet) is None + + +# ═══════════════════════════════════════════════════════════════════════════ +# _own_capability +# ═══════════════════════════════════════════════════════════════════════════ + +class TestOwnCapability: + + def test_router_capability(self): + iface = _make_interface(can_route=True) + assert iface._own_capability() == "R" + + def test_edge_capability(self): + iface = _make_interface(can_route=False) + assert iface._own_capability() == "E"