Files
meshcore-bot/tests/unit/test_flood_scope_resolve.py
T
agessaman 16cce92c46 feat(region): set region scopes from the Radio page (#283)
Region scopes had to be hand-edited in config.ini, and the radio's own default
region could not be set from the bot at all. Both now live on the Radio page.

Node Settings gains a Default Region Scope field beside the path hash size,
which is the firmware's own setting — NodePrefs.default_scope_name and
default_scope_key, the region the radio falls back to for any send the bot does
not scope itself. Firmware that has no such command is reported as not having
answered rather than shown as an empty field, and a stored key that is not the
stored name's hash is flagged, because the radio routes by the key and the name
beside it is only a label. A build-flag default stores the bare name while
hashing the '#' form, so the comparison normalizes first.

Clearing it does not go through the meshcore library, because none of its reset
paths can: set_default_flood_scope(None) raises on len(None), "" earns
ILLEGAL_ARG from the firmware, and "*" only works because the frame is padded by
character count rather than by the name it wrote, landing one byte short of the
length the firmware reads as "a scope follows". The firmware's own contract for
clearing is a bare CMD_SET_DEFAULT_FLOOD_SCOPE, so that is what goes out. The
same padding bug is why a device scope name must be ASCII: a multi-byte
character displaces the transport key out of its field.

A separate Region Scopes card covers the bot's side — [Channels] flood_scopes as
an explicit choice between "reply whatever the scope" and an allowlist, and
outgoing_flood_scope_override. It writes config.ini, queues a hot reload, then
polls that reload and reports what the bot did with it, including saying plainly
when nothing picked it up. Per-channel flood_scope.<channel> entries are listed
read-only, since they are the reason a channel can ignore the default.

The two settings interact in a way worth stating on the page: once the bot sends
a scoped message it restores with set_flood_scope("*"), which leaves the radio in
forced-unscoped mode, so the device default stops applying until the bot scopes
another send.

Also from building it:

- flood_scopes left in [Bot] is surfaced rather than hidden. CommandManager
  still honours it when [Channels] is empty, so a page that ignored it would
  show "replies to every scope" while the bot enforced an allowlist — and
  saving "off" hands the allowlist straight back to [Bot], which the banner now
  says instead of claiming the old key stops mattering.
- outgoing_flood_scope_override = none is read as global flood on the send path,
  as it already was everywhere else. send_channel_message tested the raw value
  against a fixed tuple, so the lowercase spelling became the region "#none".
- Scope normalization moved to modules/flood_scope.py so the viewer, a separate
  process, can validate a typed name without importing the bot's command
  machinery. CommandManager delegates to it.
- Dark mode named only .border, so a .border-start divider drew Bootstrap's
  light #dee2e6 onto a dark card. All four directional utilities now match.
2026-09-16 07:59:10 -07:00

408 lines
15 KiB
Python

"""Unit tests for outbound flood scope resolution."""
import configparser
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from modules.command_manager import CommandManager
from modules.models import MeshMessage
from modules.service_plugins.base_service import BaseServicePlugin
def _make_config(**channels_opts: str) -> configparser.ConfigParser:
config = configparser.ConfigParser()
config.add_section("Channels")
for key, val in channels_opts.items():
config.set("Channels", key, val)
return config
def _command_manager(config: configparser.ConfigParser) -> CommandManager:
bot = MagicMock()
bot.config = config
bot.logger = Mock()
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = bot.logger
cm.flood_scope_allow_global = False
cm.flood_scope_keys = {}
return cm
class TestResolveChannelSendScope:
def test_explicit_scope_wins(self):
cm = _command_manager(_make_config())
assert cm.resolve_channel_send_scope(scope="#west") == "#west"
def test_message_reply_scope_when_scope_arg_none(self):
cm = _command_manager(_make_config())
msg = MeshMessage(content="x", channel="general", is_dm=False, reply_scope="#east")
assert cm.resolve_channel_send_scope(scope=None, message=msg) == "#east"
def test_config_section_flood_scope(self):
config = configparser.ConfigParser()
config.add_section("Channels")
config.add_section("Weather_Service")
config.set("Weather_Service", "flood_scope", "west")
cm = _command_manager(config)
assert cm.resolve_channel_send_scope(
scope=None, config_section="Weather_Service"
) == "#west"
def test_returns_none_for_override_fallback(self):
cm = _command_manager(_make_config(outgoing_flood_scope_override="#west"))
assert cm.resolve_channel_send_scope(scope=None) is None
def test_channel_flood_scope_normalizes_channel_and_scope(self):
config = _make_config()
config.set("Channels", "flood_scope.weather", "sea")
cm = _command_manager(config)
assert cm.resolve_channel_send_scope(channel="#Weather") == "#sea"
def test_channel_global_marker_overrides_global_fallback(self):
config = _make_config(outgoing_flood_scope_override="#west")
config.set("Channels", "flood_scope.weather", "*")
cm = _command_manager(config)
assert cm.resolve_channel_send_scope(channel="weather") == "*"
def test_precedence_explicit_over_message(self):
cm = _command_manager(_make_config())
msg = MeshMessage(content="x", channel="general", is_dm=False, reply_scope="#east")
assert cm.resolve_channel_send_scope(scope="#west", message=msg) == "#west"
def test_precedence_reply_and_config_section_over_channel(self):
config = _make_config()
config.set("Channels", "flood_scope.weather", "#channel")
config.add_section("Weather_Service")
config.set("Weather_Service", "flood_scope", "#plugin")
cm = _command_manager(config)
msg = MeshMessage(content="x", channel="weather", is_dm=False, reply_scope="#reply")
assert cm.resolve_channel_send_scope(message=msg, config_section="Weather_Service") == "#reply"
assert cm.resolve_channel_send_scope(
config_section="Weather_Service", channel="weather"
) == "#plugin"
class _StubService(BaseServicePlugin):
config_section = "Weather_Service"
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
class TestGetMeshFloodScope:
def test_reads_and_normalizes_section_key(self):
config = configparser.ConfigParser()
config.add_section("Weather_Service")
config.set("Weather_Service", "flood_scope", "#sea")
bot = MagicMock()
bot.config = config
bot.logger = Mock()
svc = _StubService(bot)
assert svc.get_mesh_flood_scope() == "#sea"
def test_empty_returns_none(self):
config = configparser.ConfigParser()
config.add_section("Weather_Service")
bot = MagicMock()
bot.config = config
bot.logger = Mock()
svc = _StubService(bot)
assert svc.get_mesh_flood_scope() is None
@pytest.mark.asyncio
async def test_send_channel_message_applies_override_when_resolve_returns_none():
config = _make_config(outgoing_flood_scope_override="west")
bot = MagicMock()
bot.config = config
bot.logger = Mock()
bot.connected = True
bot.is_radio_zombie = False
bot.is_radio_offline = False
bot.channel_manager.get_channel_number.return_value = 1
set_flood_scope = AsyncMock(return_value=MagicMock(type="OK"))
send_chan_msg = AsyncMock(return_value=MagicMock(type="OK", payload={}))
bot.meshcore = MagicMock()
bot.meshcore.commands.set_flood_scope = set_flood_scope
bot.meshcore.commands.send_chan_msg = send_chan_msg
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = bot.logger
cm.flood_scope_allow_global = False
cm.flood_scope_keys = {}
cm._check_rate_limits = AsyncMock(return_value=(True, None))
cm._handle_send_result = MagicMock(return_value=True)
cm._is_no_event_received = MagicMock(return_value=False)
await cm.send_channel_message("general", "hi", scope=None)
set_flood_scope.assert_awaited()
assert set_flood_scope.await_args_list[0].args[0] == "#west"
@pytest.mark.asyncio
async def test_send_channel_message_applies_channel_scope():
config = _make_config(outgoing_flood_scope_override="#west")
config.set("Channels", "flood_scope.weather", "sea")
bot = MagicMock()
bot.config = config
bot.logger = Mock()
bot.connected = True
bot.is_radio_zombie = False
bot.is_radio_offline = False
bot.channel_manager.get_channel_number.return_value = 2
set_flood_scope = AsyncMock(return_value=MagicMock(type="OK"))
send_chan_msg = AsyncMock(return_value=MagicMock(type="OK", payload={}))
bot.meshcore = MagicMock()
bot.meshcore.commands.set_flood_scope = set_flood_scope
bot.meshcore.commands.send_chan_msg = send_chan_msg
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = bot.logger
cm.flood_scope_allow_global = False
cm.flood_scope_keys = {}
cm._check_rate_limits = AsyncMock(return_value=(True, None))
cm._handle_send_result = MagicMock(return_value=True)
cm._is_no_event_received = MagicMock(return_value=False)
await cm.send_channel_message("#Weather", "hi", scope=None)
set_flood_scope.assert_awaited()
assert set_flood_scope.await_args_list[0].args[0] == "#sea"
@pytest.mark.asyncio
async def test_send_channel_message_reads_lowercase_none_as_global():
"""Every other path normalizes "none" to the global marker. The send path
used to compare the raw value, so this spelling became the region "#none"
and set_flood_scope was called with it."""
config = _make_config(outgoing_flood_scope_override="none")
bot = MagicMock()
bot.config = config
bot.logger = Mock()
bot.connected = True
bot.is_radio_zombie = False
bot.is_radio_offline = False
bot.channel_manager.get_channel_number.return_value = 1
set_flood_scope = AsyncMock(return_value=MagicMock(type="OK"))
send_chan_msg = AsyncMock(return_value=MagicMock(type="OK", payload={}))
bot.meshcore = MagicMock()
bot.meshcore.commands.set_flood_scope = set_flood_scope
bot.meshcore.commands.send_chan_msg = send_chan_msg
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = bot.logger
cm.flood_scope_allow_global = False
cm.flood_scope_keys = {}
cm._check_rate_limits = AsyncMock(return_value=(True, None))
cm._handle_send_result = MagicMock(return_value=True)
cm._is_no_event_received = MagicMock(return_value=False)
await cm.send_channel_message("general", "hi", scope=None)
set_flood_scope.assert_not_awaited()
send_chan_msg.assert_awaited()
# ---------------------------------------------------------------------------
# Shared helpers for Path-F and Path-G tests
# ---------------------------------------------------------------------------
def _make_scoped_cm(scope_str: str = "west"):
"""CommandManager wired for send_channel_message unit tests with a regional scope."""
config = _make_config(outgoing_flood_scope_override=scope_str)
bot = MagicMock()
bot.config = config
bot.logger = Mock()
bot.connected = True
bot.is_radio_zombie = False
bot.is_radio_offline = False
bot.channel_manager.get_channel_number.return_value = 1
bot.meshcore = MagicMock()
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = bot.logger
cm.flood_scope_allow_global = False
cm.flood_scope_keys = {}
cm._check_rate_limits = AsyncMock(return_value=(True, None))
cm._handle_send_result = MagicMock(return_value=True)
return cm, bot
def _ok_result():
r = MagicMock()
r.type = "OK"
r.payload = {}
return r
def _scope_error_result():
r = MagicMock()
r.type = "ERROR"
r.payload = {}
return r
def _no_event_result():
"""Result that the real _is_no_event_received treats as a retry trigger."""
from meshcore import EventType
r = MagicMock()
r.type = EventType.ERROR
r.payload = {"reason": "no_event_received"}
return r
# ---------------------------------------------------------------------------
# Path F: firmware rejects SET_FLOOD_SCOPE
# ---------------------------------------------------------------------------
class TestSetFloodScopeResultHandling:
"""set_flood_scope result checking — warning logged on ERROR/None, send still proceeds."""
@pytest.mark.asyncio
async def test_error_result_logs_warning_and_still_sends(self):
"""set_flood_scope returning type=ERROR: warning logged, send_chan_msg still called."""
cm, bot = _make_scoped_cm("west")
bot.meshcore.commands.set_flood_scope = AsyncMock(return_value=_scope_error_result())
bot.meshcore.commands.send_chan_msg = AsyncMock(return_value=_ok_result())
cm._is_no_event_received = MagicMock(return_value=False)
result = await cm.send_channel_message("general", "hi", scope=None)
assert result is True
bot.meshcore.commands.send_chan_msg.assert_awaited_once()
warning_calls = str(bot.logger.warning.call_args_list)
assert "set_flood_scope" in warning_calls
assert "#west" in warning_calls
@pytest.mark.asyncio
async def test_none_result_logs_warning_and_still_sends(self):
"""set_flood_scope returning None: warning logged, send_chan_msg still called."""
cm, bot = _make_scoped_cm("west")
bot.meshcore.commands.set_flood_scope = AsyncMock(return_value=None)
bot.meshcore.commands.send_chan_msg = AsyncMock(return_value=_ok_result())
cm._is_no_event_received = MagicMock(return_value=False)
result = await cm.send_channel_message("general", "hi", scope=None)
assert result is True
bot.meshcore.commands.send_chan_msg.assert_awaited_once()
assert any(
"set_flood_scope" in str(c.args)
for c in bot.logger.warning.call_args_list
)
@pytest.mark.asyncio
async def test_restore_failure_logs_warning(self):
"""set_flood_scope('*') restore returning ERROR: warning logged."""
cm, bot = _make_scoped_cm("west")
# Pre-send succeeds; restore-to-global fails
bot.meshcore.commands.set_flood_scope = AsyncMock(
side_effect=[_ok_result(), _scope_error_result()]
)
bot.meshcore.commands.send_chan_msg = AsyncMock(return_value=_ok_result())
cm._is_no_event_received = MagicMock(return_value=False)
await cm.send_channel_message("general", "hi", scope=None)
assert any(
"restore" in str(c.args)
for c in bot.logger.warning.call_args_list
)
@pytest.mark.asyncio
async def test_ok_result_no_scope_failure_warning(self):
"""set_flood_scope returning OK: no scope-failure warning logged."""
cm, bot = _make_scoped_cm("west")
bot.meshcore.commands.set_flood_scope = AsyncMock(return_value=_ok_result())
bot.meshcore.commands.send_chan_msg = AsyncMock(return_value=_ok_result())
cm._is_no_event_received = MagicMock(return_value=False)
await cm.send_channel_message("general", "hi", scope=None)
assert not any(
"set_flood_scope" in str(c.args) and "failed" in str(c.args)
for c in bot.logger.warning.call_args_list
)
# ---------------------------------------------------------------------------
# Path G: retry re-applies scope
# ---------------------------------------------------------------------------
class TestRetryReappliesScope:
"""set_flood_scope(scope) is re-applied before each retry attempt."""
@pytest.mark.asyncio
async def test_scope_call_sequence_on_retry(self):
"""no_event_received on attempt 0: full set_flood_scope call sequence is correct.
Expected: set(scope) → send[fail] → set(*) → set(scope) → send[ok] → set(*)
"""
cm, bot = _make_scoped_cm("west")
bot.meshcore.commands.set_flood_scope = AsyncMock(return_value=_ok_result())
bot.meshcore.commands.send_chan_msg = AsyncMock(
side_effect=[_no_event_result(), _ok_result()]
)
with patch("modules.command_manager.asyncio.sleep", new_callable=AsyncMock):
result = await cm.send_channel_message("general", "hi", scope=None)
assert result is True
calls = [c.args[0] for c in bot.meshcore.commands.set_flood_scope.await_args_list]
assert calls == ["#west", "*", "#west", "*"]
@pytest.mark.asyncio
async def test_retry_reapply_failure_logs_warning(self):
"""set_flood_scope fails on retry re-apply: 'retry re-apply' warning logged."""
cm, bot = _make_scoped_cm("west")
# pre-send OK, restore OK, re-apply ERROR, second restore OK
bot.meshcore.commands.set_flood_scope = AsyncMock(
side_effect=[_ok_result(), _ok_result(), _scope_error_result(), _ok_result()]
)
bot.meshcore.commands.send_chan_msg = AsyncMock(
side_effect=[_no_event_result(), _ok_result()]
)
with patch("modules.command_manager.asyncio.sleep", new_callable=AsyncMock):
await cm.send_channel_message("general", "hi", scope=None)
assert any(
"retry re-apply" in str(c.args)
for c in bot.logger.warning.call_args_list
)
@pytest.mark.asyncio
async def test_all_retries_exhaust_scope_still_restored(self):
"""All 3 attempts fail: set_flood_scope('*') still called after each attempt."""
cm, bot = _make_scoped_cm("west")
bot.meshcore.commands.set_flood_scope = AsyncMock(return_value=_ok_result())
bot.meshcore.commands.send_chan_msg = AsyncMock(return_value=_no_event_result())
cm._handle_send_result = MagicMock(return_value=False)
with patch("modules.command_manager.asyncio.sleep", new_callable=AsyncMock):
result = await cm.send_channel_message("general", "hi", scope=None)
assert result is False
restore_calls = [
c.args[0] for c in bot.meshcore.commands.set_flood_scope.await_args_list
if c.args[0] == "*"
]
# One restore per attempt (3 attempts total)
assert len(restore_calls) == 3