Files
pyMC_Repeater/tests/test_handler_helpers_path_protocol_text.py
T
agessaman 0d50f5feec fix(text): dispatch on the text type and guard replays
Two things firmware does that openHop did not.

Dispatch by type, not by text. simple_room_server::onPeerDataRecv routes
CLI_DATA/CLI_COMMAND to the admin CLI and turns PLAIN into a post;
simple_repeater has no chat function, so it runs every accepted type
through handleCommand with no text test at all. openHop asked
_is_cli_command(message_text) for both, and so got both wrong: a PLAIN
"set ..." ran as a command instead of being posted, a command whose text
was not in the prefix list was published to the room, and an admin's
mistyped repeater command vanished silently instead of being answered.

Guard replays. Both servers open the branch with
`sender_timestamp >= client->last_timestamp`, treat equality as a retry
they accept but do not re-run, and drop anything older. openHop checked
neither, so replaying an admin command with the same timestamp and
different attempt bits re-executed it -- packet dedup does not catch that
because the bytes differ. Note ACL._is_replay is not reusable here: its
strict <= rejects the equal case this branch accepts.

Both read from packet.decrypted, which openhop_core now publishes. A core
too old to report them falls back to the previous text-prefix behaviour
and logs a warning rather than dropping every message.
2026-09-07 22:27:50 +01:00

1161 lines
43 KiB
Python

import struct
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openhop_core.node.handlers.result import HandlerResult
from openhop_core.protocol import Identity, LocalIdentity
from openhop_core.protocol.packet_builder import PacketBuilder
from repeater.handler_helpers.acl import (
ACL,
PERM_ACL_ADMIN,
PERM_ACL_READ_WRITE,
ClientInfo,
)
from repeater.handler_helpers.path import PathHelper
from repeater.handler_helpers.protocol_request import ProtocolRequestHelper
from repeater.handler_helpers.text import TextHelper
# ---------------------------------------------------------------------------
# Real-crypto collision scaffolding (BUG-053 "try all local candidates").
#
# A one-byte destination hash can collide: a packet addressed to a remote node
# (or a forged packet) can share its first public-key byte with a local
# identity. These helpers build genuinely-encrypted packets so we can prove the
# repeater consumes only when a local identity actually decrypts, and otherwise
# forwards.
# ---------------------------------------------------------------------------
TXT_TYPE_CLI_DATA = 1 # no delivery ACK -> deterministic (no background ACK task)
def _distinct_identities(n=3):
"""Return `n` identities whose public keys start with distinct bytes."""
ids, seen = [], set()
while len(ids) < n:
idn = LocalIdentity()
first = idn.get_public_key()[0]
if first in seen:
continue
seen.add(first)
ids.append(idn)
return ids
class _SendDest:
"""Minimal contact object accepted by PacketBuilder as a send destination."""
def __init__(self, pubkey: bytes):
self.public_key = pubkey.hex()
self.out_path = []
self.out_path_len = -1
def _force_dest_hash(packet, hash_byte: int):
"""Rewrite the on-air one-byte dest hash to simulate a prefix collision."""
packet.payload = bytearray(packet.payload)
packet.payload[0] = hash_byte
return packet
def _acl_with_client(sender, local_identity=None):
"""ACL holding `sender` as an admin client.
When `local_identity` is given, the real shared secret is precomputed —
the protocol-request handler reads `client.shared_secret` directly.
"""
acl = ACL()
sender_pub = sender.get_public_key()
client = ClientInfo(Identity(sender_pub), permissions=PERM_ACL_ADMIN)
if local_identity is not None:
client.shared_secret = Identity(sender_pub).calc_shared_secret(
local_identity.get_private_key()
)
acl.clients[sender_pub] = client
return acl
class _FakeId:
def __init__(self, pubkey: bytes):
self._pubkey = pubkey
def get_public_key(self):
return self._pubkey
class _FakeClient:
def __init__(self, pubkey: bytes, shared_secret: bytes, permissions=0):
self.id = _FakeId(pubkey)
self.shared_secret = shared_secret
self.permissions = permissions
self.out_path = bytearray()
self.out_path_len = -1
class _FakeACL:
def __init__(self, clients):
self._clients = list(clients)
def get_all_clients(self):
return self._clients
class _PathPacket:
def __init__(self, payload: bytes):
self.payload = bytearray(payload)
self.mark_do_not_retransmit = MagicMock()
class _ReqPacket:
def __init__(self, payload: bytes):
self.payload = bytearray(payload)
self.mark_do_not_retransmit = MagicMock()
@pytest.mark.asyncio
async def test_path_helper_updates_client_out_path_on_valid_decrypt():
client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32)
acl = _FakeACL([client])
helper = PathHelper(acl_dict={0x11: acl})
# Payload: dest(0x11), src(0x22), mac+data...
packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc")
with patch(
"openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt",
return_value=b"\x02\x99\x88\x01",
):
handled = await helper.process_path_packet(packet)
assert handled is True
packet.mark_do_not_retransmit.assert_called_once_with()
assert client.out_path_len == 2
assert bytes(client.out_path) == b"\x99\x88"
assert isinstance(client.last_activity, int)
@pytest.mark.asyncio
async def test_path_helper_registers_embedded_ack():
"""Firmware path returns embed the delivery ACK after the path
(extra_type=PAYLOAD_TYPE_ACK + 4-byte CRC); it must reach ack_received_callback
so local waiters (e.g. room server pushes) resolve."""
client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32)
acl = _FakeACL([client])
ack_fn = AsyncMock()
helper = PathHelper(acl_dict={0x11: acl}, ack_received_callback=ack_fn)
packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc")
# path_len(2) + path + extra_type(PAYLOAD_TYPE_ACK=3) + crc(4, LE)
decrypted = b"\x02\x99\x88" + bytes([0x03]) + bytes.fromhex("4dabaf95")
with patch(
"openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt",
return_value=decrypted,
):
await helper.process_path_packet(packet)
ack_fn.assert_awaited_once_with(0x95AFAB4D)
assert client.out_path_len == 2 # path update still applied
@pytest.mark.asyncio
async def test_path_helper_handles_encoded_path_len_with_embedded_ack():
"""path_len in a path return is the ENCODED wire byte: with 3-byte hashes an
empty path is 0x80, not 0x00. Reading it as a raw count (128) made the
helper bail as 'truncated' before the embedded ACK was registered, so
room-server pushes to same-instance companions timed out forever.
Bytes below are a decrypted path return captured from a live mesh."""
client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32)
acl = _FakeACL([client])
ack_fn = AsyncMock()
helper = PathHelper(acl_dict={0x11: acl}, ack_received_callback=ack_fn)
packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc")
# path_len 0x80 (3-byte hashes, 0 hops) + extra_type ACK + crc + AES padding
decrypted = bytes.fromhex("80038d48208500000000000000000000")
with patch(
"openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt",
return_value=decrypted,
):
await helper.process_path_packet(packet)
ack_fn.assert_awaited_once_with(0x8520488D)
assert client.out_path_len == 0x80 # encoded byte preserved
assert bytes(client.out_path) == b""
@pytest.mark.asyncio
async def test_path_helper_ignores_non_ack_extra():
client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32)
acl = _FakeACL([client])
ack_fn = AsyncMock()
helper = PathHelper(acl_dict={0x11: acl}, ack_received_callback=ack_fn)
packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc")
# extra_type 0x08 (PATH) instead of ACK: nothing to register
decrypted = b"\x02\x99\x88" + bytes([0x08]) + b"\x01\x02\x03\x04"
with patch(
"openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt",
return_value=decrypted,
):
await helper.process_path_packet(packet)
ack_fn.assert_not_awaited()
@pytest.mark.asyncio
async def test_path_helper_returns_false_for_non_matching_or_invalid_inputs():
client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32)
acl = _FakeACL([client])
helper = PathHelper(acl_dict={0x11: acl})
assert await helper.process_path_packet(_PathPacket(payload=b"\x11")) is False
assert await helper.process_path_packet(_PathPacket(payload=b"\x33\x22\xaa\xbb")) is False
no_secret_client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"")
helper_no_secret = PathHelper(acl_dict={0x11: _FakeACL([no_secret_client])})
assert (
await helper_no_secret.process_path_packet(_PathPacket(payload=b"\x11\x22\xaa\xbb"))
is False
)
with patch("openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt", return_value=None):
assert await helper.process_path_packet(_PathPacket(payload=b"\x11\x22\xaa\xbb")) is False
# A valid MAC with an invalid or truncated PATH envelope is not local
# ownership; the forwarding engine must remain eligible to handle it.
with patch(
"openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt",
return_value=b"\x7f\x99\x88\x01",
):
invalid_packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc")
assert await helper.process_path_packet(invalid_packet) is False
invalid_packet.mark_do_not_retransmit.assert_not_called()
with patch(
"openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt",
return_value=b"\x02\x99",
):
truncated_packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc")
assert await helper.process_path_packet(truncated_packet) is False
truncated_packet.mark_do_not_retransmit.assert_not_called()
@pytest.mark.asyncio
async def test_protocol_request_process_routes_and_marks_no_retransmit():
injector = AsyncMock(return_value=True)
helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=injector)
assert await helper.process_request_packet(_ReqPacket(payload=b"\x01")) is False
pkt_unknown = _ReqPacket(payload=b"\x99\x01")
assert await helper.process_request_packet(pkt_unknown) is False
dest = 0x42
response_packet = object()
async def _core_handler(_packet):
return HandlerResult.consumed(response_packet)
helper.handlers[dest] = {"handler": _core_handler}
pkt = _ReqPacket(payload=bytes([dest, 0x01, 0x02]))
with patch("repeater.handler_helpers.protocol_request.asyncio.sleep", new_callable=AsyncMock):
handled = await helper.process_request_packet(pkt)
assert handled is True
pkt.mark_do_not_retransmit.assert_called_once()
injector.assert_awaited_once_with(response_packet, wait_for_ack=False)
@pytest.mark.asyncio
async def test_protocol_request_forwards_on_dest_hash_collision():
"""A REQ whose dest prefix collides with ours but does not decrypt for a
local client must not be consumed, so the engine can still forward it."""
injector = AsyncMock(return_value=True)
helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=injector)
dest = 0x42
async def _core_handler(_packet):
# MAC failed for every same-hash candidate -> not for us.
return HandlerResult.not_for_us()
helper.handlers[dest] = {"handler": _core_handler}
pkt = _ReqPacket(payload=bytes([dest, 0x01, 0x02]))
handled = await helper.process_request_packet(pkt)
assert handled is False
pkt.mark_do_not_retransmit.assert_not_called()
injector.assert_not_awaited()
@pytest.mark.asyncio
async def test_protocol_request_consumes_authenticated_without_response():
"""A REQ that authenticates for us but yields no reply is still consumed."""
injector = AsyncMock(return_value=True)
helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=injector)
dest = 0x42
async def _core_handler(_packet):
return HandlerResult.consumed()
helper.handlers[dest] = {"handler": _core_handler}
pkt = _ReqPacket(payload=bytes([dest, 0x01, 0x02]))
handled = await helper.process_request_packet(pkt)
assert handled is True
pkt.mark_do_not_retransmit.assert_called_once()
injector.assert_not_awaited()
@pytest.mark.asyncio
async def test_protocol_request_process_exception_returns_false():
helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=AsyncMock())
async def _boom(_packet):
raise RuntimeError("oops")
helper.handlers[0x33] = {"handler": _boom}
pkt = _ReqPacket(payload=b"\x33\x01")
assert await helper.process_request_packet(pkt) is False
def test_protocol_request_handle_get_status_builds_56_byte_payload():
engine = SimpleNamespace(
start_time=time.time() - 120,
rx_count=7,
forwarded_count=5,
sent_flood_count=2,
sent_direct_count=3,
recv_flood_count=4,
recv_direct_count=1,
direct_dup_count=6,
flood_dup_count=8,
airtime_mgr=SimpleNamespace(total_airtime_ms=9300, total_rx_airtime_ms=4200),
)
radio = SimpleNamespace(
get_noise_floor=lambda: -110,
get_last_rssi=lambda: -70,
get_last_snr=lambda: 2.5,
crc_error_count=11,
)
helper = ProtocolRequestHelper(
identity_manager=MagicMock(),
packet_injector=AsyncMock(),
radio=radio,
engine=engine,
)
data = helper._handle_get_status(client=None, timestamp=0, req_data=b"")
assert isinstance(data, (bytes, bytearray))
assert len(data) == 56
def test_protocol_request_access_list_admin_and_reserved_rules():
admin = SimpleNamespace(is_admin=lambda: True)
not_admin = SimpleNamespace(is_admin=lambda: False)
c1 = _FakeClient(pubkey=b"A" * 32, shared_secret=b"k" * 32, permissions=0x02)
c2 = _FakeClient(pubkey=b"B" * 32, shared_secret=b"k" * 32, permissions=0x00)
acl = _FakeACL([c1, c2])
helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=AsyncMock())
assert helper._handle_get_access_list(not_admin, 0, b"\x00\x00", acl) is None
assert helper._handle_get_access_list(admin, 0, b"\x01\x00", acl) is None
out = helper._handle_get_access_list(admin, 0, b"\x00\x00", acl)
assert isinstance(out, bytes)
# One active entry only: 6-byte key prefix + 1-byte perms
assert len(out) == 7
assert out[-1] == 0x02
def test_protocol_request_get_neighbours_sort_and_pagination():
neighbors = {
"AA" * 16: {
"is_repeater": True,
"zero_hop": True,
"last_seen": time.time() - 1,
"snr": 5.0,
},
"BB" * 16: {
"is_repeater": True,
"zero_hop": True,
"last_seen": time.time() - 10,
"snr": 1.0,
},
"CC" * 16: {
"is_repeater": False,
"zero_hop": True,
"last_seen": time.time() - 1,
"snr": 9.0,
},
}
storage = SimpleNamespace(get_neighbors=lambda: neighbors)
helper = ProtocolRequestHelper(
identity_manager=MagicMock(),
packet_injector=AsyncMock(),
neighbor_tracker=SimpleNamespace(storage=storage),
)
# version=0, count=2, offset=0, order_by=2(strongest), pubkey_prefix_len=4, random=0
req = bytes([0, 2]) + struct.pack("<H", 0) + bytes([2, 4]) + b"\x00\x00\x00\x00"
out = helper._handle_get_neighbours(client=None, timestamp=0, req_data=req)
total, returned = struct.unpack_from("<HH", out, 0)
assert total == 2
assert returned == 2
def test_protocol_request_owner_info_fallback_version():
helper = ProtocolRequestHelper(
identity_manager=MagicMock(),
packet_injector=AsyncMock(),
config={"repeater": {"node_name": "node-x", "owner_info": "owner-y"}},
)
with patch("importlib.metadata.version", side_effect=Exception("no pkg")):
blob = helper._handle_get_owner_info(client=None, timestamp=0, req_data=b"")
text = blob.decode("utf-8")
assert "node-x" in text
assert "owner-y" in text
def test_text_helper_cli_prefix_and_admin_permission_checks():
# 0x21 is ADMIN (role 3). 0x22 is READ_WRITE (role 2) — the case that must
# NOT pass the admin gate: the old `permissions & 0x02` test let a
# read-write client run admin CLI commands.
acl = _FakeACL(
[
_FakeClient(
pubkey=bytes([0x21]) + b"x" * 31,
shared_secret=b"k" * 32,
permissions=PERM_ACL_ADMIN,
),
_FakeClient(
pubkey=bytes([0x22]) + b"x" * 31,
shared_secret=b"k" * 32,
permissions=PERM_ACL_READ_WRITE,
),
]
)
helper = TextHelper(identity_manager=MagicMock(), acl_dict={0x41: acl})
assert helper._is_cli_command("get status") is True
assert helper._is_cli_command("99|get status") is True
assert helper._is_cli_command("04|discover.neighbors") is True
assert helper._is_cli_command("hello world") is False
assert helper._check_admin_permission_for_identity(0x21, 0x41) is True
assert helper._check_admin_permission_for_identity(0x22, 0x41) is False
assert helper._check_admin_permission_for_identity(0x23, 0x41) is False
class _TxtPacket:
"""A packet as the core TextMessageHandler leaves it.
``txt_type``/``sender_timestamp`` default to absent so a test can model a
core too old to publish them.
"""
def __init__(self, text: str, txt_type=None, sender_timestamp=None):
self.decrypted = {"text": text}
if txt_type is not None:
self.decrypted["txt_type"] = txt_type
if sender_timestamp is not None:
self.decrypted["sender_timestamp"] = sender_timestamp
self.payload = bytearray([0x41, 0x21])
self.mark_do_not_retransmit = MagicMock()
def _room_helper(permissions=PERM_ACL_ADMIN):
"""A TextHelper wired as a room-server identity with a stub CLI and store."""
helper = TextHelper(identity_manager=MagicMock(), acl_dict={})
room = MagicMock()
room.add_post = AsyncMock(return_value=True)
room.cli = MagicMock()
room.cli.handle_command = MagicMock(return_value="ok")
helper.room_servers = {0x41: room}
helper.handlers = {0x41: {"name": "room", "type": "room_server", "identity": MagicMock()}}
helper._resolve_sender_client = MagicMock(
return_value=_FakeClient(
pubkey=bytes([0x21]) + b"x" * 31,
shared_secret=b"k" * 32,
permissions=permissions,
)
)
helper._check_admin_permission_for_identity = MagicMock(return_value=True)
helper._send_cli_reply = AsyncMock()
return helper, room
async def _deliver(helper, packet, identity_type="repeater", name="rep"):
await helper._on_message_received(
identity_name=name,
identity_type=identity_type,
packet=packet,
dest_hash=0x41,
src_hash=0x21,
)
def _repeater_cli_helper():
"""A TextHelper wired as a repeater identity whose admin CLI is a stub."""
helper = TextHelper(identity_manager=MagicMock(), acl_dict={})
helper.repeater_hash = 0x41
helper.cli = MagicMock()
helper.cli.handle_command = MagicMock(return_value="ok")
helper.handlers = {0x41: {"name": "rep", "type": "repeater", "identity": MagicMock()}}
helper._resolve_sender_client = MagicMock(
return_value=_FakeClient(
pubkey=bytes([0x21]) + b"x" * 31,
shared_secret=b"k" * 32,
permissions=PERM_ACL_ADMIN,
)
)
helper._check_admin_permission_for_identity = MagicMock(return_value=True)
helper._send_cli_reply = AsyncMock()
return helper
@pytest.mark.asyncio
@pytest.mark.parametrize(
"txt_type,runs",
[
(0, True), # PLAIN -- legacy CLI, firmware still accepts it
(1, True), # CLI_DATA -- what every released client sends
(3, True), # CLI_COMMAND -- the type firmware split out (2c0ace25)
(2, False), # SIGNED_PLAIN -- a room post, never a command
(None, True), # no type reported (older core): behave as before
],
)
async def test_text_helper_runs_cli_only_for_server_text_types(txt_type, runs):
"""[fails pre-fix for SIGNED_PLAIN] Only the accepted types reach the CLI.
simple_repeater::onPeerDataRecv opens its TXT_MSG branch by rejecting any
flags outside {PLAIN, CLI_DATA, CLI_COMMAND}. openHop dispatched purely on
whether the text looked like a command, so a SIGNED_PLAIN -- a room post,
whose 4-byte author prefix the core handler has already stripped, leaving
bare text -- ran as an admin CLI command whenever it happened to start with
a command prefix.
All three accepted types run here because a repeater has no chat function;
that a *plain* one runs with no text test is covered separately.
``txt_type is None`` is a core too old to report it; that must keep working
rather than silently dropping every message.
"""
helper = _repeater_cli_helper()
packet = _TxtPacket("get status", txt_type)
await helper._on_message_received(
identity_name="rep",
identity_type="repeater",
packet=packet,
dest_hash=0x41,
src_hash=0x21,
)
assert helper.cli.handle_command.called is runs
assert helper._send_cli_reply.await_count == (1 if runs else 0)
@pytest.mark.asyncio
async def test_text_helper_signed_plain_is_not_stored_as_a_room_post():
"""A SIGNED_PLAIN never reaches the room server either.
simple_room_server::onPeerDataRecv carries the same filter as the repeater;
only PLAIN becomes a post there. openHop stored whatever arrived.
"""
helper, room = _room_helper(permissions=PERM_ACL_READ_WRITE)
await _deliver(helper, _TxtPacket("hello room", 2, sender_timestamp=100), "room_server", "room")
room.add_post.assert_not_awaited()
# ...while a plain post from the same writer still lands.
await _deliver(helper, _TxtPacket("hello room", 0, sender_timestamp=101), "room_server", "room")
room.add_post.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"txt_type,is_command",
[
(0, False), # PLAIN -> a post
(1, True), # CLI_DATA -> the admin CLI
(3, True), # CLI_COMMAND -> the admin CLI
],
)
async def test_room_dispatches_on_type_not_text(txt_type, is_command):
"""[fails pre-fix] The room splits command from post by type, as firmware does.
simple_room_server::onPeerDataRecv routes CLI_DATA/CLI_COMMAND through
handleCommand and turns PLAIN into a post. openHop asked
`_is_cli_command(message_text)` instead, so it got both wrong: a PLAIN
"get status" ran as a command rather than being posted, and a CLI command
whose text was not in the prefix list was published to the room.
Both cases use the *same* text, so only the type can decide.
"""
helper, room = _room_helper()
await _deliver(
helper,
_TxtPacket("get status", txt_type, sender_timestamp=100),
identity_type="room_server",
name="room",
)
assert room.cli.handle_command.called is is_command
assert room.add_post.await_count == (0 if is_command else 1)
@pytest.mark.asyncio
async def test_repeater_runs_any_accepted_type_without_a_text_test():
"""[fails pre-fix] A repeater has no chat, so every accepted type is a command.
simple_repeater::onPeerDataRecv hands PLAIN, CLI_DATA and CLI_COMMAND alike
to handleCommand with no text test. openHop dropped anything whose text did
not start with a known prefix, so an admin's mistyped command vanished
silently instead of being answered.
"""
helper = _repeater_cli_helper()
await _deliver(helper, _TxtPacket("frobnicate", 1, sender_timestamp=100))
helper.cli.handle_command.assert_called_once()
assert helper.cli.handle_command.call_args.kwargs["command"] == "frobnicate"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"second_timestamp,label",
[(99, "older"), (100, "equal")],
)
async def test_repeater_command_is_not_run_twice(second_timestamp, label):
"""[fails pre-fix] A resent command is not executed again.
simple_repeater guards on `sender_timestamp >= client->last_timestamp` and
treats equality as a retry it answers with an empty reply; an older stamp is
dropped whole. openHop checked neither, so replaying an admin command with
the same timestamp and different attempt bits re-ran it -- packet dedup does
not catch that, because the bytes differ.
"""
helper = _repeater_cli_helper()
await _deliver(helper, _TxtPacket("reboot", 1, sender_timestamp=100))
assert helper.cli.handle_command.call_count == 1
await _deliver(helper, _TxtPacket("reboot", 1, sender_timestamp=second_timestamp))
assert helper.cli.handle_command.call_count == 1, f"{label} timestamp re-ran the command"
@pytest.mark.asyncio
async def test_repeater_command_runs_again_for_a_newer_timestamp():
"""The guard blocks replays, not legitimate repeat commands."""
helper = _repeater_cli_helper()
await _deliver(helper, _TxtPacket("reboot", 1, sender_timestamp=100))
await _deliver(helper, _TxtPacket("reboot", 1, sender_timestamp=101))
assert helper.cli.handle_command.call_count == 2
@pytest.mark.asyncio
async def test_room_post_is_not_stored_twice_for_a_retry():
"""[fails pre-fix] firmware calls addPost only when `!is_retry`."""
helper, room = _room_helper(permissions=PERM_ACL_READ_WRITE)
await _deliver(helper, _TxtPacket("hello", 0, sender_timestamp=100), "room_server", "room")
await _deliver(helper, _TxtPacket("hello", 0, sender_timestamp=100), "room_server", "room")
assert room.add_post.await_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"permissions,posts",
[
(PERM_ACL_ADMIN, True),
(PERM_ACL_READ_WRITE, True),
(0, False), # PERM_ACL_GUEST -- read the room, do not write to it
],
)
async def test_text_helper_room_post_denies_guests(permissions, posts):
"""[fails pre-fix] A guest may read the room but not post to it.
simple_room_server::onPeerDataRecv takes the
`(client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST` branch for a
PLAIN message and stores nothing. openHop called add_post for anyone who
authenticated, so the guest password was enough to write to the room --
add_post enforces a length cap and a rate limit, but no role.
"""
helper = TextHelper(identity_manager=MagicMock(), acl_dict={})
room = MagicMock()
room.add_post = AsyncMock(return_value=True)
room.cli = None
helper.room_servers = {0x41: room}
helper.handlers = {0x41: {"name": "room", "type": "room_server", "identity": MagicMock()}}
helper._resolve_sender_client = MagicMock(
return_value=_FakeClient(
pubkey=bytes([0x21]) + b"x" * 31,
shared_secret=b"k" * 32,
permissions=permissions,
)
)
await helper._on_message_received(
identity_name="room",
identity_type="room_server",
packet=_TxtPacket("hello room", 0),
dest_hash=0x41,
src_hash=0x21,
)
assert room.add_post.await_count == (1 if posts else 0)
@pytest.mark.asyncio
async def test_text_helper_process_text_packet_routes_or_forwards():
helper = TextHelper(identity_manager=MagicMock(), acl_dict={})
pkt_short = SimpleNamespace(payload=bytearray([0x01]))
assert await helper.process_text_packet(pkt_short) is False
pkt_unknown = SimpleNamespace(payload=bytearray([0x55, 0x66]))
assert await helper.process_text_packet(pkt_unknown) is False
# Handler decrypts successfully: consume and stop forwarding.
h = AsyncMock(return_value=HandlerResult.consumed())
helper.handlers[0x10] = {"handler": h, "name": "id-a", "type": "repeater"}
helper._on_message_received = AsyncMock()
pkt = SimpleNamespace(payload=bytearray([0x10, 0x66]), mark_do_not_retransmit=MagicMock())
handled = await helper.process_text_packet(pkt)
assert handled is True
h.assert_awaited_once_with(pkt)
helper._on_message_received.assert_awaited_once()
pkt.mark_do_not_retransmit.assert_called_once()
@pytest.mark.asyncio
async def test_text_helper_process_text_packet_hash_collision_forwards():
"""dest hash matches a local identity but decryption fails (collision): the
packet is not ours, so it must NOT be consumed and must be left to forward (#353)."""
helper = TextHelper(identity_manager=MagicMock(), acl_dict={})
# Handler could not decrypt for this identity.
h = AsyncMock(return_value=HandlerResult.not_for_us())
helper.handlers[0x10] = {"handler": h, "name": "id-a", "type": "repeater"}
helper._on_message_received = AsyncMock()
pkt = SimpleNamespace(payload=bytearray([0x10, 0x66]), mark_do_not_retransmit=MagicMock())
handled = await helper.process_text_packet(pkt)
assert handled is False
h.assert_awaited_once_with(pkt)
helper._on_message_received.assert_not_awaited()
pkt.mark_do_not_retransmit.assert_not_called()
@pytest.mark.asyncio
async def test_text_helper_send_packet_success_and_failures():
injector = AsyncMock(side_effect=[True, RuntimeError("fail")])
helper = TextHelper(identity_manager=MagicMock(), packet_injector=injector)
assert await helper._send_packet(object(), wait_for_ack=False) is True
assert await helper._send_packet(object(), wait_for_ack=False) is False
helper.packet_injector = None
assert await helper._send_packet(object(), wait_for_ack=False) is False
def test_text_helper_register_identity_repeater_initializes_cli_and_handler():
acl = _FakeACL([_FakeClient(pubkey=bytes([0x33]) + b"x" * 31, shared_secret=b"k" * 32)])
helper = TextHelper(
identity_manager=MagicMock(),
packet_injector=AsyncMock(),
acl_dict={0x33: acl},
config_path="/tmp/config.yaml",
config={"repeater": {}},
config_manager=MagicMock(),
sqlite_handler=MagicMock(),
)
identity = _FakeId(bytes([0x33]) + b"x" * 31)
with (
patch("repeater.handler_helpers.text.TextMessageHandler", return_value=MagicMock()) as tmh,
patch("repeater.handler_helpers.text.MeshCLI", return_value=MagicMock()) as mesh_cli,
):
helper.register_identity("rep", identity, identity_type="repeater", radio_config={})
tmh.assert_called_once()
mesh_cli.assert_called_once()
assert helper.repeater_hash == 0x33
assert 0x33 in helper.handlers
def test_text_helper_register_identity_room_server_without_event_loop_is_safe():
acl = _FakeACL([_FakeClient(pubkey=bytes([0x34]) + b"x" * 31, shared_secret=b"k" * 32)])
helper = TextHelper(
identity_manager=MagicMock(),
packet_injector=AsyncMock(),
acl_dict={0x34: acl},
config_path="/tmp/config.yaml",
config={"repeater": {}},
config_manager=MagicMock(),
sqlite_handler=MagicMock(),
)
helper._loop = None
identity = _FakeId(bytes([0x34]) + b"x" * 31)
with (
patch("repeater.handler_helpers.text.TextMessageHandler", return_value=MagicMock()),
patch("repeater.handler_helpers.text.RoomServer") as room_server_cls,
patch(
"repeater.handler_helpers.text.asyncio.get_running_loop",
side_effect=RuntimeError("no loop"),
),
):
room_server_obj = MagicMock()
room_server_cls.return_value = room_server_obj
helper.register_identity(
"room-a", identity, identity_type="room_server", radio_config={"max_posts": 2}
)
assert 0x34 in helper.room_servers
@pytest.mark.asyncio
async def test_text_helper_send_cli_reply_uses_direct_path_from_client():
helper = TextHelper(identity_manager=MagicMock(), packet_injector=AsyncMock())
sender = _FakeClient(
pubkey=bytes([0x99]) + b"x" * 31, shared_secret=b"s" * 32, permissions=0x02
)
sender.out_path = bytearray([0xAA, 0xBB])
sender.out_path_len = 2
helper.acl_dict = {0x10: _FakeACL([sender])}
helper._send_packet = AsyncMock(return_value=True)
original_packet = SimpleNamespace(payload=bytearray([0x10, 0x99]), get_route_type=lambda: 1)
reply_packet = SimpleNamespace(path=bytearray(), path_len=0)
with (
patch("openhop_core.protocol.PacketBuilder.create_datagram", return_value=reply_packet),
patch("repeater.handler_helpers.text.asyncio.sleep", new_callable=AsyncMock),
):
await helper._send_cli_reply(
original_packet=original_packet,
reply_text="ok",
handler_info={"identity": _FakeId(bytes([0x10]) + b"i" * 31)},
)
assert bytes(reply_packet.path) == b"\xaa\xbb"
assert reply_packet.path_len == 2
helper._send_packet.assert_awaited_once_with(reply_packet, wait_for_ack=False)
# ---------------------------------------------------------------------------
# Real-crypto collision integration tests (BUG-053).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_text_helper_real_crypto_consume_vs_collision_forward():
"""A real, correctly-encrypted DM to a local room-server identity is consumed;
a DM encrypted for a remote node that merely collides on the one-byte dest
hash fails to decrypt and is left for the forwarding engine."""
local, sender, remote = _distinct_identities()
local_hash = local.get_public_key()[0]
helper = TextHelper(
identity_manager=MagicMock(),
acl_dict={local_hash: _acl_with_client(sender)},
packet_injector=AsyncMock(),
)
helper.register_identity("room-a", local, identity_type="room_server")
helper._on_message_received = AsyncMock()
# Genuine CLI_DATA DM to the local identity from a known sender.
genuine, _ = PacketBuilder.create_text_message(
_SendDest(local.get_public_key()),
sender,
"status",
message_type="flood",
txt_type=TXT_TYPE_CLI_DATA,
)
assert await helper.process_text_packet(genuine) is True
assert genuine.is_marked_do_not_retransmit()
helper._on_message_received.assert_awaited_once()
# DM encrypted for a REMOTE node whose one-byte dest hash collides with ours.
helper._on_message_received.reset_mock()
collision, _ = PacketBuilder.create_text_message(
_SendDest(remote.get_public_key()),
sender,
"not yours",
message_type="flood",
txt_type=TXT_TYPE_CLI_DATA,
)
_force_dest_hash(collision, local_hash)
assert await helper.process_text_packet(collision) is False
assert not collision.is_marked_do_not_retransmit()
helper._on_message_received.assert_not_awaited()
@pytest.mark.asyncio
async def test_protocol_request_real_crypto_consume_vs_collision_forward():
"""A real, correctly-encrypted REQ to a local identity is consumed and
answered; a REQ encrypted for a colliding remote node is left to forward."""
from openhop_core.node.handlers.protocol_request import REQ_TYPE_GET_STATUS
local, sender, remote = _distinct_identities()
local_hash = local.get_public_key()[0]
injector = AsyncMock(return_value=True)
helper = ProtocolRequestHelper(
identity_manager=MagicMock(),
packet_injector=injector,
acl_dict={local_hash: _acl_with_client(sender, local_identity=local)},
)
helper.register_identity("rep", local, identity_type="repeater")
genuine, _ = PacketBuilder.create_protocol_request(
_SendDest(local.get_public_key()), sender, REQ_TYPE_GET_STATUS
)
with patch("repeater.handler_helpers.protocol_request.asyncio.sleep", new_callable=AsyncMock):
assert await helper.process_request_packet(genuine) is True
assert genuine.is_marked_do_not_retransmit()
injector.assert_awaited() # a response was transmitted
injector.reset_mock()
collision, _ = PacketBuilder.create_protocol_request(
_SendDest(remote.get_public_key()), sender, REQ_TYPE_GET_STATUS
)
_force_dest_hash(collision, local_hash)
assert await helper.process_request_packet(collision) is False
assert not collision.is_marked_do_not_retransmit()
injector.assert_not_awaited()
# ---------------------------------------------------------------------------
# REQ_TYPE_GET_TELEMETRY_DATA (matches simple_repeater / simple_room_server
# handleRequest). Response payload is CayenneLPP: a base voltage entry on
# TELEM_CHANNEL_SELF, then configured sensors gated by the (inverse) perm mask.
# ---------------------------------------------------------------------------
from openhop_core.node.handlers.protocol_request import ( # noqa: E402
REQ_TYPE_GET_TELEMETRY_DATA,
)
from openhop_core.protocol.cayenne_lpp import ( # noqa: E402
TELEM_CHANNEL_SELF,
encode_barometric_pressure,
encode_current,
encode_power,
encode_relative_humidity,
encode_temperature,
encode_voltage,
)
class _FakeSensorManager:
"""Minimal sensor manager exposing cached readings via get_summary()."""
def __init__(self, readings):
self._readings = readings
def get_summary(self):
return {"readings": self._readings}
def _reading(ok=True, **data):
return {"name": "s", "type": "t", "ok": ok, "data": data}
def test_telemetry_base_voltage_floor_when_no_sensors():
"""No configured sensors -> base voltage-only floor of 0.0 V."""
helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=AsyncMock())
admin = SimpleNamespace(is_guest=lambda: False)
lpp = helper._handle_get_telemetry(admin, 0, b"\x00")
assert lpp == encode_voltage(TELEM_CHANNEL_SELF, 0.0)
def test_telemetry_base_voltage_from_ups_sensor():
"""The configured UPS bus voltage seeds the base entry."""
sm = _FakeSensorManager([_reading(bus_voltage_v=12.6)])
helper = ProtocolRequestHelper(
identity_manager=MagicMock(), packet_injector=AsyncMock(), sensor_manager=sm
)
admin = SimpleNamespace(is_guest=lambda: False)
lpp = helper._handle_get_telemetry(admin, 0, b"\x00")
# Base voltage stays on channel 1, and the additional INA219 voltage view
# is emitted on the next channel when environment telemetry is allowed.
assert lpp == encode_voltage(TELEM_CHANNEL_SELF, 12.6) + encode_voltage(
TELEM_CHANNEL_SELF + 1, 12.6
)
def test_telemetry_admin_full_mask_includes_environment_sensors():
"""Admin with full mask gets configured env sensors after the base entry.
The UPS reading has no temperature/humidity, so channel 2 is assigned to the
first environment sensor (firmware querySensors channel assignment).
"""
sm = _FakeSensorManager(
[
_reading(bus_voltage_v=12.6, current_ma=500.0, power_mw=6000.0),
_reading(temperature_c=21.5, humidity_pct=55.0),
]
)
helper = ProtocolRequestHelper(
identity_manager=MagicMock(), packet_injector=AsyncMock(), sensor_manager=sm
)
admin = SimpleNamespace(is_guest=lambda: False)
# req_data[0] = 0x00 inverse mask -> perm_mask 0xFF (environment allowed).
lpp = helper._handle_get_telemetry(admin, 0, b"\x00")
expected = (
encode_voltage(TELEM_CHANNEL_SELF, 12.6)
+ encode_voltage(TELEM_CHANNEL_SELF + 1, 12.6)
+ encode_current(TELEM_CHANNEL_SELF + 1, 0.5)
+ encode_power(TELEM_CHANNEL_SELF + 1, 6)
+ encode_temperature(TELEM_CHANNEL_SELF + 2, 21.5)
+ encode_relative_humidity(TELEM_CHANNEL_SELF + 2, 55.0)
)
assert lpp == expected
def test_station_g3_subwatt_power_uses_lpp_one_watt_resolution():
sm = _FakeSensorManager([_reading(bus_voltage_v=12.46, current_ma=59.7, power_mw=744.0)])
helper = ProtocolRequestHelper(
identity_manager=MagicMock(), packet_injector=AsyncMock(), sensor_manager=sm
)
admin = SimpleNamespace(is_guest=lambda: False)
lpp = helper._handle_get_telemetry(admin, 0, b"\x00")
expected = (
encode_voltage(TELEM_CHANNEL_SELF, 12.46)
+ encode_voltage(TELEM_CHANNEL_SELF + 1, 12.46)
+ encode_current(TELEM_CHANNEL_SELF + 1, 0.0597)
+ encode_power(TELEM_CHANNEL_SELF + 1, 0)
)
assert lpp == expected
def test_negative_power_is_preserved_as_current_but_omitted_from_unsigned_lpp_power():
sm = _FakeSensorManager([_reading(bus_voltage_v=12.46, current_ma=-59.7, power_mw=-744.0)])
helper = ProtocolRequestHelper(
identity_manager=MagicMock(), packet_injector=AsyncMock(), sensor_manager=sm
)
admin = SimpleNamespace(is_guest=lambda: False)
lpp = helper._handle_get_telemetry(admin, 0, b"\x00")
expected = (
encode_voltage(TELEM_CHANNEL_SELF, 12.46)
+ encode_voltage(TELEM_CHANNEL_SELF + 1, 12.46)
+ encode_current(TELEM_CHANNEL_SELF + 1, -0.0597)
)
assert lpp == expected
def test_telemetry_bme280_emits_temperature_humidity_and_pressure_on_one_channel():
"""A BME280-shaped reading emits all three measured values on one channel.
Mirrors the firmware's query_bme280 order: temperature, relative humidity,
then barometric pressure.
"""
sm = _FakeSensorManager([_reading(temperature_c=21.5, humidity_pct=55.0, pressure_hpa=1013.25)])
helper = ProtocolRequestHelper(
identity_manager=MagicMock(), packet_injector=AsyncMock(), sensor_manager=sm
)
admin = SimpleNamespace(is_guest=lambda: False)
lpp = helper._handle_get_telemetry(admin, 0, b"\x00")
expected = (
encode_voltage(TELEM_CHANNEL_SELF, 0.0)
+ encode_temperature(TELEM_CHANNEL_SELF + 1, 21.5)
+ encode_relative_humidity(TELEM_CHANNEL_SELF + 1, 55.0)
+ encode_barometric_pressure(TELEM_CHANNEL_SELF + 1, 1013.25)
)
assert lpp == expected
def test_telemetry_guest_forced_to_base_only():
"""A guest is restricted to base telemetry even when requesting the full mask."""
sm = _FakeSensorManager([_reading(temperature_c=21.5, humidity_pct=55.0)])
helper = ProtocolRequestHelper(
identity_manager=MagicMock(), packet_injector=AsyncMock(), sensor_manager=sm
)
guest = SimpleNamespace(is_guest=lambda: True)
lpp = helper._handle_get_telemetry(guest, 0, b"\x00")
assert lpp == encode_voltage(TELEM_CHANNEL_SELF, 0.0)
def test_telemetry_inverse_mask_gates_environment():
"""An inverse mask that clears the environment bit strips env sensors.
perm_mask = ~inverse_mask; inverse byte 0x04 clears TELEM_PERM_ENVIRONMENT.
"""
sm = _FakeSensorManager([_reading(temperature_c=21.5)])
helper = ProtocolRequestHelper(
identity_manager=MagicMock(), packet_injector=AsyncMock(), sensor_manager=sm
)
admin = SimpleNamespace(is_guest=lambda: False)
lpp = helper._handle_get_telemetry(admin, 0, b"\x04")
assert lpp == encode_voltage(TELEM_CHANNEL_SELF, 0.0)
@pytest.mark.asyncio
async def test_telemetry_req_end_to_end_produces_response_and_advances_watermark():
"""A real, correctly-encrypted telemetry REQ is consumed, answered through
the normal _build_response path, and advances the client replay watermark."""
local, sender, _remote = _distinct_identities()
local_hash = local.get_public_key()[0]
acl = _acl_with_client(sender, local_identity=local)
client = acl.clients[sender.get_public_key()]
assert client.last_timestamp == 0
sm = _FakeSensorManager([_reading(bus_voltage_v=12.6)])
injector = AsyncMock(return_value=True)
helper = ProtocolRequestHelper(
identity_manager=MagicMock(),
packet_injector=injector,
acl_dict={local_hash: acl},
sensor_manager=sm,
)
helper.register_identity("rep", local, identity_type="repeater")
genuine, ts = PacketBuilder.create_protocol_request(
_SendDest(local.get_public_key()),
sender,
REQ_TYPE_GET_TELEMETRY_DATA,
b"\x00",
)
with patch("repeater.handler_helpers.protocol_request.asyncio.sleep", new_callable=AsyncMock):
assert await helper.process_request_packet(genuine) is True
assert genuine.is_marked_do_not_retransmit()
injector.assert_awaited() # a RESPONSE frame was transmitted
assert client.last_timestamp == ts # replay watermark advanced