Files
potato-mesh/tests/test_node_identity_binding_unit.py

396 lines
14 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.
"""A Meshtastic NodeInfo is filed under the node that sent it (SPEC NI1).
Packets are real ``MeshPacket`` protobufs handed to the pinned meshtastic
library's own ``MeshInterface._handlePacketFromRadio`` (``noProto=True``: no
device, no serial port, pubsub delivered synchronously) and from there to the
production subscription, so every case sees the dict shapes a radio produces.
The radio's nodeDB at connect holds three nodes: the sender ``A``, the node
``B`` its NodeInfo names, and a third node ``C``. Only the HTTP queue is
replaced; nothing touches the network.
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
import meshtastic # noqa: E402
from meshtastic import mesh_interface as lib_mesh_interface # noqa: E402
from meshtastic.protobuf import mesh_pb2, portnums_pb2 # noqa: E402
from pubsub import pub # noqa: E402
from daemon_fakes import make_state # noqa: E402
import data.mesh_ingestor.activity as activity # noqa: E402
import data.mesh_ingestor.channels as channels # noqa: E402
import data.mesh_ingestor.config as config # noqa: E402
import data.mesh_ingestor.daemon as daemon # noqa: E402
import data.mesh_ingestor.handlers as handlers # noqa: E402
import data.mesh_ingestor.handlers._state as _state # noqa: E402
import data.mesh_ingestor.handlers.ignored as ignored_mod # noqa: E402
import data.mesh_ingestor.interfaces as interfaces # noqa: E402
import data.mesh_ingestor.queue as queue # noqa: E402
from data.mesh_ingestor.protocols.meshtastic import MeshtasticProvider # noqa: E402
SENDER = 0xA1A1A1A1
"""Node number in the header ``from`` of every packet: the radio that sent it."""
SENDER_ID = "!a1a1a1a1"
"""Canonical id of :data:`SENDER`."""
NAMED = 0xB2B2B2B2
"""Node number of the node the sender's NodeInfo names as itself."""
NAMED_ID = "!b2b2b2b2"
"""Canonical id of :data:`NAMED`."""
OTHER = 0xC3C3C3C3
"""A third node in the radio's nodeDB."""
OTHER_ID = "!c3c3c3c3"
"""Canonical id of :data:`OTHER`."""
NODEINFO_APP = portnums_pb2.PortNum.NODEINFO_APP
"""Port number of the dispatch-table entry the ingestor guards."""
@pytest.fixture
def posts(monkeypatch, tmp_path):
"""Neutral ingest filters; every queued POST is recorded instead of sent.
Returns:
A list that collects ``(path, payload)`` for each queued request.
"""
for name, value in (
("DEBUG", False),
("PROTOCOL", "meshtastic"),
("ALLOWED_CHANNELS", ()),
("HIDDEN_CHANNELS", ()),
("PRIMARY_CHANNEL_ONLY", False),
("DROP_VIA_MQTT", False),
("PRIMARY_CHANNEL_NAME", ""),
("LORA_FREQ", None),
("MODEM_PRESET", None),
):
monkeypatch.setattr(config, name, value, raising=False)
for name in (
"_host_node_id",
"_host_telemetry_last_rx",
"_host_nodeinfo_last_seen",
):
monkeypatch.setattr(_state, name, None)
monkeypatch.setattr(ignored_mod, "_IGNORED_PACKET_LOG_PATH", tmp_path / "x.txt")
monkeypatch.setattr(activity, "_packet_count", 0)
channels._reset_channel_cache()
recorded: list[tuple[str, dict]] = []
monkeypatch.setattr(
queue,
"_queue_post_json",
lambda path, payload, *, priority=None, **_kw: recorded.append((path, payload)),
)
yield recorded
channels._reset_channel_cache()
@pytest.fixture
def warnings_logged(monkeypatch):
"""Capture ``warn`` log lines instead of printing them.
Returns:
A list that collects each warning's context and metadata.
"""
logged: list[dict] = []
def record(message, *, context=None, severity="debug", **metadata):
"""Keep the warnings and drop every other log line."""
if severity == "warn":
logged.append({"context": context, **metadata})
monkeypatch.setattr(config, "_debug_log", record)
return logged
@pytest.fixture
def radio(posts, monkeypatch):
"""The pinned library's interface after connect, wired to the subscription.
The ingestor's meshtastic patches are applied to the real library the way
importing :mod:`data.mesh_ingestor.interfaces` applies them; the library's
dispatch table and module attribute are restored afterwards.
Returns:
A namespace with the interface ``iface`` and the recorded ``posts``.
"""
monkeypatch.setitem(
meshtastic.protocols, NODEINFO_APP, meshtastic.protocols[NODEINFO_APP]
)
monkeypatch.setattr(meshtastic, "_onNodeInfoReceive", meshtastic._onNodeInfoReceive)
interfaces._patch_meshtastic_nodeinfo_handler()
monkeypatch.setattr(lib_mesh_interface.publishingThread, "queueWork", lambda w: w())
iface = lib_mesh_interface.MeshInterface(noProto=True)
iface.nodes = {}
iface.nodesByNum = {}
topics = MeshtasticProvider().subscribe()
for num, name in (
(SENDER, "Sender Radio"),
(NAMED, "Named Node"),
(OTHER, "Other"),
):
info = mesh_pb2.NodeInfo(
num=num, user=mesh_pb2.User(id=f"!{num:08x}", long_name=name)
)
iface._handleFromRadio(mesh_pb2.FromRadio(node_info=info).SerializeToString())
yield SimpleNamespace(iface=iface, posts=posts)
for topic in topics:
pub.unsubscribe(handlers.on_receive, topic)
def _hear(iface, port: str, payload: bytes, *, packet_id: int) -> None:
"""Deliver one received ``MeshPacket`` whose header ``from`` is :data:`SENDER`."""
packet = mesh_pb2.MeshPacket()
setattr(packet, "from", SENDER)
packet.to = 0xFFFFFFFF
packet.id = packet_id
packet.channel = 0
packet.rx_time = int(time.time())
packet.decoded.portnum = portnums_pb2.PortNum.Value(port)
packet.decoded.payload = payload
iface._handlePacketFromRadio(packet)
def _profile_naming_another_node() -> bytes:
"""A ``NODEINFO_APP`` payload from :data:`SENDER` whose ``user.id`` is :data:`NAMED_ID`."""
return mesh_pb2.User(
id=NAMED_ID, long_name="Mallory", short_name="MLRY", hw_model=9
).SerializeToString()
def _varint(value: int) -> bytes:
"""Encode ``value`` as a protobuf base-128 varint."""
out = bytearray()
while True:
byte = value & 0x7F
value >>= 7
if not value:
out.append(byte)
return bytes(out)
out.append(byte | 0x80)
def _nodeinfo_payload_with_another_number() -> bytes:
"""A ``NODEINFO_APP`` payload from :data:`SENDER` that carries :data:`NAMED`'s number.
Field 1 appears twice: as a varint it is ``NodeInfo.num`` (the number of
:data:`NAMED`), as a string ``User.id`` (the sender's own id). The
library parses the payload as a ``User`` and sees the sender's id; the
ingestor's decoder parses it as a ``NodeInfo`` and sees the other number.
"""
sender_id = SENDER_ID.encode()
profile = mesh_pb2.User(
id=SENDER_ID, long_name="Sender Radio", short_name="SNDR"
).SerializeToString()
return (
b"\x08"
+ _varint(NAMED)
+ b"\x0a"
+ bytes([len(sender_id)])
+ sender_id
+ b"\x12"
+ bytes([len(profile)])
+ profile
)
def _node_posts(recorded) -> dict[str, dict]:
"""Merge every ``/api/nodes`` POST body into ``{node_id: entry}``."""
out: dict[str, dict] = {}
for path, payload in recorded:
if path == "/api/nodes":
out.update({k: v for k, v in payload.items() if k.startswith("!")})
return out
def _snapshot(radio) -> dict[str, dict]:
"""Run the daemon's node-list snapshot over ``radio``; return the posted nodes."""
radio.posts.clear()
state = make_state(provider=MeshtasticProvider(), iface=radio.iface)
assert daemon._try_send_snapshot(state)
return _node_posts(radio.posts)
def test_nodeinfo_naming_another_node_posts_nothing_keyed_on_it(radio, warnings_logged):
"""A NodeInfo from A whose ``user.id`` is B is dropped with a warning naming both."""
_hear(radio.iface, "NODEINFO_APP", _profile_naming_another_node(), packet_id=0x51)
assert _node_posts(radio.posts) == {}
assert warnings_logged == [
{
"context": "handlers.store_nodeinfo",
"from_id": SENDER_ID,
"user_id": NAMED_ID,
}
]
# The library's nodeDB keeps each node under its own profile.
assert radio.iface.nodesByNum[SENDER]["user"]["id"] == SENDER_ID
assert radio.iface.nodes[NAMED_ID]["num"] == NAMED
def test_later_packets_stay_attributed_to_the_header_source(radio):
"""After such a NodeInfo, A's own position and text still post under A."""
_hear(radio.iface, "NODEINFO_APP", _profile_naming_another_node(), packet_id=0x52)
radio.posts.clear()
position = mesh_pb2.Position(
latitude_i=481_000_000, longitude_i=116_000_000, time=int(time.time())
).SerializeToString()
_hear(radio.iface, "POSITION_APP", position, packet_id=0x53)
_hear(radio.iface, "TEXT_MESSAGE_APP", b"hello from the sender", packet_id=0x54)
attributed = [
(path, payload.get("node_id") or payload.get("from_id"))
for path, payload in radio.posts
if path in ("/api/positions", "/api/messages")
]
assert attributed == [("/api/positions", SENDER_ID), ("/api/messages", SENDER_ID)]
def test_neighborinfo_naming_another_node_is_dropped(radio, warnings_logged):
"""A NeighborInfo from A whose ``node_id`` is B posts nothing; A's own posts under A."""
foreign = mesh_pb2.NeighborInfo(
node_id=NAMED, neighbors=[mesh_pb2.Neighbor(node_id=OTHER, snr=5.0)]
).SerializeToString()
own = mesh_pb2.NeighborInfo(
node_id=SENDER, neighbors=[mesh_pb2.Neighbor(node_id=OTHER, snr=5.0)]
).SerializeToString()
_hear(radio.iface, "NEIGHBORINFO_APP", foreign, packet_id=0x61)
_hear(radio.iface, "NEIGHBORINFO_APP", own, packet_id=0x62)
filed = [
(body["node_id"], body["node_num"])
for path, body in radio.posts
if path == "/api/neighbors"
]
assert filed == [(SENDER_ID, SENDER)]
assert warnings_logged == [
{
"context": "handlers.store_neighborinfo",
"from_id": SENDER_ID,
"node_id": NAMED_ID,
}
]
def test_nodeinfo_number_naming_another_node_is_not_posted(radio, warnings_logged):
"""A NodeInfo-format payload carrying B's number posts the sender's own number."""
_hear(
radio.iface,
"NODEINFO_APP",
_nodeinfo_payload_with_another_number(),
packet_id=0x64,
)
nodes = _node_posts(radio.posts)
assert {node_id: entry["num"] for node_id, entry in nodes.items()} == {
SENDER_ID: SENDER
}
assert warnings_logged == []
def test_nodeinfo_without_a_user_id_reaches_the_handler(radio):
"""A NodeInfo whose ``User`` has no ``id`` is filed under its sender.
The library's own NodeInfo callback raised ``KeyError('id')`` on it, which
ended ``_handlePacketFromRadio`` before the packet was published.
"""
profile = mesh_pb2.User(long_name="No Id", short_name="NOID").SerializeToString()
_hear(radio.iface, "NODEINFO_APP", profile, packet_id=0x63)
nodes = _node_posts(radio.posts)
assert {node_id: entry["user"]["longName"] for node_id, entry in nodes.items()} == {
SENDER_ID: "No Id"
}
assert radio.iface.nodesByNum[SENDER]["user"]["id"] == SENDER_ID
def test_nodeinfo_with_a_non_canonical_user_id_is_dropped(radio, warnings_logged):
"""Only the sender's canonical id files a NodeInfo under the sender.
A ``user.id`` that names no node, or the sender in another spelling, is a
present id other than the sender's canonical one: dropped like one naming
another node, and kept out of the library's node database.
"""
for packet_id, claimed in ((0x65, "!Decrypted"), (0x66, "!A1A1A1A1")):
profile = mesh_pb2.User(id=claimed, long_name="Decoded").SerializeToString()
_hear(radio.iface, "NODEINFO_APP", profile, packet_id=packet_id)
assert _node_posts(radio.posts) == {}
assert warnings_logged == [
{"context": "handlers.store_nodeinfo", "from_id": SENDER_ID, "user_id": claimed}
for claimed in ("!Decrypted", "!A1A1A1A1")
]
assert radio.iface.nodesByNum[SENDER]["user"]["id"] == SENDER_ID
assert set(radio.iface.nodes) == {SENDER_ID, NAMED_ID, OTHER_ID}
def test_snapshot_skips_an_entry_whose_num_disagrees_with_its_id(radio):
"""A nodeDB entry filed under another node's id is not posted under that id."""
# The library view an unguarded NodeInfo naming B leaves behind: A's own
# entry is filed under B's id as well.
radio.iface.nodes[NAMED_ID] = radio.iface.nodesByNum[SENDER]
nodes = _snapshot(radio)
assert {node_id: entry["num"] for node_id, entry in nodes.items()} == {
SENDER_ID: SENDER,
OTHER_ID: OTHER,
}
def test_snapshot_still_posts_every_radio_entry(radio):
"""The snapshot legitimately posts other nodes: every entry under its own id."""
nodes = _snapshot(radio)
assert set(nodes) == {SENDER_ID, NAMED_ID, OTHER_ID}
for node_id, entry in nodes.items():
assert f"!{entry['num']:08x}" == node_id == entry["user"]["id"]