mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
400 lines
15 KiB
Python
400 lines
15 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.
|
|
"""Tests for the periodic report that keeps a Reticulum host fresh (SPEC RE8).
|
|
|
|
The host's own destinations come from the running stack's 0-hop path table. A
|
|
local app's announce reaches the ingestor only while both are attached, one
|
|
made before connect is not replayed, and ``rns.transport`` never announces.
|
|
The node snapshot posts the host once per connection, and a busy mesh never
|
|
recycles the connection, because every announce resets the 1 h inactivity
|
|
reconnect. The daemon therefore re-posts the host on its self-node report,
|
|
through the optional ``self_node_items`` provider hook.
|
|
|
|
Covers the daemon side of the hook, the Reticulum provider's implementation of
|
|
it, and the end-to-end regression (ACCEPTANCE RE-A14).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import time
|
|
import types
|
|
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 # noqa: E402 - path setup
|
|
|
|
from daemon_fakes import make_state # noqa: E402 - shared daemon doubles
|
|
from data.mesh_ingestor import ( # noqa: E402 - path setup
|
|
activity,
|
|
config,
|
|
daemon,
|
|
handlers,
|
|
ingestors,
|
|
queue,
|
|
)
|
|
from data.mesh_ingestor.handlers import _state as handler_state # noqa: E402
|
|
from data.mesh_ingestor.protocols.reticulum import ReticulumProvider # noqa: E402
|
|
from test_reticulum_unit import ( # noqa: E402 - shared Reticulum doubles
|
|
_FIELD_LXMF,
|
|
_FIELD_NOMADNET,
|
|
_FIELD_PRIMARY,
|
|
_FIELD_TRANSPORT,
|
|
_FakeIdentity,
|
|
)
|
|
|
|
_HOST = "!27716218"
|
|
"""Canonical node id of the field host's primary identity."""
|
|
|
|
_REMOTE_IDENTITY = "aabbccdd" + "11" * 12
|
|
"""Identity hash of a remote peer one hop out."""
|
|
|
|
_REMOTE_DEST = "c0ffee00" + "22" * 12
|
|
"""The remote peer's ``lxmf.delivery`` destination hash."""
|
|
|
|
_WALL_OFFSET = 1_791_000_000
|
|
"""Unix seconds added to the fake monotonic clock to give the wall clock."""
|
|
|
|
|
|
class _Stack:
|
|
"""The running RNS stack as a shared-instance client sees it.
|
|
|
|
``_local_stack`` in ``test_reticulum_unit`` patches a fixed path table and
|
|
the process transport flag. The refresh needs a 0-hop table and a routing
|
|
answer that change between loops, the stack's own transport answer through
|
|
``get_interface_stats`` (SPEC RA12), and a count of path-table reads, which
|
|
is the cost the report must keep hourly.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Attach the field host's two aspects, not routing yet."""
|
|
self.local = {_FIELD_LXMF: _FIELD_PRIMARY, _FIELD_NOMADNET: _FIELD_PRIMARY}
|
|
self.routing = False
|
|
self.path_table_reads = 0
|
|
|
|
def get_path_table(self, max_hops=None):
|
|
"""Return the 0-hop entries and count the read."""
|
|
self.path_table_reads += 1
|
|
return [
|
|
{
|
|
"hash": bytes.fromhex(dest),
|
|
"hops": 0,
|
|
"interface": "LocalInterface[rns/default]",
|
|
}
|
|
for dest in self.local
|
|
]
|
|
|
|
def get_next_hop_if_name(self, _dest_hash):
|
|
"""Name the interface a remote announce arrived on."""
|
|
return "RNodeInterface[RNode LoRa]"
|
|
|
|
def get_interface_stats(self):
|
|
"""Report a ``transport_id`` only while the stack routes (SPEC RA12)."""
|
|
if not self.routing:
|
|
return {}
|
|
return {"transport_id": bytes.fromhex(_FIELD_TRANSPORT)}
|
|
|
|
def recall(self, dest_hash, **_kw):
|
|
"""Map a 0-hop destination to its owner, as ``RNS.Identity.recall`` does.
|
|
|
|
Only path-table entries are recalled: a remote announce hands its
|
|
identity to the handler directly.
|
|
"""
|
|
return _FakeIdentity(bytes.fromhex(self.local[bytes(dest_hash).hex()]))
|
|
|
|
|
|
@pytest.fixture
|
|
def world(monkeypatch, tmp_path):
|
|
"""Point RNS at a :class:`_Stack`, with a fake clock and no transmission.
|
|
|
|
Returns:
|
|
Namespace with ``stack`` (the fake), ``clock`` (``{"mono": seconds}``,
|
|
driving both ``time.monotonic`` and ``time.time``), ``host_posts``
|
|
(every record of the host node posted to ``/api/nodes``) and ``sent``
|
|
(every transmission attempted, which must stay empty).
|
|
"""
|
|
stack = _Stack()
|
|
clock = {"mono": 10_000.0}
|
|
host_posts: list[dict] = []
|
|
sent: list[str] = []
|
|
|
|
monkeypatch.setattr(RNS.Reticulum, "get_instance", staticmethod(lambda: stack))
|
|
monkeypatch.setattr(RNS.Reticulum, "transport_enabled", staticmethod(lambda: False))
|
|
monkeypatch.setattr(
|
|
RNS.Transport,
|
|
"internal_identity",
|
|
staticmethod(lambda: _FakeIdentity(bytes.fromhex(_FIELD_TRANSPORT))),
|
|
)
|
|
monkeypatch.setattr(RNS.Transport, "register_announce_handler", lambda _h: None)
|
|
monkeypatch.setattr(RNS.Transport, "deregister_announce_handler", lambda _h: None)
|
|
monkeypatch.setattr(RNS.Transport, "hops_to", lambda _dh: 1)
|
|
monkeypatch.setattr(RNS.Identity, "recall", staticmethod(stack.recall))
|
|
monkeypatch.setattr(
|
|
RNS.Identity, "recall_app_data", staticmethod(lambda _dh, **_k: None)
|
|
)
|
|
# Recorded, not raised: the provider and the daemon swallow exceptions, so
|
|
# a raising fake could be called and the test still pass.
|
|
monkeypatch.setattr(RNS.Packet, "send", lambda *_a, **_k: sent.append("send"))
|
|
monkeypatch.setattr(
|
|
RNS.Destination, "announce", lambda *_a, **_k: sent.append("announce")
|
|
)
|
|
|
|
for name, value in (
|
|
("PROTOCOL", "reticulum"),
|
|
("INGESTOR_NODE_ID", None),
|
|
("CONNECTION", None),
|
|
("RETICULUM_CONFIG_DIR", str(tmp_path)),
|
|
("RETICULUM_INTERFACES", ()),
|
|
("RETICULUM_FREQ", None),
|
|
("RETICULUM_PRESET", None),
|
|
("LORA_FREQ", None),
|
|
("MODEM_PRESET", None),
|
|
("_RECONNECT_INITIAL_DELAY_SECS", 0.0),
|
|
("_SELF_NODE_REPORT_INTERVAL_SECS", 3600.0),
|
|
):
|
|
monkeypatch.setattr(config, name, value)
|
|
monkeypatch.setattr(config, "_debug_log", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(time, "monotonic", lambda: clock["mono"])
|
|
monkeypatch.setattr(time, "time", lambda: _WALL_OFFSET + clock["mono"])
|
|
|
|
def _capture(path, payload, **_kw):
|
|
"""Keep the host node's record from each ``/api/nodes`` POST."""
|
|
if path == "/api/nodes" and _HOST in payload:
|
|
host_posts.append(payload[_HOST])
|
|
|
|
monkeypatch.setattr(queue, "_queue_post_json", _capture)
|
|
# Announcements are not under test, and transmit nothing by default.
|
|
monkeypatch.setattr(daemon, "_process_announcements", lambda s: s.last_announce)
|
|
|
|
# Process-wide state the loop writes; monkeypatch restores it afterwards.
|
|
for name in (
|
|
"_host_node_id",
|
|
"_host_telemetry_last_rx",
|
|
"_host_nodeinfo_last_seen",
|
|
"_last_packet_monotonic",
|
|
):
|
|
monkeypatch.setattr(handler_state, name, None)
|
|
monkeypatch.setattr(ingestors.STATE, "node_id", None)
|
|
monkeypatch.setattr(ingestors.STATE, "last_heartbeat", None)
|
|
monkeypatch.setattr(activity, "_packet_count", 0)
|
|
return types.SimpleNamespace(
|
|
stack=stack, clock=clock, host_posts=host_posts, sent=sent
|
|
)
|
|
|
|
|
|
def _busy_pass(state, world) -> None:
|
|
"""Run one daemon pass on a busy mesh, then advance the clock by 60 s.
|
|
|
|
Once connected, a remote announce lands through the real handler before
|
|
every pass, so the 1 h inactivity reconnect never fires.
|
|
"""
|
|
if state.iface is not None:
|
|
handler = next(
|
|
h
|
|
for h in state.iface._announce_handlers
|
|
if h.aspect_filter == "lxmf.delivery"
|
|
)
|
|
handler.received_announce(
|
|
bytes.fromhex(_REMOTE_DEST),
|
|
_FakeIdentity(bytes.fromhex(_REMOTE_IDENTITY)),
|
|
b"Remote Peer",
|
|
)
|
|
daemon._loop_iteration(state)
|
|
world.clock["mono"] += 60.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End to end: the real provider in the real daemon loop (ACCEPTANCE RE-A14)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_host_destinations_are_refreshed_on_a_connection_that_never_recycles(world):
|
|
"""The host is re-posted hourly while the connection stays up (SPEC RE8/RE9).
|
|
|
|
The busy mesh keeps the interface, so only the self-node report can refresh
|
|
the host, and the stack starts routing after the snapshot, so only that
|
|
report can post ``rns.transport``. Against the unfixed loop this fails with
|
|
``assert 'rns.transport' in {...}``: the host was posted once, at connect.
|
|
"""
|
|
state = make_state(provider=ReticulumProvider(), inactivity_reconnect_secs=3600.0)
|
|
connected_at = int(time.time())
|
|
_busy_pass(state, world) # connect; the snapshot posts the two aspects
|
|
first_iface = state.iface
|
|
world.stack.routing = True # the operator enables transport on rnsd
|
|
reads_before = world.stack.path_table_reads
|
|
for _ in range(61): # one report interval, plus one loop
|
|
_busy_pass(state, world)
|
|
|
|
newest: dict[str, int] = {}
|
|
for node in world.host_posts:
|
|
aspect = node["destination"]["aspect"]
|
|
newest[aspect] = max(newest.get(aspect, 0), node["lastHeard"])
|
|
|
|
assert state.iface is first_iface # no reconnect did the work
|
|
assert "rns.transport" in newest
|
|
assert newest["lxmf.delivery"] >= connected_at + 3600
|
|
assert newest["nomadnetwork.node"] >= connected_at + 3600
|
|
assert world.stack.path_table_reads - reads_before <= 4 # hourly, not per loop
|
|
assert world.sent == [] # local reads only (SPEC RN5, MA7)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ReticulumProvider.self_node_items
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_self_node_items_is_empty_until_a_host_id_is_registered(world):
|
|
"""No registered host id: nothing to report, and the stack is not asked."""
|
|
assert ReticulumProvider().self_node_items(None) == []
|
|
assert world.stack.path_table_reads == 0
|
|
|
|
|
|
def test_self_node_items_reports_the_registered_hosts_destinations(world):
|
|
"""The host's aspects, plus its transport instance on a routing stack."""
|
|
world.stack.routing = True
|
|
handlers.register_host_node_id(_HOST)
|
|
items = ReticulumProvider().self_node_items(None)
|
|
assert {node_id for node_id, _node in items} == {_HOST}
|
|
assert sorted(node["destination"]["aspect"] for _nid, node in items) == [
|
|
"lxmf.delivery",
|
|
"nomadnetwork.node",
|
|
"rns.transport",
|
|
]
|
|
assert world.stack.path_table_reads == 2 # the primary pick, then the records
|
|
|
|
|
|
def test_self_node_items_is_tied_to_the_registered_host_id(world):
|
|
"""A second local identity fronting more destinations is not reported.
|
|
|
|
It wins the primary pick, and the web's destination upsert takes the
|
|
incoming node id on conflict, so reporting it would move ``rns.transport``
|
|
onto ``!0be70000`` while the registered host stays ``!27716218``.
|
|
"""
|
|
other = "0be70000" + "33" * 12
|
|
for app, aspect in (
|
|
("lxmf", "delivery"),
|
|
("lxmf", "propagation"),
|
|
("nomadnetwork", "node"),
|
|
):
|
|
dest = RNS.Destination.hash(bytes.fromhex(other), app, aspect).hex()
|
|
world.stack.local[dest] = other
|
|
world.stack.routing = True
|
|
handlers.register_host_node_id(_HOST)
|
|
provider = ReticulumProvider()
|
|
assert {n["nodeId"] for n in provider.host_destination_nodes()} == {"!0be70000"}
|
|
assert provider.self_node_items(None) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# daemon._try_send_self_node with the list hook
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _BothHooksProvider:
|
|
"""Provider stub exposing both self-node hooks."""
|
|
|
|
def __init__(self, items=(), error: Exception | None = None) -> None:
|
|
"""Answer the list hook with *items*, or raise *error* from it."""
|
|
self.items = list(items)
|
|
self.error = error
|
|
self.single_calls = 0
|
|
|
|
def self_node_items(self, iface):
|
|
"""Return the configured records, or raise the configured error."""
|
|
if self.error is not None:
|
|
raise self.error
|
|
return list(self.items)
|
|
|
|
def self_node_item(self, iface): # pragma: no cover - the list hook wins
|
|
"""Count the call: the list hook must win when both exist."""
|
|
self.single_calls += 1
|
|
return "!ffffffff", {}
|
|
|
|
|
|
@pytest.fixture
|
|
def report(monkeypatch):
|
|
"""Capture the upserts and log lines of a self-node report at a fixed time.
|
|
|
|
Returns:
|
|
Namespace with ``upserted`` (``(node_id, node)`` pairs) and ``logged``
|
|
(``(message, fields)`` pairs).
|
|
"""
|
|
upserted: list[tuple[str, dict]] = []
|
|
logged: list[tuple[str, dict]] = []
|
|
monkeypatch.setattr(
|
|
daemon.handlers, "upsert_node", lambda nid, node: upserted.append((nid, node))
|
|
)
|
|
monkeypatch.setattr(
|
|
daemon.config, "_debug_log", lambda msg, **kw: logged.append((msg, kw))
|
|
)
|
|
monkeypatch.setattr(daemon.time, "monotonic", lambda: 5000.0)
|
|
return types.SimpleNamespace(upserted=upserted, logged=logged)
|
|
|
|
|
|
def test_list_hook_is_preferred_and_every_record_is_upserted(report):
|
|
"""Every record is upserted, the single hook is skipped, the timer stamped."""
|
|
records = [
|
|
(_HOST, {"destination": {"aspect": "lxmf.delivery"}}),
|
|
(_HOST, {"destination": {"aspect": "rns.transport"}}),
|
|
]
|
|
provider = _BothHooksProvider(records)
|
|
state = make_state(provider=provider, iface=object())
|
|
daemon._try_send_self_node(state)
|
|
|
|
assert report.upserted == records
|
|
assert provider.single_calls == 0
|
|
assert state.last_self_node_report == 5000.0
|
|
assert report.logged == [
|
|
(
|
|
"Sent periodic self-node report",
|
|
{
|
|
"context": "daemon.self_node",
|
|
"severity": "info",
|
|
"node_ids": [_HOST],
|
|
"records": 2,
|
|
},
|
|
)
|
|
]
|
|
|
|
|
|
def test_list_hook_stamps_the_timer_on_an_empty_answer(report):
|
|
"""Nothing to report still stamps, so the next try waits an interval."""
|
|
provider = _BothHooksProvider([])
|
|
state = make_state(provider=provider, iface=object())
|
|
daemon._try_send_self_node(state)
|
|
|
|
assert report.upserted == []
|
|
assert report.logged == []
|
|
assert provider.single_calls == 0
|
|
assert state.last_self_node_report == 5000.0
|
|
|
|
|
|
def test_list_hook_error_is_logged_and_still_stamps_the_timer(report):
|
|
"""A failing hook is logged once and retried an interval later, not per loop."""
|
|
provider = _BothHooksProvider(error=RuntimeError("stack gone"))
|
|
state = make_state(provider=provider, iface=object())
|
|
daemon._try_send_self_node(state) # must not raise
|
|
|
|
assert report.upserted == []
|
|
assert provider.single_calls == 0
|
|
assert state.last_self_node_report == 5000.0
|
|
assert [(msg, kw["severity"]) for msg, kw in report.logged] == [
|
|
("Self-node re-report failed", "warn")
|
|
]
|