Files
MeshChatX/tests/backend/test_interface_discovery_ifac.py

320 lines
11 KiB
Python

# SPDX-License-Identifier: 0BSD
"""Tests for encrypted IFAC values surfaced via interface discovery announces.
When an upstream interface is configured with publish_ifac = yes, RNS embeds
the network_name (ifac_netname) and passphrase (ifac_netkey) into the
discovery announce payload, plus a ready-to-paste config_entry block. These
tests verify that:
1. The /api/v1/reticulum/discovered-interfaces endpoint passes those fields
through to the frontend with both the raw RNS keys and the canonical
config-style aliases (network_name/passphrase).
2. ReticulumMeshChat.normalize_discovered_ifac_fields handles bytes payloads,
missing values, and non-list inputs without raising.
3. discovery_filter_candidates includes network_name so users can whitelist
or blacklist by IFAC network name.
"""
import json
import shutil
import tempfile
from unittest.mock import MagicMock, patch
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat
class ConfigDict(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.write_called = False
def write(self):
self.write_called = True
return True
@pytest.fixture
def temp_dir():
path = tempfile.mkdtemp()
try:
yield path
finally:
shutil.rmtree(path)
def build_identity():
identity = MagicMock(spec=RNS.Identity)
identity.hash = b"test_hash_32_bytes_long_01234567"
identity.hexhash = identity.hash.hex()
identity.get_private_key.return_value = b"test_private_key"
return identity
async def find_route_handler(app_instance, path, method):
for route in app_instance.get_routes():
if route.path == path and route.method == method:
return route.handler
return None
def test_normalize_handles_non_list_input():
assert ReticulumMeshChat.normalize_discovered_ifac_fields({"foo": "bar"}) == {
"foo": "bar"
}
assert ReticulumMeshChat.normalize_discovered_ifac_fields(None) is None
def test_normalize_aliases_string_ifac_fields():
interfaces = [
{
"name": "kin.earth",
"type": "BackboneInterface",
"reachable_on": "rns.kin.earth",
"port": 4242,
"ifac_netname": "kin.earth",
"ifac_netkey": "asty8vT8spXNQdCnPVMATbCKkwUxuzG9",
"config_entry": "[[kin.earth]]\n type = BackboneInterface\n enabled = yes",
},
]
normalized = ReticulumMeshChat.normalize_discovered_ifac_fields(interfaces)
assert normalized[0]["ifac_netname"] == "kin.earth"
assert normalized[0]["ifac_netkey"] == "asty8vT8spXNQdCnPVMATbCKkwUxuzG9"
assert normalized[0]["network_name"] == "kin.earth"
assert normalized[0]["passphrase"] == "asty8vT8spXNQdCnPVMATbCKkwUxuzG9"
assert normalized[0]["publish_ifac"] is True
assert normalized[0]["config_entry"].startswith("[[kin.earth]]")
def test_normalize_decodes_bytes_ifac_fields():
interfaces = [
{
"name": "bytes-iface",
"type": "BackboneInterface",
"ifac_netname": b"bytes_net",
"ifac_netkey": b"bytes_key",
"config_entry": b"[[bytes-iface]]\n type = BackboneInterface",
},
]
normalized = ReticulumMeshChat.normalize_discovered_ifac_fields(interfaces)
assert normalized[0]["network_name"] == "bytes_net"
assert normalized[0]["passphrase"] == "bytes_key"
assert normalized[0]["config_entry"].startswith("[[bytes-iface]]")
def test_normalize_missing_ifac_fields_yields_none_aliases():
interfaces = [
{
"name": "open-iface",
"type": "TCPClientInterface",
"reachable_on": "10.0.0.1",
"port": 4242,
},
]
normalized = ReticulumMeshChat.normalize_discovered_ifac_fields(interfaces)
assert normalized[0]["ifac_netname"] is None
assert normalized[0]["ifac_netkey"] is None
assert normalized[0]["network_name"] is None
assert normalized[0]["passphrase"] is None
assert normalized[0]["publish_ifac"] is False
assert normalized[0]["config_entry"] is None
def test_normalize_skips_non_dict_entries():
interfaces = [
"not a dict",
{"name": "ok", "ifac_netname": "n", "ifac_netkey": "k"},
]
normalized = ReticulumMeshChat.normalize_discovered_ifac_fields(interfaces)
assert normalized[0] == "not a dict"
assert normalized[1]["network_name"] == "n"
assert normalized[1]["passphrase"] == "k"
def test_discovery_filter_candidates_includes_network_name():
iface = {
"name": "node-1",
"type": "BackboneInterface",
"network_name": "kin.earth",
"ifac_netname": "kin.earth",
}
candidates = ReticulumMeshChat.discovery_filter_candidates(iface)
assert "kin.earth" in candidates
def test_filter_discovered_interfaces_whitelist_by_network_name():
interfaces = [
{"name": "good", "type": "BackboneInterface", "network_name": "kin.earth"},
{"name": "bad", "type": "BackboneInterface", "network_name": "other.net"},
]
filtered = ReticulumMeshChat.filter_discovered_interfaces(
interfaces,
whitelist_patterns="kin.earth",
blacklist_patterns="",
)
names = [i["name"] for i in filtered]
assert names == ["good"]
@pytest.mark.asyncio
async def test_discovered_interfaces_endpoint_surfaces_ifac(temp_dir):
"""End-to-end: HTTP endpoint exposes IFAC values from RNS announce."""
config = ConfigDict({"reticulum": {}, "interfaces": {}})
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
patch("meshchatx.meshchat.InterfaceDiscovery") as mock_discovery_cls,
):
mock_reticulum = mock_rns.return_value
mock_reticulum.config = config
mock_reticulum.configpath = "/tmp/mock_config"
mock_reticulum.is_connected_to_shared_instance = False
mock_reticulum.transport_enabled.return_value = True
mock_reticulum.get_interface_stats.return_value = {"interfaces": []}
cfg_block = (
"[[kin.earth]]\n"
" type = BackboneInterface\n"
" enabled = yes\n"
" remote = rns.kin.earth\n"
" target_port = 4242\n"
" transport_identity = eea3d09f02143e157b3dae83060ee843\n"
" network_name = kin.earth\n"
" passphrase = asty8vT8spXNQdCnPVMATbCKkwUxuzG9"
)
mock_discovery_cls.return_value.list_discovered_interfaces.return_value = [
{
"name": "kin.earth",
"type": "BackboneInterface",
"reachable_on": "rns.kin.earth",
"port": 4242,
"transport_id": "eea3d09f02143e157b3dae83060ee843",
"network_id": "abc123",
"value": 16,
"hops": 1,
"status": "available",
"last_heard": 1700000000,
"ifac_netname": "kin.earth",
"ifac_netkey": "asty8vT8spXNQdCnPVMATbCKkwUxuzG9",
"config_entry": cfg_block,
},
{
"name": "open-node",
"type": "BackboneInterface",
"reachable_on": "open.example",
"port": 4242,
"transport_id": "ff" * 16,
"network_id": "def456",
"value": 8,
"hops": 2,
"status": "available",
"last_heard": 1700000001,
},
]
app_instance = ReticulumMeshChat(
identity=build_identity(),
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
handler = await find_route_handler(
app_instance,
"/api/v1/reticulum/discovered-interfaces",
"GET",
)
assert handler
response = await handler(MagicMock())
data = json.loads(response.body)
ifaces = data["interfaces"]
assert len(ifaces) == 2
encrypted = next(i for i in ifaces if i["name"] == "kin.earth")
assert encrypted["ifac_netname"] == "kin.earth"
assert encrypted["ifac_netkey"] == "asty8vT8spXNQdCnPVMATbCKkwUxuzG9"
assert encrypted["network_name"] == "kin.earth"
assert encrypted["passphrase"] == "asty8vT8spXNQdCnPVMATbCKkwUxuzG9"
assert encrypted["publish_ifac"] is True
assert encrypted["config_entry"].startswith("[[kin.earth]]")
assert "network_name = kin.earth" in encrypted["config_entry"]
plain = next(i for i in ifaces if i["name"] == "open-node")
assert plain["network_name"] is None
assert plain["passphrase"] is None
assert plain["publish_ifac"] is False
@pytest.mark.asyncio
async def test_discovered_interfaces_filter_works_with_ifac_network_name(temp_dir):
"""Whitelist/blacklist patterns can match an announce by its network_name."""
config = ConfigDict(
{
"reticulum": {
"interface_discovery_whitelist": "kin.earth",
},
"interfaces": {},
},
)
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
patch("meshchatx.meshchat.InterfaceDiscovery") as mock_discovery_cls,
):
mock_reticulum = mock_rns.return_value
mock_reticulum.config = config
mock_reticulum.configpath = "/tmp/mock_config"
mock_reticulum.is_connected_to_shared_instance = False
mock_reticulum.transport_enabled.return_value = True
mock_reticulum.get_interface_stats.return_value = {"interfaces": []}
mock_discovery_cls.return_value.list_discovered_interfaces.return_value = [
{
"name": "matching",
"type": "BackboneInterface",
"reachable_on": "10.0.0.1",
"port": 4242,
"ifac_netname": "kin.earth",
"ifac_netkey": "secret",
},
{
"name": "non-matching",
"type": "BackboneInterface",
"reachable_on": "10.0.0.2",
"port": 4242,
"ifac_netname": "other.net",
"ifac_netkey": "other",
},
]
app_instance = ReticulumMeshChat(
identity=build_identity(),
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
handler = await find_route_handler(
app_instance,
"/api/v1/reticulum/discovered-interfaces",
"GET",
)
assert handler
response = await handler(MagicMock())
data = json.loads(response.body)
names = [i["name"] for i in data["interfaces"]]
assert names == ["matching"]
assert data["interfaces"][0]["network_name"] == "kin.earth"
assert data["interfaces"][0]["passphrase"] == "secret"