Files
meshcore-bot/tests/integration/test_flood_scope_reply.py
T
Stacy Olivas c7fa0ba3d2 fix: resolve ruff F401 violations in command_manager and upstream test files
- Add noqa: F401 to PUBLIC_CHANNEL_KEY_HEX import (re-exported for core.py)
- Remove unused `call` import from test_flood_scope_reply.py
- Remove unused `asyncio` import from test_log_data_scope_fields.py
- Remove unused `patch` and `validate_config` imports from
  test_public_channel_guard.py; fix import sort order
2026-04-14 10:01:51 -07:00

326 lines
13 KiB
Python

"""Integration tests: TC_FLOOD scope matching → reply sent with matched scope.
These tests verify the full chain:
incoming RF data with TC_FLOOD transport code
→ _match_scope identifies scope
→ MeshMessage.reply_scope set
→ send_response passes scope to send_channel_message
"""
import configparser
import hmac as hmac_mod
from hashlib import sha256
from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
from modules.command_manager import CommandManager
from modules.message_handler import MessageHandler
from modules.models import MeshMessage
# ── helpers ──────────────────────────────────────────────────────────────────
PAYLOAD_TYPE = 0x05 # GRP_TXT (channel message flood)
PKT_PAYLOAD = b"\xde\xad\xbe\xef\x01\x02\x03\x04"
def _scope_key(name: str) -> bytes:
return sha256(name.encode()).digest()[:16]
def make_transport_code(scope_name: str, payload_type: int, pkt_payload: bytes) -> int:
key = _scope_key(scope_name)
data = bytes([payload_type]) + pkt_payload
digest = hmac_mod.new(key, data, sha256).digest()
code = int.from_bytes(digest[:2], "little")
if code == 0:
code = 1
elif code == 0xFFFF:
code = 0xFFFE
return code
def _tc_hex(scope_name: str, payload_type: int, pkt_payload: bytes) -> str:
"""Build the 4-byte transport_code hex string as stored in RF data (tc1 + 0000)."""
tc1 = make_transport_code(scope_name, payload_type, pkt_payload)
return tc1.to_bytes(2, "little").hex() + "0000"
def make_config(flood_scopes: str = "", outgoing_flood_scope_override: str = "") -> configparser.ConfigParser:
config = configparser.ConfigParser()
config.add_section("Bot")
config.set("Bot", "bot_name", "TestBot")
config.add_section("Channels")
config.set("Channels", "monitor_channels", "general")
if flood_scopes:
config.set("Channels", "flood_scopes", flood_scopes)
if outgoing_flood_scope_override:
config.set("Channels", "outgoing_flood_scope_override", outgoing_flood_scope_override)
return config
# ── _match_scope unit-level integration ──────────────────────────────────────
class TestMatchScopeIntegration:
"""_match_scope with realistic scope_keys dict from CommandManager._load_flood_scope_keys."""
def _make_scope_keys(self, *names: str) -> dict[str, bytes]:
return {name: _scope_key(name) for name in names}
def test_west_scope_matched(self):
scope_keys = self._make_scope_keys("#west")
tc = make_transport_code("#west", PAYLOAD_TYPE, PKT_PAYLOAD)
assert MessageHandler._match_scope(tc, PAYLOAD_TYPE, PKT_PAYLOAD, scope_keys) == "#west"
def test_east_scope_matched_from_multiple(self):
scope_keys = self._make_scope_keys("#west", "#east", "#north")
tc = make_transport_code("#east", PAYLOAD_TYPE, PKT_PAYLOAD)
assert MessageHandler._match_scope(tc, PAYLOAD_TYPE, PKT_PAYLOAD, scope_keys) == "#east"
def test_unknown_scope_returns_none(self):
scope_keys = self._make_scope_keys("#west")
tc = make_transport_code("#other", PAYLOAD_TYPE, PKT_PAYLOAD)
assert MessageHandler._match_scope(tc, PAYLOAD_TYPE, PKT_PAYLOAD, scope_keys) is None
def test_global_flood_no_transport_code(self):
scope_keys = self._make_scope_keys("#west")
assert MessageHandler._match_scope(None, PAYLOAD_TYPE, PKT_PAYLOAD, scope_keys) is None
# ── CommandManager.flood_scope_keys loading ──────────────────────────────────
class TestFloodScopeKeysLoading:
def _make_bot(self, flood_scopes: str) -> MagicMock:
bot = MagicMock()
bot.logger = Mock()
bot.config = make_config(flood_scopes=flood_scopes)
bot.command_manager = MagicMock()
# Minimal stubs so CommandManager.__init__ can call load_* methods without crashing
bot.config.has_section = bot.config.has_section
bot.config.has_option = bot.config.has_option
bot.config.get = bot.config.get
return bot
def test_scope_keys_loaded_for_hash_names(self):
bot = MagicMock()
bot.logger = Mock()
bot.config = make_config(flood_scopes="#west, #east")
# Call _load_flood_scope_keys directly (bypasses full __init__)
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = Mock()
cm.flood_scope_allow_global = False
result = cm._load_flood_scope_keys()
assert "#west" in result
assert "#east" in result
assert result["#west"] == _scope_key("#west")
def test_bare_names_normalized_on_load(self):
bot = MagicMock()
bot.logger = Mock()
bot.config = make_config(flood_scopes="west, east")
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = Mock()
cm.flood_scope_allow_global = False
result = cm._load_flood_scope_keys()
assert "#west" in result
assert "#east" in result
assert "west" not in result
def test_empty_flood_scopes_returns_empty_dict(self):
bot = MagicMock()
bot.logger = Mock()
bot.config = make_config()
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = Mock()
cm.flood_scope_allow_global = False
result = cm._load_flood_scope_keys()
assert result == {}
def test_star_excluded_from_scope_keys(self):
bot = MagicMock()
bot.logger = Mock()
bot.config = make_config(flood_scopes="#west, *")
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = Mock()
cm.flood_scope_allow_global = False
result = cm._load_flood_scope_keys()
assert "*" not in result
assert "#west" in result
def test_star_sets_allow_global(self):
bot = MagicMock()
bot.logger = Mock()
bot.config = make_config(flood_scopes="#west, *")
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = Mock()
cm.flood_scope_allow_global = False
cm._load_flood_scope_keys()
assert cm.flood_scope_allow_global is True
def test_named_only_allow_global_stays_false(self):
bot = MagicMock()
bot.logger = Mock()
bot.config = make_config(flood_scopes="#west, #east")
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = Mock()
cm.flood_scope_allow_global = False
cm._load_flood_scope_keys()
assert cm.flood_scope_allow_global is False
# ── Allowlist gate logic ──────────────────────────────────────────────────────
class TestAllowlistGate:
"""The allowlist gate suppresses replies when flood_scope_keys is active
and the incoming message does not match any configured scope."""
def _gate(self, scope_keys: dict, allow_global: bool, reply_scope, rt: int) -> bool:
"""Mirror the gate logic in message_handler.py."""
should_suppress = False
if scope_keys and reply_scope is None:
if rt == 0:
should_suppress = True # TC_FLOOD, unknown scope
elif not allow_global:
should_suppress = True # FLOOD, no * in allowlist
return should_suppress
def test_flood_suppressed_when_allowlist_active_no_star(self):
"""Regular FLOOD with unrecognized scope is suppressed when allow_global is False."""
scope_keys = {"#west": _scope_key("#west")}
allow_global = False
rt = 1 # FLOOD
# _match_scope would return None because it's a plain FLOOD (no tc_code1)
reply_scope = MessageHandler._match_scope(None, PAYLOAD_TYPE, PKT_PAYLOAD, scope_keys)
assert reply_scope is None
assert self._gate(scope_keys, allow_global, reply_scope, rt) is True
def test_flood_allowed_when_star_in_allowlist(self):
"""Regular FLOOD with unrecognized scope is allowed when allow_global is True."""
scope_keys = {"#west": _scope_key("#west")}
allow_global = True
rt = 1 # FLOOD
reply_scope = MessageHandler._match_scope(None, PAYLOAD_TYPE, PKT_PAYLOAD, scope_keys)
assert reply_scope is None
assert self._gate(scope_keys, allow_global, reply_scope, rt) is False
def test_tc_flood_suppressed_unknown_scope(self):
"""TC_FLOOD with a scope not in allowlist is suppressed."""
scope_keys = {"#west": _scope_key("#west")}
allow_global = False
rt = 0 # TC_FLOOD
tc = make_transport_code("#other", PAYLOAD_TYPE, PKT_PAYLOAD)
reply_scope = MessageHandler._match_scope(tc, PAYLOAD_TYPE, PKT_PAYLOAD, scope_keys)
assert reply_scope is None
assert self._gate(scope_keys, allow_global, reply_scope, rt) is True
def test_tc_flood_allowed_known_scope(self):
"""TC_FLOOD with a matching scope is allowed (gate does not suppress)."""
scope_keys = {"#west": _scope_key("#west")}
allow_global = False
rt = 0 # TC_FLOOD
tc = make_transport_code("#west", PAYLOAD_TYPE, PKT_PAYLOAD)
reply_scope = MessageHandler._match_scope(tc, PAYLOAD_TYPE, PKT_PAYLOAD, scope_keys)
assert reply_scope == "#west"
assert self._gate(scope_keys, allow_global, reply_scope, rt) is False
def test_no_scope_keys_allows_everything(self):
"""Empty scope_keys dict means no allowlist is active — nothing is suppressed."""
scope_keys = {}
allow_global = False
rt = 1 # FLOOD
reply_scope = None
assert self._gate(scope_keys, allow_global, reply_scope, rt) is False
# ── send_response passes reply_scope to send_channel_message ─────────────────
@pytest.mark.asyncio
async def test_send_response_passes_reply_scope_to_channel_send():
"""MeshMessage.reply_scope is forwarded as scope= to send_channel_message."""
bot = MagicMock()
bot.logger = Mock()
bot.config = make_config()
bot.connected = True
bot.meshcore = MagicMock()
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = bot.logger
# Patch send_channel_message to capture args
cm.send_channel_message = AsyncMock(return_value=True)
msg = MeshMessage(
content="hello",
channel="general",
is_dm=False,
sender_id="Alice",
reply_scope="#west",
)
await cm.send_response(msg, "reply text")
cm.send_channel_message.assert_awaited_once()
_, kwargs = cm.send_channel_message.call_args
assert kwargs.get("scope") == "#west"
@pytest.mark.asyncio
async def test_send_response_passes_none_scope_when_unset():
"""MeshMessage without reply_scope → scope=None (global flood)."""
bot = MagicMock()
bot.logger = Mock()
bot.config = make_config()
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = bot.logger
cm.send_channel_message = AsyncMock(return_value=True)
msg = MeshMessage(content="hello", channel="general", is_dm=False, sender_id="Alice")
await cm.send_response(msg, "reply text")
_, kwargs = cm.send_channel_message.call_args
assert kwargs.get("scope") is None
# ── scope normalization in send_channel_message ───────────────────────────────
@pytest.mark.asyncio
async def test_send_channel_message_normalizes_bare_scope():
"""scope='west' passed in is normalized to '#west' before set_flood_scope call."""
bot = MagicMock()
bot.logger = Mock()
bot.config = make_config()
bot.connected = True
bot.meshcore = MagicMock()
bot.is_radio_zombie = False
bot.channel_manager = MagicMock()
bot.channel_manager.get_channel_number = Mock(return_value=0)
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = bot.logger
set_flood_scope = AsyncMock(return_value=MagicMock(type="OK"))
send_chan_msg = AsyncMock(return_value=MagicMock(type="OK", payload={}))
bot.meshcore.commands.set_flood_scope = set_flood_scope
bot.meshcore.commands.send_chan_msg = send_chan_msg
# Stub out rate limiters and other helpers so the method runs end-to-end
cm._check_rate_limits = AsyncMock(return_value=(True, None))
cm._is_no_event_received = Mock(return_value=False)
cm._handle_send_result = Mock(return_value=True)
await cm.send_channel_message("general", "hi", scope="west")
# set_flood_scope should have been called with "#west", not "west"
calls = set_flood_scope.await_args_list
scope_set = [c.args[0] for c in calls if c.args]
assert "#west" in scope_set