test(companion): drive the scope override through the real repeater path

The existing guard stopped at the bridge's injector, so it proved inheritance
but not that the override survives what the repeater actually does with a
packet. This adds the missing leg: a real core Dispatcher, a real
RepeaterHandler engine and a real PacketRouter, with only the radio and the
daemon container substituted. The assertion is on the bytes read back off the
radio.

The conflict is made real rather than notional. Both the companion's default
scope and the dispatcher's are set to the repeater's own region, so without
the override that region is what goes out -- asserted by a control test that
sends the same message on the same channel with no override and finds the
repeater's code on the air. Only the override differs between the two.

Verified by mutation rather than by assuming: neutering
`_apply_explicit_flood_scope` so it falls back to the node's resolver fails
the integration test while leaving the control passing, so the test is
sensitive to the thing it names and not merely to "something got scoped".

A second mutation settles the marker question the previous commit reworded.
Clearing `_flood_scope_applied` while keeping the scoping leaves the
integration test green and fails only the assertion on the mark itself,
confirming the transport code is protected by the route change alone.

The fake radio returns a metadata dict because that is what the shipped
wrappers do. A bare True crashes the engine at `tx_metadata.get(...)` after
the bytes are already on the air -- harmless here since sx1262 and kiss_modem
both return dicts, but it would have masked a real failure behind an
apparently successful send.
This commit is contained in:
agessaman committed 2026-09-11 14:32:11 -07:00
1 parent 58f7293b33
commit 71837f214c
1 file changed
+154 -1
+154 -1
View File
@@ -17,18 +17,22 @@ were lost, the message would go out under the repeater's own region instead of
the one the client asked for, and nothing would report an error.
"""
import functools
import struct
from unittest.mock import AsyncMock, Mock
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from openhop_core import LocalIdentity
from openhop_core.companion import constants as core_constants
from openhop_core.companion.models import Channel
from openhop_core.node.dispatcher import Dispatcher
from openhop_core.protocol import Packet
from openhop_core.protocol.constants import ROUTE_TYPE_TRANSPORT_FLOOD
from openhop_core.protocol.transport_keys import calc_transport_code, get_auto_key_for
from repeater.companion.bridge import RepeaterCompanionBridge
from repeater.companion.frame_server import CompanionFrameServer
from repeater.packet_router import PacketRouter
# The dependency pin is `openhop_core@dev`, so until the Core change is on that
# branch CI installs a Core without these symbols and this module cannot even
@@ -104,3 +108,152 @@ async def test_scoped_send_through_repeater_bridge_reaches_injector_scoped():
# is asserted as the contract Core states, not as the thing standing
# between the message and the repeater's own region.
assert pkt._flood_scope_applied is True
# ---------------------------------------------------------------------------
# Full production path: bridge -> PacketRouter -> engine -> dispatcher -> radio
# ---------------------------------------------------------------------------
# The repeater's own region, used both as the companion's default scope and as
# the dispatcher's, so the override is fighting a scope that really would win.
REPEATER_REGION_KEY = get_auto_key_for("#washington")
CLIENT_REGION_KEY = get_auto_key_for("#USA")
class _CaptureRadio:
"""Radio that records the raw bytes the dispatcher hands it."""
def __init__(self):
self.sent: list[bytes] = []
self.spreading_factor = 8
self.bandwidth = 125000
self.coding_rate = 8
self.preamble_length = 17
self.frequency = 915000000
self.tx_power = 14
async def send(self, data):
self.sent.append(bytes(data))
# Real wrappers (sx1262, kiss_modem) return a metadata dict, and the
# engine reads `.get()` off it after a successful TX. Returning a bare
# True here would crash the engine *after* the bytes were already on
# the air, hiding the thing this test exists to check.
return {"radio_id": "test-radio"}
def set_rx_callback(self, callback):
pass
def _engine_config():
"""Minimal engine config: no duty-cycle deferral, no TX delay, no adverts."""
return {
"repeater": {
"mode": "forward",
"cache_ttl": 3600,
"node_name": "scope-int",
"send_advert_interval_hours": 0,
},
"mesh": {"unscoped_flood_allow": True, "loop_detect": "off"},
"delays": {"tx_delay_factor": 0.0, "direct_tx_delay_factor": 0.0},
"duty_cycle": {"max_airtime_per_minute": 3600, "enforcement_enabled": False},
"radio": {
"spreading_factor": 8,
"bandwidth": 125000,
"coding_rate": 8,
"preamble_length": 17,
},
}
def _wire_production_chain():
"""Assemble the real send chain and return (bridge, radio).
Everything from the bridge down is the production object: a real core
Dispatcher (whose ``send_packet`` runs the node-level scope resolver), the
real RepeaterHandler engine, and a real PacketRouter. Only the radio and
the daemon container are substituted.
"""
radio = _CaptureRadio()
dispatcher = Dispatcher(radio)
dispatcher.default_flood_transport_key = REPEATER_REGION_KEY
with (
patch("repeater.engine.StorageCollector"),
patch("repeater.engine.RepeaterHandler._start_background_tasks"),
):
from repeater.engine import RepeaterHandler
engine = RepeaterHandler(_engine_config(), dispatcher, 0xAB, local_hash_bytes=bytes([0xAB]))
daemon = MagicMock()
daemon.repeater_handler = engine
daemon.dispatcher = dispatcher
daemon.companion_bridges = {}
for helper in (
"trace_helper",
"discovery_helper",
"advert_helper",
"login_helper",
"text_helper",
"path_helper",
"protocol_request_helper",
):
setattr(daemon, helper, None)
router = PacketRouter(daemon)
bridge = RepeaterCompanionBridge(
LocalIdentity(),
functools.partial(router.inject_packet, origin_hash="0x77"),
node_name="scoped-companion",
)
bridge.channels.set(0, Channel(name="test-ch", secret=b"\xab" * 16))
# The companion's own default scope is the repeater's region. Without the
# override this is what every flood it sends is scoped with, so the test
# below is a genuine contest rather than a scope applied to empty space.
bridge.set_default_flood_scope("washington", REPEATER_REGION_KEY)
daemon.companion_bridges = {0x77: bridge}
return bridge, radio
def _last_radio_packet(radio):
assert radio.sent, "nothing reached the radio"
pkt = Packet()
assert pkt.read_from(radio.sent[-1]), "radio bytes did not parse"
return pkt
@pytest.mark.asyncio
async def test_default_scope_reaches_the_radio_without_an_override():
"""Control: the repeater's own region is what normally goes out.
Without this the test below would pass against a node that simply never
scopes anything, which is the failure it is meant to catch.
"""
bridge, radio = _wire_production_chain()
assert await bridge.send_channel_message(0, "control") is True
pkt = _last_radio_packet(radio)
assert pkt.get_route_type() == ROUTE_TYPE_TRANSPORT_FLOOD
assert pkt.transport_codes[0] == calc_transport_code(REPEATER_REGION_KEY, pkt)
@pytest.mark.asyncio
async def test_override_survives_the_whole_repeater_path_to_the_radio():
"""The client's region is on the air, not the repeater's.
Same node and channel as the control above; only the override differs. The
packet crosses PacketRouter.inject_packet, the RepeaterHandler engine, and
Dispatcher.send_packet -- which runs the node-level scope resolver against
a default that would otherwise claim this packet -- before the bytes are
read back off the radio.
"""
bridge, radio = _wire_production_chain()
assert await bridge.send_channel_message(0, "hello", flood_scope_key=CLIENT_REGION_KEY) is True
pkt = _last_radio_packet(radio)
assert pkt.get_route_type() == ROUTE_TYPE_TRANSPORT_FLOOD
assert pkt.transport_codes[0] == calc_transport_code(CLIENT_REGION_KEY, pkt)
assert pkt.transport_codes[0] != calc_transport_code(REPEATER_REGION_KEY, pkt)
assert pkt.transport_codes[1] == 0