Files
pyMC_Repeater/tests/test_region_map_wiring.py
agessaman 72e874f838 fix(regions): scope flood replies to the request's region
Build a core RegionMap from the node's served regions and wire it into
the dispatcher and every companion bridge, so a flood reply is re-scoped
to the region its request arrived under (or left plain for a wildcard /
direct request) -- matching firmware simple_repeater::sendFloodReply.
Previously replies went out plain, so a reply to a request in region B
was dropped by B-only repeaters.

The map is built once from the node-wide transport_keys table (each named
region -> RegionEntry, flags=REGION_DENY_FLOOD for deny-flood regions; the
'*' wildcard is deliberately not an entry so plain floods reply plain). A
single shared instance reaches the dispatcher and all bridges. Public
regions rely on name-hashing for their key; a stored key is carried only
when it is genuinely custom material the name would not reproduce, keeping
reply-matching aligned with the forwarding transport-code check.

Region edits at runtime (CLI, web API, Glass sync) all funnel through the
transport_keys CRUD methods, which now fire a post-commit change callback;
the daemon rebuilds the map and reassigns a fresh instance to the
dispatcher and every live bridge (atomic rebind, safe against an in-flight
find_match on the RX thread).

Requires openhop_core with Dispatcher.region_map / CompanionBridge.region_map.
2026-07-24 07:52:16 -07:00

293 lines
9.5 KiB
Python

"""Served-region map: build correctness + dispatcher/bridge wiring.
Core re-scopes a flood reply to the region its request arrived under
(``region_map.apply_reply_scope``), but only when a ``RegionMap`` is wired onto
the dispatcher and companion bridges. This repeater builds that map from the
``transport_keys`` table (``build_region_map``), assigns one shared instance to
the dispatcher and every bridge, and rebuilds it whenever a region is added,
removed, or re-flooded via the storage change hook.
These tests cover the repeater's contribution: the record -> RegionEntry mapping
and flood matching, the storage change hook, and the daemon wiring that keeps a
non-None map on the dispatcher and all live bridges after a runtime change.
"""
import base64
from types import SimpleNamespace
from unittest.mock import MagicMock
from openhop_core.protocol.packet import Packet
from openhop_core.protocol.region_map import REGION_DENY_FLOOD
from openhop_core.protocol.transport_keys import get_auto_key_for, scope_packet
from repeater.data_acquisition.sqlite_handler import SQLiteHandler
from repeater.main import RepeaterDaemon
from repeater.region_map_builder import build_region_map
class _FakeHandler:
def __init__(self, records):
self._records = records
def get_transport_keys(self):
return self._records
def _b64key(name):
return base64.b64encode(get_auto_key_for(name)).decode("ascii")
def _scoped_flood(key, payload=b"reply-body"):
"""A TRANSPORT_FLOOD packet whose transport code was hashed with ``key``."""
pkt = Packet()
pkt.payload = bytearray(payload)
scope_packet(pkt, key)
return pkt
def _plain_flood(payload=b"reply-body"):
pkt = Packet()
pkt.payload = bytearray(payload)
pkt.header = 0x01 # ROUTE_TYPE_FLOOD, no transport codes
return pkt
# ---------------------------------------------------------------------------
# build_region_map: record -> RegionEntry mapping and flood matching
# ---------------------------------------------------------------------------
def test_allow_region_matches_scoped_flood():
rm = build_region_map(
{},
_FakeHandler(
[
{
"id": 1,
"name": "#usa",
"flood_policy": "allow",
"transport_key": _b64key("#usa"),
"parent_id": None,
}
]
),
)
match = rm.find_match(_scoped_flood(get_auto_key_for("#usa")), mask=REGION_DENY_FLOOD)
assert match is not None
assert match.name == "#usa"
assert match.flags == 0
def test_deny_region_is_skipped_under_flood_mask():
rm = build_region_map(
{},
_FakeHandler(
[
{
"id": 2,
"name": "#secret",
"flood_policy": "deny",
"transport_key": _b64key("#secret"),
"parent_id": None,
}
]
),
)
# The entry exists and carries the deny flag ...
assert [r.flags for r in rm.regions] == [REGION_DENY_FLOOD]
# ... so a flood scoped to it still replies plain (find_match returns None).
assert rm.find_match(_scoped_flood(get_auto_key_for("#secret")), mask=REGION_DENY_FLOOD) is None
def test_wildcard_and_empty_names_are_not_entries():
rm = build_region_map(
{},
_FakeHandler(
[
{
"id": 3,
"name": "*",
"flood_policy": "allow",
"transport_key": None,
"parent_id": None,
},
{
"id": 4,
"name": "",
"flood_policy": "allow",
"transport_key": None,
"parent_id": None,
},
{
"id": 5,
"name": " ",
"flood_policy": "allow",
"transport_key": None,
"parent_id": None,
},
]
),
)
assert rm.regions == []
def test_plain_flood_never_matches():
rm = build_region_map(
{},
_FakeHandler(
[
{
"id": 1,
"name": "#usa",
"flood_policy": "allow",
"transport_key": _b64key("#usa"),
"parent_id": None,
}
]
),
)
assert rm.find_match(_plain_flood(), mask=REGION_DENY_FLOOD) is None
def test_private_region_uses_stored_key():
custom = b"\x11" * 16
rm = build_region_map(
{},
_FakeHandler(
[
{
"id": 7,
"name": "$vip",
"flood_policy": "allow",
"transport_key": base64.b64encode(custom).decode("ascii"),
"parent_id": None,
}
]
),
)
entry = rm.regions[0]
assert entry.private_keys == [custom]
assert rm.find_match(_scoped_flood(custom), mask=REGION_DENY_FLOOD) is not None
def test_private_region_without_key_matches_nothing():
rm = build_region_map(
{},
_FakeHandler(
[
{
"id": 8,
"name": "$vip",
"flood_policy": "allow",
"transport_key": None,
"parent_id": None,
}
]
),
)
# No usable key for a "$" region -> never matches (core never name-hashes it).
assert rm.find_match(_scoped_flood(get_auto_key_for("$vip")), mask=REGION_DENY_FLOOD) is None
def test_public_region_with_custom_key_carries_private_key():
custom = b"\x22" * 16
rm = build_region_map(
{},
_FakeHandler(
[
{
"id": 9,
"name": "#usa",
"flood_policy": "allow",
"transport_key": base64.b64encode(custom).decode("ascii"),
"parent_id": None,
}
]
),
)
entry = rm.regions[0]
assert entry.private_keys == [custom] # differs from name hash -> carried through
assert rm.find_match(_scoped_flood(custom), mask=REGION_DENY_FLOOD) is not None
def test_public_region_with_auto_key_relies_on_name_hash():
rm = build_region_map(
{},
_FakeHandler(
[
{
"id": 1,
"name": "#usa",
"flood_policy": "allow",
"transport_key": _b64key("#usa"),
"parent_id": None,
}
]
),
)
# Stored key equals the name hash -> not carried as an explicit private key.
assert rm.regions[0].private_keys is None
def test_missing_storage_yields_empty_map():
assert build_region_map({}, None).regions == []
# ---------------------------------------------------------------------------
# Storage change hook fires on transport_keys writes
# ---------------------------------------------------------------------------
def test_transport_keys_change_hook_fires_on_writes(tmp_path):
handler = SQLiteHandler(tmp_path)
cb = MagicMock()
handler.set_transport_keys_changed_callback(cb)
key_id = handler.create_transport_key("#usa", "allow")
assert key_id is not None
assert cb.call_count == 1
assert handler.update_transport_key(key_id, flood_policy="deny")
assert cb.call_count == 2
# A no-op update (unknown id) must not fire the hook.
assert not handler.update_transport_key(999999, flood_policy="allow")
assert cb.call_count == 2
assert handler.delete_transport_key(key_id)
assert cb.call_count == 3
handler.sync_transport_keys(
[
{"node_id": "n1", "name": "#eu", "flood_policy": "allow"},
]
)
assert cb.call_count == 4
# ---------------------------------------------------------------------------
# Daemon wiring: dispatcher + bridges get a shared, non-None map that a runtime
# region change refreshes for every holder.
# ---------------------------------------------------------------------------
def test_daemon_wires_and_refreshes_region_map(tmp_path):
handler = SQLiteHandler(tmp_path)
handler.create_transport_key("#usa", "allow")
daemon = RepeaterDaemon({"logging": {}, "mesh": {}})
daemon.repeater_handler = SimpleNamespace(storage=SimpleNamespace(sqlite_handler=handler))
daemon.dispatcher = SimpleNamespace(region_map=None)
daemon.companion_bridges = {1: SimpleNamespace(region_map=None)}
daemon._init_region_map()
# Dispatcher has a non-None map with the served region.
assert daemon.dispatcher.region_map is not None
assert [r.name for r in daemon.dispatcher.region_map.regions] == ["#usa"]
# A runtime add fires the storage hook -> refresh reaches dispatcher + bridges.
handler.create_transport_key("#eu", "allow")
assert sorted(r.name for r in daemon.dispatcher.region_map.regions) == ["#eu", "#usa"]
assert daemon.companion_bridges[1].region_map is daemon.dispatcher.region_map
# A runtime delete also reaches every holder.
eu = next(r for r in handler.get_transport_keys() if r["name"] == "#eu")
handler.delete_transport_key(eu["id"])
assert [r.name for r in daemon.dispatcher.region_map.regions] == ["#usa"]
assert daemon.companion_bridges[1].region_map is daemon.dispatcher.region_map