mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-25 15:22:10 +00:00
434 lines
14 KiB
Python
434 lines
14 KiB
Python
# SPDX-License-Identifier: 0BSD
|
|
|
|
"""End-to-end tests for the /api/v1/reticulum/interfaces/add endpoint.
|
|
|
|
Each test exercises one interface type and asserts that the rich set of
|
|
options exposed in the UI is persisted into the Reticulum config dict.
|
|
"""
|
|
|
|
import contextlib
|
|
import json
|
|
import shutil
|
|
import socket
|
|
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
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def make_app(temp_dir, config):
|
|
with (
|
|
patch("meshchatx.meshchat.generate_ssl_certificate"),
|
|
patch("RNS.Reticulum") as mock_rns,
|
|
patch("RNS.Transport"),
|
|
patch("LXMF.LXMRouter"),
|
|
):
|
|
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
|
|
|
|
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/interfaces/add",
|
|
"POST",
|
|
)
|
|
assert handler
|
|
yield handler
|
|
|
|
|
|
def make_request(payload):
|
|
class Request:
|
|
@staticmethod
|
|
async def json():
|
|
return payload
|
|
|
|
return Request()
|
|
|
|
|
|
def _free_port(kind="tcp"):
|
|
sock_type = socket.SOCK_STREAM if kind == "tcp" else socket.SOCK_DGRAM
|
|
with contextlib.closing(socket.socket(socket.AF_INET, sock_type)) as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_interface_persists_full_options(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "AutoLAN",
|
|
"type": "AutoInterface",
|
|
"group_id": "homelab",
|
|
"discovery_scope": "site",
|
|
"discovery_port": 35000,
|
|
"data_port": 35001,
|
|
"multicast_address_type": "permanent",
|
|
"devices": "eth0, wlan0",
|
|
"ignored_devices": "tun0",
|
|
"configured_bitrate": 5000000,
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 200, body
|
|
saved = config["interfaces"]["AutoLAN"]
|
|
assert saved["type"] == "AutoInterface"
|
|
assert saved["group_id"] == "homelab"
|
|
assert saved["discovery_scope"] == "site"
|
|
assert saved["discovery_port"] == 35000
|
|
assert saved["data_port"] == 35001
|
|
assert saved["multicast_address_type"] == "permanent"
|
|
assert saved["devices"] == "eth0, wlan0"
|
|
assert saved["ignored_devices"] == "tun0"
|
|
assert saved["configured_bitrate"] == 5000000
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_interface_rejects_invalid_discovery_scope(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "AutoLAN",
|
|
"type": "AutoInterface",
|
|
"discovery_scope": "outerspace",
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 422
|
|
assert "discovery scope" in body["message"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_interface_rejects_invalid_multicast_type(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "AutoLAN",
|
|
"type": "AutoInterface",
|
|
"multicast_address_type": "weird",
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 422
|
|
assert "multicast" in body["message"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_interface_rejects_busy_data_port(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
busy = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
busy.bind(("127.0.0.1", 0))
|
|
busy_port = busy.getsockname()[1]
|
|
try:
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "AutoLAN",
|
|
"type": "AutoInterface",
|
|
"data_port": busy_port,
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 409, body
|
|
assert str(busy_port) in body["message"]
|
|
finally:
|
|
busy.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tcp_client_persists_advanced_options(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "TCPClient",
|
|
"type": "TCPClientInterface",
|
|
"target_host": "example.com",
|
|
"target_port": "4242",
|
|
"kiss_framing": True,
|
|
"i2p_tunneled": True,
|
|
"connect_timeout": 12,
|
|
"max_reconnect_tries": 7,
|
|
"fixed_mtu": 480,
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 200, body
|
|
saved = config["interfaces"]["TCPClient"]
|
|
assert saved["kiss_framing"] is True
|
|
assert saved["i2p_tunneled"] is True
|
|
assert saved["connect_timeout"] == 12
|
|
assert saved["max_reconnect_tries"] == 7
|
|
assert saved["fixed_mtu"] == 480
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tcp_server_persists_optional_options(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
free_port = _free_port("tcp")
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "TCPServer",
|
|
"type": "TCPServerInterface",
|
|
"listen_ip": "127.0.0.1",
|
|
"listen_port": free_port,
|
|
"device": "eth0",
|
|
"prefer_ipv6": True,
|
|
"i2p_tunneled": True,
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 200, body
|
|
saved = config["interfaces"]["TCPServer"]
|
|
assert saved["device"] == "eth0"
|
|
assert saved["prefer_ipv6"] is True
|
|
assert saved["i2p_tunneled"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tcp_server_rejects_busy_listen_port(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
busy = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
busy.bind(("127.0.0.1", 0))
|
|
busy.listen(1)
|
|
busy_port = busy.getsockname()[1]
|
|
try:
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "TCPServer",
|
|
"type": "TCPServerInterface",
|
|
"listen_ip": "127.0.0.1",
|
|
"listen_port": busy_port,
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 409, body
|
|
assert str(busy_port) in body["message"]
|
|
finally:
|
|
busy.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_udp_rejects_busy_listen_port(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
busy = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
busy.bind(("127.0.0.1", 0))
|
|
busy_port = busy.getsockname()[1]
|
|
try:
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "UDP1",
|
|
"type": "UDPInterface",
|
|
"listen_ip": "127.0.0.1",
|
|
"listen_port": busy_port,
|
|
"forward_ip": "255.255.255.255",
|
|
"forward_port": busy_port,
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 409, body
|
|
assert "UDP" in body["message"]
|
|
finally:
|
|
busy.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_backbone_listener_mode_persists_options(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
free_port = _free_port("tcp")
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "BackboneListen",
|
|
"type": "BackboneInterface",
|
|
"listen_ip": "127.0.0.1",
|
|
"listen_port": free_port,
|
|
"device": "eth0",
|
|
"prefer_ipv6": True,
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 200, body
|
|
saved = config["interfaces"]["BackboneListen"]
|
|
assert saved["listen_port"] == free_port
|
|
assert saved["listen_ip"] == "127.0.0.1"
|
|
assert saved["device"] == "eth0"
|
|
assert saved["prefer_ipv6"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_backbone_connector_mode_still_requires_remote(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "BackboneOut",
|
|
"type": "BackboneInterface",
|
|
"target_port": "4242",
|
|
"transport_identity": "00" * 16,
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 422
|
|
assert "remote" in body["message"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rnode_persists_flow_control_and_id_callsign(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "Radio",
|
|
"type": "RNodeInterface",
|
|
"port": "/dev/ttyUSB0",
|
|
"frequency": 868000000,
|
|
"bandwidth": 125000,
|
|
"txpower": 7,
|
|
"spreadingfactor": 8,
|
|
"codingrate": 5,
|
|
"flow_control": True,
|
|
"id_callsign": "NOCALL",
|
|
"id_interval": 600,
|
|
"airtime_limit_long": 1.5,
|
|
"airtime_limit_short": 33.0,
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 200, body
|
|
saved = config["interfaces"]["Radio"]
|
|
assert saved["flow_control"] is True
|
|
assert saved["id_callsign"] == "NOCALL"
|
|
assert saved["id_interval"] == 600
|
|
assert saved["airtime_limit_long"] == 1.5
|
|
assert saved["airtime_limit_short"] == 33.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kiss_persists_full_serial_options(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "KISSRadio",
|
|
"type": "KISSInterface",
|
|
"port": "/dev/ttyACM0",
|
|
"speed": 19200,
|
|
"databits": 8,
|
|
"parity": "N",
|
|
"stopbits": 1,
|
|
"preamble": 200,
|
|
"txtail": 30,
|
|
"persistence": 128,
|
|
"slottime": 25,
|
|
"flow_control": True,
|
|
"id_callsign": "BEACON",
|
|
"id_interval": 1200,
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 200, body
|
|
saved = config["interfaces"]["KISSRadio"]
|
|
assert saved["speed"] == 19200
|
|
assert saved["databits"] == 8
|
|
assert saved["parity"] == "N"
|
|
assert saved["stopbits"] == 1
|
|
assert saved["preamble"] == 200
|
|
assert saved["txtail"] == 30
|
|
assert saved["persistence"] == 128
|
|
assert saved["slottime"] == 25
|
|
assert saved["flow_control"] is True
|
|
assert saved["id_callsign"] == "BEACON"
|
|
assert saved["id_interval"] == 1200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ax25_kiss_persists_callsign_and_ssid(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "AX25",
|
|
"type": "AX25KISSInterface",
|
|
"port": "/dev/ttyACM1",
|
|
"callsign": "N0CALL",
|
|
"ssid": 7,
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 200, body
|
|
saved = config["interfaces"]["AX25"]
|
|
assert saved["callsign"] == "N0CALL"
|
|
assert saved["ssid"] == 7
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i2p_connectable_can_be_disabled(temp_dir):
|
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
|
|
|
async with make_app(temp_dir, config) as handler:
|
|
payload = {
|
|
"name": "I2POut",
|
|
"type": "I2PInterface",
|
|
"peers": ["abcdef.b32.i2p"],
|
|
"connectable": False,
|
|
}
|
|
response = await handler(make_request(payload))
|
|
body = json.loads(response.body)
|
|
assert response.status == 200, body
|
|
saved = config["interfaces"]["I2POut"]
|
|
assert saved["connectable"] == "False"
|
|
assert saved["peers"] == ["abcdef.b32.i2p"]
|