mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
466 lines
17 KiB
Python
466 lines
17 KiB
Python
# Copyright © 2025-26 l5yth & contributors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Unit tests for Reticulum interface names without peer addresses (SPEC RI1).
|
|
|
|
The cases live in ``tests/fixtures/reticulum_interface_names.tsv``, which
|
|
``web/spec/interface_names_spec.rb`` reads too, so the ingestor and the web
|
|
scrub a name the same way. Each case this module knows is built from the real
|
|
RNS class without ``__init__`` (no socket, no device), so its printed name is
|
|
exactly what the pinned RNS prints, and every interface class the pinned RNS
|
|
ships has a case.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import inspect
|
|
import pkgutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(REPO_ROOT) not in sys.path: # pragma: no cover - conftest adds it
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
import RNS.Interfaces # noqa: E402 - path setup
|
|
from RNS.Interfaces.Android.KISSInterface import ( # noqa: E402 - path setup
|
|
KISSInterface as AndroidKISSInterface,
|
|
)
|
|
from RNS.Interfaces.Android.RNodeInterface import ( # noqa: E402 - path setup
|
|
RNodeInterface as AndroidRNodeInterface,
|
|
)
|
|
from RNS.Interfaces.Android.SerialInterface import ( # noqa: E402 - path setup
|
|
SerialInterface as AndroidSerialInterface,
|
|
)
|
|
from RNS.Interfaces.AutoInterface import ( # noqa: E402 - path setup
|
|
AutoInterface,
|
|
AutoInterfacePeer,
|
|
)
|
|
from RNS.Interfaces.AX25KISSInterface import AX25KISSInterface # noqa: E402
|
|
from RNS.Interfaces.BackboneInterface import ( # noqa: E402 - path setup
|
|
BackboneClientInterface,
|
|
BackboneInterface,
|
|
)
|
|
from RNS.Interfaces.I2PInterface import ( # noqa: E402 - path setup
|
|
I2PInterface,
|
|
I2PInterfacePeer,
|
|
)
|
|
from RNS.Interfaces.Interface import Interface # noqa: E402 - path setup
|
|
from RNS.Interfaces.KISSInterface import KISSInterface # noqa: E402
|
|
from RNS.Interfaces.LocalInterface import ( # noqa: E402 - path setup
|
|
LocalClientInterface,
|
|
LocalServerInterface,
|
|
)
|
|
from RNS.Interfaces.PipeInterface import PipeInterface # noqa: E402
|
|
from RNS.Interfaces.RNodeInterface import RNodeInterface # noqa: E402
|
|
from RNS.Interfaces.RNodeMultiInterface import ( # noqa: E402 - path setup
|
|
RNodeMultiInterface,
|
|
RNodeSubInterface,
|
|
)
|
|
from RNS.Interfaces.SerialInterface import SerialInterface # noqa: E402
|
|
from RNS.Interfaces.TCPInterface import ( # noqa: E402 - path setup
|
|
TCPClientInterface,
|
|
TCPServerInterface,
|
|
)
|
|
from RNS.Interfaces.UDPInterface import UDPInterface # noqa: E402
|
|
from RNS.Interfaces.WeaveInterface import ( # noqa: E402 - path setup
|
|
WeaveInterface,
|
|
WeaveInterfacePeer,
|
|
)
|
|
from test_reticulum_scope_unit import ( # noqa: E402 - shared RNS doubles
|
|
IFACES,
|
|
_bare,
|
|
_Run,
|
|
)
|
|
|
|
import data.mesh_ingestor.config as config # noqa: E402 - path setup
|
|
from data.mesh_ingestor.protocols import reticulum as mod # noqa: E402
|
|
from data.mesh_ingestor.protocols import reticulum_interfaces # noqa: E402
|
|
|
|
FIXTURE = REPO_ROOT / "tests" / "fixtures" / "reticulum_interface_names.tsv"
|
|
"""The cases shared with the web suite."""
|
|
|
|
_I2P_PEER = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrst.b32.i2p"
|
|
"""A made-up ``.b32.i2p`` address for an outbound I2P peer's name."""
|
|
|
|
_I2P_BASE64 = ("AbCdEfGh0123~-IjKlMnOp4567QrStUvWx89YzAB-~" * 13)[:516]
|
|
"""A made-up base64 I2P destination, as long as the shortest real one."""
|
|
|
|
|
|
def _cases() -> dict[str, tuple[str, str]]:
|
|
"""Read the shared fixture.
|
|
|
|
Returns:
|
|
``{case: (printed name, public name)}`` in file order.
|
|
"""
|
|
cases: dict[str, tuple[str, str]] = {}
|
|
for line in FIXTURE.read_text(encoding="utf-8").splitlines():
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
case, printed, public = line.split("\t")
|
|
assert case not in cases, f"duplicate case {case}"
|
|
cases[case] = (printed, public)
|
|
return cases
|
|
|
|
|
|
CASES = _cases()
|
|
"""Every case of the shared fixture."""
|
|
|
|
_MULTI = _bare(RNodeMultiInterface, name="Multi Radio")
|
|
|
|
RNS_CASES = {
|
|
"tcp_client": _bare(
|
|
TCPClientInterface,
|
|
name="RNS Testnet Amsterdam",
|
|
target_ip="amsterdam.connect.reticulum.network",
|
|
target_port=4965,
|
|
),
|
|
"tcp_client_name_with_slash": _bare(
|
|
TCPClientInterface, name="Hub A/B", target_ip="203.0.113.77", target_port=4242
|
|
),
|
|
# A TCP server's connecting peer: RNS names it after the server and
|
|
# stamps the peer's address and port (TCPServerInterface.incoming_connection).
|
|
"tcp_server_peer": _bare(
|
|
TCPClientInterface,
|
|
name="Client on Public Hub",
|
|
target_ip="203.0.113.77",
|
|
target_port="51234",
|
|
),
|
|
"tcp_server_peer_ipv6": _bare(
|
|
TCPClientInterface,
|
|
name="Client on Public Hub",
|
|
target_ip="2001:db8::77",
|
|
target_port="51234",
|
|
),
|
|
"tcp_server": _bare(
|
|
TCPServerInterface, name="Public Hub", bind_ip="0.0.0.0", bind_port=4242
|
|
),
|
|
"tcp_server_ipv6": _bare(
|
|
TCPServerInterface, name="Public Hub", bind_ip="::", bind_port=4242
|
|
),
|
|
"backbone": _bare(
|
|
BackboneInterface, name="Backbone Hub", bind_ip="198.51.100.4", bind_port=4242
|
|
),
|
|
"backbone_client": _bare(
|
|
BackboneClientInterface,
|
|
name="Backbone Peer",
|
|
target_ip="203.0.113.7",
|
|
target_port=4242,
|
|
),
|
|
"backbone_peer_ipv6": _bare(
|
|
BackboneClientInterface,
|
|
name="Client on Backbone Hub",
|
|
target_ip="2001:db8::7",
|
|
target_port=39012,
|
|
),
|
|
"udp": _bare(UDPInterface, name="Local UDP", bind_ip="0.0.0.0", bind_port=4242),
|
|
"udp_ipv6": _bare(
|
|
UDPInterface, name="Local UDP", bind_ip="fe80::1", bind_port=4242
|
|
),
|
|
"auto_peer": _bare(
|
|
AutoInterfacePeer, ifname="wlan0", addr="fe80::1c2b:3aff:fe4d:5e6f"
|
|
),
|
|
"weave_peer": _bare(WeaveInterfacePeer, endpoint_addr=bytes.fromhex("0a1b2c3d")),
|
|
"auto": _bare(AutoInterface, name="Default Interface"),
|
|
"weave": _bare(WeaveInterface, name="Weave Link"),
|
|
"i2p": _bare(I2PInterface, name="I2P Link"),
|
|
# I2PInterface names an outbound peer "<name> to <peer>"
|
|
# (I2PInterface.__init__) and an inbound one "Connected peer on <name>".
|
|
"i2p_peer": _bare(I2PInterfacePeer, name=f"I2P Link to {_I2P_PEER}"),
|
|
"i2p_peer_hostname": _bare(I2PInterfacePeer, name="I2P Link to reticulum-hub.i2p"),
|
|
"i2p_peer_base64": _bare(I2PInterfacePeer, name=f"I2P Link to {_I2P_BASE64}"),
|
|
"i2p_peer_name_with_to": _bare(
|
|
I2PInterfacePeer, name=f"Link to Berlin to {_I2P_PEER}"
|
|
),
|
|
"i2p_connected_peer": _bare(I2PInterfacePeer, name="Connected peer on I2P Link"),
|
|
"local": _bare(LocalClientInterface, socket_path="\0rns/default"),
|
|
"local_tcp": _bare(LocalClientInterface, socket_path=None, target_port=37428),
|
|
"shared_instance": _bare(LocalServerInterface, socket_path="\0rns/default"),
|
|
"shared_instance_tcp": _bare(
|
|
LocalServerInterface, socket_path=None, bind_port=37428
|
|
),
|
|
"pipe": _bare(PipeInterface, name="Pipe Link"),
|
|
"kiss": _bare(KISSInterface, name="Packet Radio"),
|
|
"ax25_kiss": _bare(AX25KISSInterface, name="AX.25 Radio"),
|
|
"serial": _bare(SerialInterface, name="Serial Link"),
|
|
"serial_name_with_slash": _bare(SerialInterface, name="Shed/North"),
|
|
"rnode": _bare(RNodeInterface, name="RNode Reticulum Berlin"),
|
|
"rnode_name_with_slash": _bare(RNodeInterface, name="LoRa 868/915"),
|
|
"rnode_multi": _MULTI,
|
|
"android_kiss": _bare(AndroidKISSInterface, name="Packet Radio"),
|
|
"android_rnode": _bare(AndroidRNodeInterface, name="RNode Reticulum Berlin"),
|
|
"android_serial": _bare(AndroidSerialInterface, name="Serial Link"),
|
|
"rnode_sub": _bare(RNodeSubInterface, name="868 Low", parent_interface=_MULTI),
|
|
"rnode_sub_with_slash": _bare(
|
|
RNodeSubInterface, name="868/915", parent_interface=_MULTI
|
|
),
|
|
"rnode_sub_rnode_parent_with_slash": _bare(
|
|
RNodeSubInterface,
|
|
name="868/915",
|
|
parent_interface=_bare(RNodeMultiInterface, name="RNodeMulti"),
|
|
),
|
|
# The one RNode name the rule cuts: a parent name that reads as a class.
|
|
"rnode_sub_identifier_parent_with_slash": _bare(
|
|
RNodeSubInterface,
|
|
name="868/915",
|
|
parent_interface=_bare(RNodeMultiInterface, name="LoRaMulti"),
|
|
),
|
|
}
|
|
"""Cases built from the real RNS class, keyed as in the fixture."""
|
|
|
|
HAND_MADE = {
|
|
"unknown_class",
|
|
"unknown_class_slashes",
|
|
"unknown_class_without_slash",
|
|
"unknown_class_address_only",
|
|
"tcp_address_only",
|
|
"tcp_cut_in_address",
|
|
"tcp_cut_in_name",
|
|
"i2p_peer_cut",
|
|
"i2p_peer_hostname_upper",
|
|
"i2p_peer_base64_kelvin",
|
|
"i2p_peer_base64_long_s",
|
|
"i2p_peer_base64_kelvin_at_500",
|
|
"i2p_peer_base64_long_s_at_500",
|
|
"weave_peer_cut",
|
|
"unknown_class_cut",
|
|
"rnode_cut",
|
|
"no_brackets",
|
|
}
|
|
"""Cases no RNS class prints: unknown classes, cut and malformed names, and
|
|
names that pin the I2P peer's character classes (no Unicode case folding)."""
|
|
|
|
|
|
def _pinned_interface_classes() -> set[type]:
|
|
"""Return every interface class the installed RNS ships.
|
|
|
|
Returns:
|
|
Each ``Interface`` subclass defined in a module under
|
|
``RNS.Interfaces``, the Android ones included.
|
|
"""
|
|
classes: set[type] = set()
|
|
prefix = RNS.Interfaces.__name__ + "."
|
|
for info in pkgutil.walk_packages(RNS.Interfaces.__path__, prefix):
|
|
module = importlib.import_module(info.name)
|
|
for obj in vars(module).values():
|
|
if (
|
|
inspect.isclass(obj)
|
|
and issubclass(obj, Interface)
|
|
and obj is not Interface
|
|
and obj.__module__ == module.__name__
|
|
):
|
|
classes.add(obj)
|
|
return classes
|
|
|
|
|
|
def test_every_pinned_interface_class_has_a_case():
|
|
"""The pinned RNS ships no interface class that the cases leave out."""
|
|
covered = {type(iface) for iface in RNS_CASES.values()}
|
|
missing = _pinned_interface_classes() - covered
|
|
assert not missing, sorted(f"{cls.__module__}.{cls.__name__}" for cls in missing)
|
|
|
|
|
|
def test_every_case_is_built_from_rns_or_hand_made():
|
|
"""No fixture case goes unchecked, and every built case is in the fixture."""
|
|
assert set(RNS_CASES) | HAND_MADE == set(CASES)
|
|
assert not set(RNS_CASES) & HAND_MADE
|
|
|
|
|
|
@pytest.mark.parametrize("case", sorted(RNS_CASES))
|
|
def test_printed_name_is_what_rns_prints(case):
|
|
"""Each built case prints the fixture's name, so the fixture is RNS 1.5.7's."""
|
|
assert str(RNS_CASES[case]) == CASES[case][0]
|
|
|
|
|
|
@pytest.mark.parametrize("case", list(CASES))
|
|
def test_public_name(case):
|
|
"""Each printed name posts as the fixture's public name."""
|
|
printed, public = CASES[case]
|
|
assert reticulum_interfaces.public_interface_name(printed) == public
|
|
|
|
|
|
@pytest.mark.parametrize("case", list(CASES))
|
|
def test_public_name_is_idempotent(case):
|
|
"""A public name passes through unchanged, as the web scrub and boot rely on."""
|
|
public = CASES[case][1]
|
|
assert reticulum_interfaces.public_interface_name(public) == public
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"case",
|
|
[
|
|
"tcp_client",
|
|
"tcp_server_peer",
|
|
"tcp_server_peer_ipv6",
|
|
"tcp_server",
|
|
"backbone",
|
|
"backbone_client",
|
|
"udp",
|
|
"auto_peer",
|
|
"weave_peer",
|
|
],
|
|
)
|
|
def test_no_address_survives(case):
|
|
"""No host, IP address, port or endpoint of an address-printing class stays."""
|
|
iface = RNS_CASES[case]
|
|
public = reticulum_interfaces.public_interface_name(str(iface))
|
|
for attr in ("target_ip", "target_port", "bind_ip", "bind_port", "addr"):
|
|
value = getattr(iface, attr, None)
|
|
if value is not None:
|
|
assert str(value) not in public
|
|
endpoint = getattr(iface, "endpoint_addr", None)
|
|
if endpoint is not None:
|
|
assert endpoint.hex(":") not in public
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"case",
|
|
["i2p_peer", "i2p_peer_hostname", "i2p_peer_base64", "i2p_peer_name_with_to"],
|
|
)
|
|
def test_no_i2p_peer_survives(case):
|
|
"""An outbound I2P peer's destination goes, its I2PInterface name stays."""
|
|
printed = str(RNS_CASES[case])
|
|
name, _, peer = printed[len("I2PInterfacePeer[") : -1].rpartition(" to ")
|
|
public = reticulum_interfaces.public_interface_name(printed)
|
|
assert peer not in public
|
|
assert public == f"I2PInterfacePeer[{name}]"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"case",
|
|
[
|
|
"rnode",
|
|
"rnode_name_with_slash",
|
|
"rnode_multi",
|
|
"rnode_sub",
|
|
"rnode_sub_with_slash",
|
|
"rnode_sub_rnode_parent_with_slash",
|
|
"android_rnode",
|
|
],
|
|
)
|
|
def test_rnode_names_post_unchanged(case):
|
|
"""RNode names, a "/" included, post exactly as RNS printed them."""
|
|
printed = str(RNS_CASES[case])
|
|
assert reticulum_interfaces.public_interface_name(printed) == printed
|
|
|
|
|
|
@pytest.mark.parametrize("value", [None, 7, b"TCPInterface[x/1.2.3.4:5]"])
|
|
def test_a_value_that_is_no_string_passes_through(value):
|
|
"""Only a printed name is rewritten."""
|
|
assert reticulum_interfaces.public_interface_name(value) is value
|
|
|
|
|
|
def test_the_class_prefix_sets_are_disjoint():
|
|
"""A printed class prefix has exactly one rule."""
|
|
groups = (
|
|
set(reticulum_interfaces.ADDRESS_INTERFACE_PREFIXES),
|
|
set(reticulum_interfaces.BARE_ADDRESS_INTERFACE_PREFIXES),
|
|
set(reticulum_interfaces.NAME_INTERFACE_PREFIXES),
|
|
)
|
|
for index, group in enumerate(groups):
|
|
for other in groups[index + 1 :]:
|
|
assert not group & other
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The provider posts the public name (the scope test harness, real RNS names)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_POSTED = {
|
|
"tcp_peer": "TCPInterface[RNS Testnet Amsterdam]",
|
|
"backbone_peer": "BackboneInterface[Backbone Peer]",
|
|
"auto_peer": "AutoInterfacePeer[eth0]",
|
|
"rnode_peer": "RNodeInterface[RNode LoRa Interface]",
|
|
"rnode_sub_peer": "Multi Radio[868 Low]",
|
|
"own": "LocalInterface[rns/default]",
|
|
}
|
|
"""The interface each peer of the scope harness posts."""
|
|
|
|
_ADDRESSES = (
|
|
"amsterdam.connect.reticulum.network",
|
|
"4965",
|
|
"203.0.113.7",
|
|
"4242",
|
|
"fe80::1",
|
|
)
|
|
"""Every host, address and port the harness's interfaces print."""
|
|
|
|
|
|
def test_every_scope_posts_names_without_peer_addresses(monkeypatch, tmp_path):
|
|
"""``*`` posts each peer's interface without its address, in every record.
|
|
|
|
The announce records and the node snapshot the daemon posts at connect,
|
|
the host's own destinations included, carry the public name; an RNode, a
|
|
sub-interface and the local socket keep theirs.
|
|
"""
|
|
run = _Run(monkeypatch, tmp_path, config._parse_reticulum_interfaces("*"))
|
|
run.connect().announce()
|
|
assert {label: run.record(label)["interface"] for label in _POSTED} == _POSTED
|
|
snapshot = mod.ReticulumProvider().node_snapshot_items(run.iface)
|
|
names = [node.get("interface") for _nid, node in run.upserts + snapshot]
|
|
assert len(names) > len(_POSTED) and all(names)
|
|
assert not [name for name in names for part in _ADDRESSES if part in name]
|
|
|
|
|
|
def test_a_list_matches_the_printed_name_and_posts_the_public_one(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""A list fragment may match the address part; the post still omits it."""
|
|
scope = config._parse_reticulum_interfaces("reticulum.network")
|
|
run = _Run(monkeypatch, tmp_path, scope).connect()
|
|
run.announce()
|
|
assert run.ingested == {"tcp_peer", "own"}
|
|
assert run.record("tcp_peer")["interface"] == _POSTED["tcp_peer"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("parent", "posted"),
|
|
[("Multi Radio", "Multi Radio[868/915]"), ("LoRaMulti", "LoRaMulti[868]")],
|
|
)
|
|
def test_the_radio_test_reads_the_printed_name(monkeypatch, tmp_path, parent, posted):
|
|
"""A sub-interface whose name holds "/" counts as LoRa, cut or not.
|
|
|
|
Its class comes from the stats entry for the printed name, so the record
|
|
keeps the radio metadata also when the posted name is not that entry's: a
|
|
parent name that reads as a class name is cut like an unknown class's
|
|
(SPEC RI1, RL3). An IP peer still gets none.
|
|
"""
|
|
multi = _bare(RNodeMultiInterface, name=parent)
|
|
sub = _bare(RNodeSubInterface, name="868/915", parent_interface=multi)
|
|
monkeypatch.setitem(IFACES, "rnode_sub", sub)
|
|
run = _Run(monkeypatch, tmp_path, config._parse_reticulum_interfaces("*"))
|
|
run.connect().announce("rnode_sub_peer", "tcp_peer")
|
|
record = run.record("rnode_sub_peer")
|
|
assert record["interface"] == posted
|
|
assert (record["lora_freq"], record["modem_preset"]) == (867, "SF8/BW125/CR5")
|
|
assert "lora_freq" not in run.record("tcp_peer")
|
|
|
|
|
|
def test_host_destinations_post_without_a_peer_address(monkeypatch, tmp_path):
|
|
"""A host destination read from the path table posts the public name too."""
|
|
run = _Run(monkeypatch, tmp_path, config._parse_reticulum_interfaces("*"))
|
|
own = run.stack.identities["own"]
|
|
nomad = RNS.Destination.hash(own, "nomadnetwork", "node")
|
|
run.stack.paths[nomad] = (0, IFACES["auto_peer"])
|
|
run.connect().announce("own")
|
|
snapshot = mod.ReticulumProvider().node_snapshot_items(run.iface)
|
|
[host] = [
|
|
node
|
|
for _nid, node in snapshot
|
|
if node.get("destination", {}).get("aspect") == "nomadnetwork.node"
|
|
]
|
|
assert host["interface"] == "AutoInterfacePeer[eth0]"
|