Files
meshcore-bot/tests/test_webhook_service.py
Stacy Olivas d07cca6d7a feat: inbound webhook relay with bearer token authentication
Add POST /webhook endpoint to the web viewer. Authenticated via
Authorization: Bearer <token> set in [Webhook] config section. Relays
JSON or text payload to a configured MeshCore channel or user DM.
2026-03-17 18:07:18 -07:00

244 lines
9.5 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)
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 — 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_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