mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-07-29 06:49:29 +00:00
- Modified the core service to start the webhook service before establishing a radio connection, reducing the window for connection refusals. - Implemented a readiness check in the webhook service to return a 503 status when the bot is not connected, ensuring clear communication to external callers. - Added unit tests to verify the webhook service's behavior when the bot's connection status changes, including rate limiting and readiness responses.
332 lines
13 KiB
Python
332 lines
13 KiB
Python
"""Tests for WebhookService."""
|
|
|
|
from configparser import ConfigParser
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
|
|
from modules.service_plugins.webhook_service import WebhookService
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_dm = AsyncMock(return_value=True)
|
|
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
|