Files
meshcore-bot/tests/test_webhook_service.py
CoderNemesis 5175558dd7 fix(send): split oversized channel text before it stalls the radio (#299)
An oversized webhook or channel body was handed to the firmware whole. The device dropped it without an ack, retries stalled the transport, and the radio looked dead. Split to the frame budget, keep links intact, and tag multi-part sends so they can be reassembled.

Reserve the wx location prefix from that same budget, and let NOAA alert polling be turned off without disabling the rest of the weather service.
2026-09-22 16:21:54 -07:00

623 lines
25 KiB
Python

"""Tests for WebhookService."""
import re
from configparser import ConfigParser
from unittest.mock import AsyncMock, Mock
import pytest
from modules.command_manager import CommandManager
from modules.service_plugins.webhook_service import WebhookService
def _strip_part_suffix(text: str) -> str:
"""Drop a trailing " (i/n)" ordering marker so content can be compared."""
return re.sub(r" \(\d+/\d+\)$", "", text)
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _make_bot(mock_logger, extra_cfg=None):
"""Return a minimal mock bot for WebhookService."""
bot = Mock()
bot.logger = mock_logger
bot.config = ConfigParser()
bot.config.add_section("Webhook")
bot.config.set("Webhook", "enabled", "true")
bot.config.set("Webhook", "host", "127.0.0.1")
bot.config.set("Webhook", "port", "8765")
bot.config.set("Webhook", "secret_token", "")
bot.config.set("Webhook", "max_message_length", "200")
bot.config.set("Webhook", "allowed_channels", "")
if extra_cfg:
for key, val in extra_cfg.items():
bot.config.set("Webhook", key, val)
bot.command_manager = Mock()
bot.command_manager.send_channel_message = AsyncMock(return_value=True)
bot.command_manager.send_channel_messages_chunked = AsyncMock(return_value=True)
bot.command_manager.send_dm = AsyncMock(return_value=True)
# The service sizes bodies against the real budget/split helpers, so a bare
# Mock would hand a Mock to the splitter. Use the production implementations.
bot.command_manager.split_text_into_utf8_chunks = (
CommandManager.split_text_into_utf8_chunks
)
bot.command_manager.split_text_into_numbered_utf8_chunks = (
CommandManager.split_text_into_numbered_utf8_chunks
)
bot.command_manager.links_split_across = CommandManager.links_split_across
bot.command_manager.channel_body_budget = Mock(return_value=130)
bot.connected = True
return bot
def _make_request(body=None, headers=None, remote="127.0.0.1"):
"""Return a mock aiohttp Request."""
req = Mock()
req.remote = remote
req.headers = headers or {}
req.json = AsyncMock(return_value=body or {})
return req
def _make_service(mock_logger, extra_cfg=None):
bot = _make_bot(mock_logger, extra_cfg)
return WebhookService(bot), bot
# ---------------------------------------------------------------------------
# TestInit
# ---------------------------------------------------------------------------
class TestInit:
def test_enabled_reads_from_config(self, mock_logger):
svc, _ = _make_service(mock_logger)
assert svc.enabled is True
def test_disabled_when_no_section(self, mock_logger):
bot = Mock()
bot.logger = mock_logger
bot.config = ConfigParser()
svc = WebhookService(bot)
assert svc.enabled is False
def test_allowed_channels_parsed(self, mock_logger):
svc, _ = _make_service(mock_logger, {"allowed_channels": "general, alerts"})
assert "general" in svc.allowed_channels
assert "alerts" in svc.allowed_channels
def test_hash_stripped_from_channel_names(self, mock_logger):
svc, _ = _make_service(mock_logger, {"allowed_channels": "#general,#alerts"})
assert "general" in svc.allowed_channels
assert "alerts" in svc.allowed_channels
def test_empty_allowed_channels_means_all(self, mock_logger):
svc, _ = _make_service(mock_logger)
assert svc.allowed_channels == set()
def test_secret_token_loaded(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "s3cr3t"})
assert svc.secret_token == "s3cr3t"
# ---------------------------------------------------------------------------
# TestVerifyToken
# ---------------------------------------------------------------------------
class TestVerifyToken:
def test_bearer_token_accepted(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "abc123"})
req = _make_request(headers={"Authorization": "Bearer abc123"})
assert svc._verify_token(req) is True
def test_wrong_bearer_rejected(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "abc123"})
req = _make_request(headers={"Authorization": "Bearer wrong"})
assert svc._verify_token(req) is False
def test_x_webhook_token_accepted(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "abc123"})
req = _make_request(headers={"X-Webhook-Token": "abc123"})
assert svc._verify_token(req) is True
def test_no_token_header_rejected(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "abc123"})
req = _make_request(headers={})
assert svc._verify_token(req) is False
def test_case_insensitive_bearer_prefix(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "tok"})
req = _make_request(headers={"Authorization": "BEARER tok"})
assert svc._verify_token(req) is True
# ---------------------------------------------------------------------------
# TestHandleWebhook — readiness
# ---------------------------------------------------------------------------
class TestHandleWebhookReadiness:
@pytest.mark.asyncio
async def test_not_connected_returns_503(self, mock_logger):
svc, bot = _make_service(mock_logger)
bot.connected = False
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 503
@pytest.mark.asyncio
async def test_not_connected_does_not_dispatch(self, mock_logger):
svc, bot = _make_service(mock_logger)
bot.connected = False
req = _make_request(body={"channel": "general", "message": "hi"})
await svc._handle_webhook(req)
bot.command_manager.send_channel_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_connected_true_proceeds_normally(self, mock_logger):
svc, bot = _make_service(mock_logger)
bot.connected = True
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 200
@pytest.mark.asyncio
async def test_missing_connected_attr_defaults_to_not_ready(self, mock_logger):
"""If `bot.connected` isn't present at all, fail safe (503) rather than assume readiness."""
svc, bot = _make_service(mock_logger)
del bot.connected
# Mock() raises AttributeError for deleted attrs, so getattr(..., False) applies.
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 503
@pytest.mark.asyncio
async def test_rate_limit_checked_before_readiness(self, mock_logger):
"""Rate limiting should still apply even while the bot isn't connected yet."""
svc, bot = _make_service(mock_logger, {"rate_limit_per_minute": "1"})
bot.connected = False
req = _make_request(body={"channel": "general", "message": "hi"})
await svc._handle_webhook(req)
resp = await svc._handle_webhook(req)
assert resp.status == 429
# ---------------------------------------------------------------------------
# TestHandleWebhook — auth
# ---------------------------------------------------------------------------
class TestHandleWebhookAuth:
@pytest.mark.asyncio
async def test_missing_token_returns_401(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "secret"})
req = _make_request(body={"channel": "general", "message": "hi"}, headers={})
resp = await svc._handle_webhook(req)
assert resp.status == 401
@pytest.mark.asyncio
async def test_correct_token_returns_200(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "secret"})
req = _make_request(
body={"channel": "general", "message": "hi"},
headers={"Authorization": "Bearer secret"},
)
resp = await svc._handle_webhook(req)
assert resp.status == 200
@pytest.mark.asyncio
async def test_no_token_required_when_secret_empty(self, mock_logger):
svc, _ = _make_service(mock_logger) # no secret_token
req = _make_request(body={"channel": "general", "message": "hi"}, headers={})
resp = await svc._handle_webhook(req)
assert resp.status == 200
# ---------------------------------------------------------------------------
# TestHandleWebhook — validation
# ---------------------------------------------------------------------------
class TestHandleWebhookValidation:
@pytest.mark.asyncio
async def test_invalid_json_returns_400(self, mock_logger):
svc, _ = _make_service(mock_logger)
req = _make_request()
req.json = AsyncMock(side_effect=Exception("bad json"))
resp = await svc._handle_webhook(req)
assert resp.status == 400
@pytest.mark.asyncio
async def test_missing_message_returns_400(self, mock_logger):
svc, _ = _make_service(mock_logger)
req = _make_request(body={"channel": "general"})
resp = await svc._handle_webhook(req)
assert resp.status == 400
@pytest.mark.asyncio
async def test_missing_channel_and_dm_returns_400(self, mock_logger):
svc, _ = _make_service(mock_logger)
req = _make_request(body={"message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 400
@pytest.mark.asyncio
async def test_disallowed_channel_returns_400(self, mock_logger):
svc, _ = _make_service(mock_logger, {"allowed_channels": "alerts"})
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 400
@pytest.mark.asyncio
async def test_allowed_channel_passes(self, mock_logger):
svc, _ = _make_service(mock_logger, {"allowed_channels": "general"})
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 200
# ---------------------------------------------------------------------------
# TestHandleWebhook — dispatch
# ---------------------------------------------------------------------------
class TestHandleWebhookDispatch:
@pytest.mark.asyncio
async def test_channel_message_dispatched(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(body={"channel": "general", "message": "Hello!"})
await svc._handle_webhook(req)
bot.command_manager.send_channel_message.assert_awaited_once()
call_args = bot.command_manager.send_channel_message.call_args
assert call_args[0][0] == "general"
assert call_args[0][1] == "Hello!"
@pytest.mark.asyncio
async def test_hash_stripped_from_channel_in_body(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(body={"channel": "#general", "message": "Hello!"})
await svc._handle_webhook(req)
call_args = bot.command_manager.send_channel_message.call_args
assert call_args[0][0] == "general"
@pytest.mark.asyncio
async def test_dm_dispatched(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(body={"dm_to": "Alice", "message": "Hi Alice!"})
await svc._handle_webhook(req)
bot.command_manager.send_dm.assert_awaited_once()
call_args = bot.command_manager.send_dm.call_args
assert call_args[0][0] == "Alice"
assert call_args[0][1] == "Hi Alice!"
@pytest.mark.asyncio
async def test_flood_scope_in_body_passed_to_send(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(
body={"channel": "general", "message": "Hello!", "flood_scope": "west"}
)
await svc._handle_webhook(req)
_, kwargs = bot.command_manager.send_channel_message.call_args
assert kwargs.get("scope") == "#west"
@pytest.mark.asyncio
async def test_flood_scope_null_falls_back_to_config(self, mock_logger):
svc, bot = _make_service(mock_logger, {"flood_scope": "#sea"})
req = _make_request(
body={"channel": "general", "message": "Hello!", "flood_scope": None}
)
await svc._handle_webhook(req)
_, kwargs = bot.command_manager.send_channel_message.call_args
assert kwargs.get("scope") == "#sea"
@pytest.mark.asyncio
async def test_config_flood_scope_used_when_body_omits_it(self, mock_logger):
svc, bot = _make_service(mock_logger, {"flood_scope": "#sea"})
req = _make_request(body={"channel": "general", "message": "Hello!"})
await svc._handle_webhook(req)
_, kwargs = bot.command_manager.send_channel_message.call_args
assert kwargs.get("scope") == "#sea"
@pytest.mark.asyncio
async def test_long_message_truncated(self, mock_logger):
svc, bot = _make_service(mock_logger, {"max_message_length": "10"})
long_msg = "A" * 100
req = _make_request(body={"channel": "general", "message": long_msg})
await svc._handle_webhook(req)
sent = bot.command_manager.send_channel_message.call_args[0][1]
assert len(sent) == 10
@pytest.mark.asyncio
async def test_send_failure_returns_500(self, mock_logger):
svc, bot = _make_service(mock_logger)
bot.command_manager.send_channel_message = AsyncMock(
side_effect=RuntimeError("mesh offline")
)
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 500
@pytest.mark.asyncio
async def test_send_returns_false_returns_500(self, mock_logger):
svc, bot = _make_service(mock_logger)
bot.command_manager.send_channel_message = AsyncMock(return_value=False)
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 500
# ---------------------------------------------------------------------------
# TestChunking — a message longer than one frame must go out in several parts
# ---------------------------------------------------------------------------
class TestChunking:
"""The 200-char cap is a total, not a frame size.
A MeshCore channel body only holds ~130 bytes, so relaying a longer payload
whole gets it dropped by the device with no acknowledgement — the send burns
its retries and the stalled transport then looks dead.
"""
# The alert from the reported failure: 173 chars, under the 200-char cap but
# well over a single channel frame.
LONG_ALERT = (
"The Heat Advisory for the I-35 Corridor and Coastal Plains (Hays, "
"Bexar, Comal, Guadalupe, Caldwell, Atascosa, Wilson, Karnes, "
"Gonzales, De Witt) has expired as of 7 PM CDT."
)
@pytest.mark.asyncio
async def test_oversized_channel_message_is_chunked(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(body={"channel": "ky-wx", "message": self.LONG_ALERT})
resp = await svc._handle_webhook(req)
assert resp.status == 200
bot.command_manager.send_channel_message.assert_not_awaited()
bot.command_manager.send_channel_messages_chunked.assert_awaited_once()
chunks = bot.command_manager.send_channel_messages_chunked.call_args[0][1]
assert len(chunks) > 1
for chunk in chunks:
assert len(chunk.encode("utf-8")) <= 130
@pytest.mark.asyncio
async def test_chunks_preserve_the_whole_message(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(body={"channel": "ky-wx", "message": self.LONG_ALERT})
await svc._handle_webhook(req)
chunks = bot.command_manager.send_channel_messages_chunked.call_args[0][1]
rejoined = " ".join(_strip_part_suffix(c) for c in chunks)
assert rejoined.split() == self.LONG_ALERT.split()
@pytest.mark.asyncio
async def test_response_reports_part_count(self, mock_logger):
import json
svc, bot = _make_service(mock_logger)
req = _make_request(body={"channel": "ky-wx", "message": self.LONG_ALERT})
resp = await svc._handle_webhook(req)
chunks = bot.command_manager.send_channel_messages_chunked.call_args[0][1]
assert json.loads(resp.text) == {"ok": True, "parts": len(chunks)}
@pytest.mark.asyncio
async def test_single_part_message_still_reports_one(self, mock_logger):
import json
svc, _ = _make_service(mock_logger)
req = _make_request(body={"channel": "general", "message": "Hello!"})
resp = await svc._handle_webhook(req)
assert json.loads(resp.text) == {"ok": True, "parts": 1}
@pytest.mark.asyncio
async def test_short_message_uses_the_unchunked_path(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(body={"channel": "general", "message": "Hello!"})
await svc._handle_webhook(req)
bot.command_manager.send_channel_message.assert_awaited_once()
bot.command_manager.send_channel_messages_chunked.assert_not_awaited()
@pytest.mark.asyncio
async def test_budget_is_resolved_for_the_target_channel_and_scope(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(
body={"channel": "ky-wx", "message": "hi", "flood_scope": "west"}
)
await svc._handle_webhook(req)
bot.command_manager.channel_body_budget.assert_called_once_with(
channel="ky-wx", scope="#west"
)
@pytest.mark.asyncio
async def test_scope_is_passed_to_the_chunked_send(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(
body={"channel": "ky-wx", "message": self.LONG_ALERT, "flood_scope": "west"}
)
await svc._handle_webhook(req)
_, kwargs = bot.command_manager.send_channel_messages_chunked.call_args
assert kwargs.get("scope") == "#west"
@pytest.mark.asyncio
async def test_chunked_send_failure_returns_500(self, mock_logger):
svc, bot = _make_service(mock_logger)
bot.command_manager.send_channel_messages_chunked = AsyncMock(return_value=False)
req = _make_request(body={"channel": "ky-wx", "message": self.LONG_ALERT})
resp = await svc._handle_webhook(req)
assert resp.status == 500
@pytest.mark.asyncio
async def test_narrow_budget_produces_more_parts(self, mock_logger):
"""A long bot name or a regional scope shrinks the budget; parts follow it."""
svc, bot = _make_service(mock_logger)
bot.command_manager.channel_body_budget = Mock(return_value=40)
req = _make_request(body={"channel": "ky-wx", "message": self.LONG_ALERT})
await svc._handle_webhook(req)
chunks = bot.command_manager.send_channel_messages_chunked.call_args[0][1]
assert len(chunks) >= 5
for chunk in chunks:
assert len(chunk.encode("utf-8")) <= 40
@pytest.mark.asyncio
async def test_truncation_still_applies_before_chunking(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(
body={"channel": "ky-wx", "message": "A" * 1000}
)
await svc._handle_webhook(req)
chunks = bot.command_manager.send_channel_messages_chunked.call_args[0][1]
assert sum(len(_strip_part_suffix(c)) for c in chunks) == 200
@pytest.mark.asyncio
async def test_long_dm_reports_parts_but_sends_once(self, mock_logger):
"""send_dm applies its own byte guard, so the whole body goes in one call."""
import json
svc, bot = _make_service(mock_logger, {"max_message_length": "400"})
req = _make_request(body={"dm_to": "Alice", "message": "B" * 400})
resp = await svc._handle_webhook(req)
bot.command_manager.send_dm.assert_awaited_once()
assert bot.command_manager.send_dm.call_args[0][1] == "B" * 400
assert json.loads(resp.text) == {"ok": True, "parts": 3}
# ---------------------------------------------------------------------------
# TestPartNumbering — a mesh does not guarantee delivery order
# ---------------------------------------------------------------------------
class TestPartNumbering:
"""Multi-part posts carry " (i/n)" so a reader can reassemble them.
Nothing else distinguishes a continuation from a standalone post, and parts
can arrive out of order.
"""
@pytest.mark.asyncio
async def test_each_part_is_tagged_with_its_position(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(
body={"channel": "ky-wx", "message": TestChunking.LONG_ALERT}
)
await svc._handle_webhook(req)
chunks = bot.command_manager.send_channel_messages_chunked.call_args[0][1]
total = len(chunks)
assert total > 1
for i, chunk in enumerate(chunks, 1):
assert chunk.endswith(f" ({i}/{total})")
@pytest.mark.asyncio
async def test_suffix_does_not_push_a_part_over_budget(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(
body={"channel": "ky-wx", "message": TestChunking.LONG_ALERT}
)
await svc._handle_webhook(req)
chunks = bot.command_manager.send_channel_messages_chunked.call_args[0][1]
for chunk in chunks:
assert len(chunk.encode("utf-8")) <= 130
@pytest.mark.asyncio
async def test_single_part_carries_no_suffix(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(body={"channel": "general", "message": "Hello!"})
await svc._handle_webhook(req)
assert bot.command_manager.send_channel_message.call_args[0][1] == "Hello!"
@pytest.mark.asyncio
async def test_reported_parts_match_the_numbering(self, mock_logger):
import json
svc, bot = _make_service(mock_logger)
req = _make_request(
body={"channel": "ky-wx", "message": TestChunking.LONG_ALERT}
)
resp = await svc._handle_webhook(req)
chunks = bot.command_manager.send_channel_messages_chunked.call_args[0][1]
assert json.loads(resp.text)["parts"] == len(chunks)
assert chunks[-1].endswith(f" ({len(chunks)}/{len(chunks)})")
class TestWebhookLinkIntegrity:
"""A relayed link must survive the split intact."""
LINK = "https://is.gd/a1B2c3"
@pytest.mark.asyncio
async def test_link_survives_a_split_relay(self, mock_logger):
svc, bot = _make_service(mock_logger)
text = (
"Flood Warning for Caldwell and Hays counties until 9PM CDT, avoid low "
f"water crossings and do not drive through standing water {self.LINK}"
)
req = _make_request(body={"channel": "ky-wx", "message": text})
resp = await svc._handle_webhook(req)
assert resp.status == 200
chunks = bot.command_manager.send_channel_messages_chunked.call_args[0][1]
assert len(chunks) > 1
assert any(self.LINK in chunk for chunk in chunks)
@pytest.mark.asyncio
async def test_unspaced_link_survives_a_split_relay(self, mock_logger):
svc, bot = _make_service(mock_logger)
text = f"FloodWarn-Caldwell-Hays-til-9PM-avoid-low-water-crossings|{self.LINK}"
bot.command_manager.channel_body_budget = Mock(return_value=40)
req = _make_request(body={"channel": "ky-wx", "message": text})
await svc._handle_webhook(req)
chunks = bot.command_manager.send_channel_messages_chunked.call_args[0][1]
assert any(self.LINK in chunk for chunk in chunks)
@pytest.mark.asyncio
async def test_uncuttable_link_still_relays_and_is_warned_about(self, mock_logger):
"""A link longer than one frame cannot survive; the relay must not fail."""
svc, bot = _make_service(mock_logger)
long_link = (
"https://api.weather.gov/alerts/urn:oid:2.49.0.1.840.0.abcdef.001.1"
)
bot.command_manager.channel_body_budget = Mock(return_value=40)
req = _make_request(body={"channel": "ky-wx", "message": f"Alert {long_link}"})
resp = await svc._handle_webhook(req)
assert resp.status == 200
assert any(
"not be clickable" in str(call)
for call in mock_logger.warning.call_args_list
)