"""Tests for RadicleBridge announce filtering and state logic (RNS mocked).""" import struct import time from unittest.mock import MagicMock, patch, call import pytest import RNS from radicle_reticulum.bridge import ( RadicleBridge, BRIDGE_APP_DATA_MAGIC, TunnelConnection, ) from radicle_reticulum.identity import RadicleIdentity # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_bridge(auto_connect: bool = False, auto_seed: bool = False) -> RadicleBridge: """Instantiate bridge with all RNS I/O patched out.""" identity = RadicleIdentity.generate() dest_mock = MagicMock() dest_mock.hash = b"\xaa" * 16 dest_mock.hexhash = "aa" * 16 with patch("radicle_reticulum.bridge.RNS.Reticulum"), \ patch("radicle_reticulum.bridge.RNS.Destination", return_value=dest_mock), \ patch("radicle_reticulum.bridge.RNS.Transport"), \ patch("radicle_reticulum.bridge.RNS.log"): bridge = RadicleBridge( identity=identity, auto_connect=auto_connect, auto_seed=auto_seed, ) return bridge def _nid_app_data(nid: str) -> bytes: """Build bridge app_data bytes that include a radicle NID.""" nid_bytes = nid.encode("utf-8") return BRIDGE_APP_DATA_MAGIC + struct.pack("!H", len(nid_bytes)) + nid_bytes # --------------------------------------------------------------------------- # TunnelConnection # --------------------------------------------------------------------------- class TestTunnelConnection: def test_close_closes_tcp_socket(self): mock_socket = MagicMock() mock_link = MagicMock() tunnel = TunnelConnection( tunnel_id=1, tcp_socket=mock_socket, rns_link=mock_link, remote_destination=b"\x00" * 16, ) tunnel.close() mock_socket.close.assert_called_once() mock_link.teardown.assert_called_once() assert not tunnel.active def test_close_tolerates_already_closed_socket(self): mock_socket = MagicMock() mock_socket.close.side_effect = OSError("already closed") tunnel = TunnelConnection( tunnel_id=2, tcp_socket=mock_socket, rns_link=None, remote_destination=None, ) tunnel.close() # should not raise # --------------------------------------------------------------------------- # Bridge construction # --------------------------------------------------------------------------- class TestBridgeConstruction: def test_initial_state_is_empty(self): bridge = _make_bridge() assert bridge.get_remote_bridges() == [] stats = bridge.get_stats() assert stats["active_tunnels"] == 0 assert stats["known_bridges"] == 0 def test_local_nid_is_none_initially(self): bridge = _make_bridge() assert bridge._local_radicle_nid is None def test_set_local_radicle_nid(self): bridge = _make_bridge() with patch("radicle_reticulum.bridge.RNS.log"): bridge.set_local_radicle_nid("z6Mktest") assert bridge._local_radicle_nid == "z6Mktest" def test_get_remote_bridge_nid_returns_none_for_unknown(self): bridge = _make_bridge() assert bridge.get_remote_bridge_nid(b"\x01" * 16) is None # --------------------------------------------------------------------------- # _handle_announce # --------------------------------------------------------------------------- class TestBridgeHandleAnnounce: def _rns_id(self): return RNS.Identity() def test_ignores_own_announce(self): bridge = _make_bridge() own_hash = bridge.destination.hash # mock's b"\xaa" * 16 discovered = [] bridge.set_on_bridge_discovered(lambda h, n: discovered.append(h)) with patch("radicle_reticulum.bridge.RNS.log"): bridge._handle_announce(own_hash, self._rns_id(), BRIDGE_APP_DATA_MAGIC) assert discovered == [] assert bridge.get_remote_bridges() == [] def test_ignores_non_bridge_announce(self): bridge = _make_bridge() with patch("radicle_reticulum.bridge.RNS.log"): bridge._handle_announce(b"\xbb" * 16, self._rns_id(), b"SOME_OTHER_APP") assert bridge.get_remote_bridges() == [] def test_ignores_announce_with_no_app_data(self): bridge = _make_bridge() with patch("radicle_reticulum.bridge.RNS.log"): bridge._handle_announce(b"\xcc" * 16, self._rns_id(), None) assert bridge.get_remote_bridges() == [] def test_discovers_bridge_with_magic_only(self): bridge = _make_bridge() peer_hash = b"\xdd" * 16 discovered = [] bridge.set_on_bridge_discovered(lambda h, n: discovered.append((h, n))) with patch("radicle_reticulum.bridge.RNS.log"): bridge._handle_announce(peer_hash, self._rns_id(), BRIDGE_APP_DATA_MAGIC) assert len(discovered) == 1 assert discovered[0][0] == peer_hash assert discovered[0][1] is None assert peer_hash in bridge.get_remote_bridges() def test_extracts_nid_from_app_data(self): bridge = _make_bridge() peer_hash = b"\xee" * 16 nid = "z6MktestNID123" discovered = [] bridge.set_on_bridge_discovered(lambda h, n: discovered.append((h, n))) with patch("radicle_reticulum.bridge.RNS.log"): bridge._handle_announce(peer_hash, self._rns_id(), _nid_app_data(nid)) assert discovered[0][1] == nid assert bridge.get_remote_bridge_nid(peer_hash) == nid def test_second_announce_does_not_fire_callback_again(self): bridge = _make_bridge() peer_hash = b"\xff" * 16 discovered = [] bridge.set_on_bridge_discovered(lambda h, n: discovered.append(h)) with patch("radicle_reticulum.bridge.RNS.log"): bridge._handle_announce(peer_hash, self._rns_id(), BRIDGE_APP_DATA_MAGIC) bridge._handle_announce(peer_hash, self._rns_id(), BRIDGE_APP_DATA_MAGIC) assert len(discovered) == 1 assert len(bridge.get_remote_bridges()) == 1 def test_multiple_distinct_bridges_all_tracked(self): bridge = _make_bridge() with patch("radicle_reticulum.bridge.RNS.log"): for i in range(4): bridge._handle_announce( bytes([i]) * 16, self._rns_id(), BRIDGE_APP_DATA_MAGIC ) assert len(bridge.get_remote_bridges()) == 4 def test_auto_connect_spawns_thread(self): bridge = _make_bridge(auto_connect=True) peer_hash = b"\x11" * 16 with patch("radicle_reticulum.bridge.RNS.log"), \ patch("radicle_reticulum.bridge.threading.Thread") as mock_thread: mock_t = MagicMock() mock_thread.return_value = mock_t bridge._handle_announce(peer_hash, self._rns_id(), BRIDGE_APP_DATA_MAGIC) mock_t.start.assert_called() def test_auto_seed_spawns_thread_when_nid_present(self): bridge = _make_bridge(auto_seed=True) peer_hash = b"\x22" * 16 nid = "z6MkAutoSeed" with patch("radicle_reticulum.bridge.RNS.log"), \ patch("radicle_reticulum.bridge.threading.Thread") as mock_thread: mock_t = MagicMock() mock_thread.return_value = mock_t bridge._handle_announce(peer_hash, self._rns_id(), _nid_app_data(nid)) mock_t.start.assert_called() def test_malformed_nid_in_app_data_is_ignored_gracefully(self): bridge = _make_bridge() peer_hash = b"\x33" * 16 bad_app_data = BRIDGE_APP_DATA_MAGIC + b"\xff\xff" # nid_len=65535 but truncated discovered = [] bridge.set_on_bridge_discovered(lambda h, n: discovered.append((h, n))) with patch("radicle_reticulum.bridge.RNS.log"): bridge._handle_announce(peer_hash, self._rns_id(), bad_app_data) # Bridge is still discovered, NID just not parsed assert len(discovered) == 1 assert discovered[0][1] is None # --------------------------------------------------------------------------- # register_seed # --------------------------------------------------------------------------- class TestRegisterSeed: def test_register_seed_success(self): bridge = _make_bridge() with patch("radicle_reticulum.bridge.subprocess.run") as mock_run, \ patch("radicle_reticulum.bridge.RNS.log"): mock_run.return_value = MagicMock(returncode=0) result = bridge.register_seed("z6MktestNID") assert result is True cmd = mock_run.call_args[0][0] assert "rad" in cmd assert "z6MktestNID" in " ".join(cmd) def test_register_seed_rad_not_found(self): bridge = _make_bridge() with patch("radicle_reticulum.bridge.subprocess.run", side_effect=FileNotFoundError), \ patch("radicle_reticulum.bridge.RNS.log"): result = bridge.register_seed("z6MkNID") assert result is False def test_register_seed_nonzero_exit(self): bridge = _make_bridge() with patch("radicle_reticulum.bridge.subprocess.run") as mock_run, \ patch("radicle_reticulum.bridge.RNS.log"): mock_run.return_value = MagicMock(returncode=1, stderr="error") result = bridge.register_seed("z6MkNID") assert result is False def test_register_seed_timeout(self): import subprocess bridge = _make_bridge() with patch("radicle_reticulum.bridge.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="rad", timeout=30)), \ patch("radicle_reticulum.bridge.RNS.log"): result = bridge.register_seed("z6MkNID") assert result is False # --------------------------------------------------------------------------- # get_stats # --------------------------------------------------------------------------- class TestBridgeStats: def test_stats_with_no_tunnels(self): bridge = _make_bridge() stats = bridge.get_stats() assert stats == { "active_tunnels": 0, "known_bridges": 0, "bytes_sent": 0, "bytes_received": 0, "rns_hash": bridge.destination.hexhash, } def test_stats_counts_known_bridges(self): bridge = _make_bridge() with patch("radicle_reticulum.bridge.RNS.log"): bridge._handle_announce(b"\x01" * 16, RNS.Identity(), BRIDGE_APP_DATA_MAGIC) bridge._handle_announce(b"\x02" * 16, RNS.Identity(), BRIDGE_APP_DATA_MAGIC) stats = bridge.get_stats() assert stats["known_bridges"] == 2