mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
First pass of test-suite reduction to address flaky CI and bloat. Removed tests that fall into these change-detector patterns: 1. Source-grep tests (tests/gateway/test_feishu.py, test_email.py): tests that call inspect.getsource() on production modules and grep for string literals. Break on any refactor/rename even when behavior is correct. 2. Platform enum tautologies (every gateway/test_X.py): assertions like `Platform.X.value == 'x'` duplicated across ~9 adapter test files. 3. Toolset/PLATFORM_HINTS/setup-wizard registry-presence checks: tests that only verify a key exists in a dict. Data-layout tests, not behavior. 4. Argparse wiring tests (test_argparse_flag_propagation, test_subparser_routing _fallback): tests that do parser.parse_args([...]) then assert args.field. Tests Python's argparse, not our code. 5. Pure dispatch tests (test_plugins_cmd.TestPluginsCommandDispatch): patch cmd_X, call plugins_command with matching action, assert mock called. Tests the if/elif chain, not behavior. 6. Kwarg-to-mock verification (test_auxiliary_client ~45 tests, test_web_tools_config, test_gemini_cloudcode, test_retaindb_plugin): tests that mock the external API client, call our function, and assert exact kwargs. Break on refactor even when behavior is preserved. 7. Schedule-internal "function-was-called" tests (acp/test_server scheduling tests): tests that patch own helper method, then assert it was called. Kept behavioral tests throughout: error paths (pytest.raises), security tests (path traversal, SSRF, redaction), message alternation invariants, provider API format conversion, streaming logic, memory contract, real config load/merge tests. Net reduction: 169 tests removed. 38 empty classes cleaned up. Collected before: 12,522 tests Collected after: 12,353 tests
496 lines
20 KiB
Python
496 lines
20 KiB
Python
"""Tests for SMS (Twilio) platform integration.
|
|
|
|
Covers config loading, format/truncate, echo prevention,
|
|
requirements check, toolset verification, and Twilio signature validation.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform, PlatformConfig, HomeChannel
|
|
|
|
|
|
# ── Config loading ──────────────────────────────────────────────────
|
|
|
|
class TestSmsConfigLoading:
|
|
"""Verify _apply_env_overrides wires SMS correctly."""
|
|
|
|
def test_env_overrides_create_sms_config(self):
|
|
from gateway.config import load_gateway_config
|
|
|
|
env = {
|
|
"TWILIO_ACCOUNT_SID": "ACtest123",
|
|
"TWILIO_AUTH_TOKEN": "token_abc",
|
|
"TWILIO_PHONE_NUMBER": "+15551234567",
|
|
}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
config = load_gateway_config()
|
|
assert Platform.SMS in config.platforms
|
|
pc = config.platforms[Platform.SMS]
|
|
assert pc.enabled is True
|
|
assert pc.api_key == "token_abc"
|
|
|
|
def test_env_overrides_set_home_channel(self):
|
|
from gateway.config import load_gateway_config
|
|
|
|
env = {
|
|
"TWILIO_ACCOUNT_SID": "ACtest123",
|
|
"TWILIO_AUTH_TOKEN": "token_abc",
|
|
"TWILIO_PHONE_NUMBER": "+15551234567",
|
|
"SMS_HOME_CHANNEL": "+15559876543",
|
|
"SMS_HOME_CHANNEL_NAME": "My Phone",
|
|
}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
config = load_gateway_config()
|
|
hc = config.platforms[Platform.SMS].home_channel
|
|
assert hc is not None
|
|
assert hc.chat_id == "+15559876543"
|
|
assert hc.name == "My Phone"
|
|
assert hc.platform == Platform.SMS
|
|
|
|
# ── Format / truncate ───────────────────────────────────────────────
|
|
|
|
class TestSmsFormatAndTruncate:
|
|
"""Test SmsAdapter.format_message strips markdown."""
|
|
|
|
def _make_adapter(self):
|
|
from gateway.platforms.sms import SmsAdapter
|
|
|
|
env = {
|
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
|
"TWILIO_AUTH_TOKEN": "tok",
|
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
|
}
|
|
with patch.dict(os.environ, env):
|
|
pc = PlatformConfig(enabled=True, api_key="tok")
|
|
adapter = object.__new__(SmsAdapter)
|
|
adapter.config = pc
|
|
adapter._platform = Platform.SMS
|
|
adapter._account_sid = "ACtest"
|
|
adapter._auth_token = "tok"
|
|
adapter._from_number = "+15550001111"
|
|
return adapter
|
|
|
|
def test_strips_bold(self):
|
|
adapter = self._make_adapter()
|
|
assert adapter.format_message("**hello**") == "hello"
|
|
|
|
def test_strips_italic(self):
|
|
adapter = self._make_adapter()
|
|
assert adapter.format_message("*world*") == "world"
|
|
|
|
def test_strips_code_blocks(self):
|
|
adapter = self._make_adapter()
|
|
result = adapter.format_message("```python\nprint('hi')\n```")
|
|
assert "```" not in result
|
|
assert "print('hi')" in result
|
|
|
|
def test_strips_inline_code(self):
|
|
adapter = self._make_adapter()
|
|
assert adapter.format_message("`code`") == "code"
|
|
|
|
def test_strips_headers(self):
|
|
adapter = self._make_adapter()
|
|
assert adapter.format_message("## Title") == "Title"
|
|
|
|
def test_strips_links(self):
|
|
adapter = self._make_adapter()
|
|
assert adapter.format_message("[click](https://example.com)") == "click"
|
|
|
|
def test_collapses_newlines(self):
|
|
adapter = self._make_adapter()
|
|
result = adapter.format_message("a\n\n\n\nb")
|
|
assert result == "a\n\nb"
|
|
|
|
|
|
# ── Echo prevention ────────────────────────────────────────────────
|
|
|
|
class TestSmsEchoPrevention:
|
|
"""Adapter should ignore messages from its own number."""
|
|
|
|
def test_own_number_detection(self):
|
|
"""The adapter stores _from_number for echo prevention."""
|
|
from gateway.platforms.sms import SmsAdapter
|
|
|
|
env = {
|
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
|
"TWILIO_AUTH_TOKEN": "tok",
|
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
|
}
|
|
with patch.dict(os.environ, env):
|
|
pc = PlatformConfig(enabled=True, api_key="tok")
|
|
adapter = SmsAdapter(pc)
|
|
assert adapter._from_number == "+15550001111"
|
|
|
|
|
|
# ── Requirements check ─────────────────────────────────────────────
|
|
|
|
class TestSmsRequirements:
|
|
def test_check_sms_requirements_missing_sid(self):
|
|
from gateway.platforms.sms import check_sms_requirements
|
|
|
|
env = {"TWILIO_AUTH_TOKEN": "tok"}
|
|
with patch.dict(os.environ, env, clear=True):
|
|
assert check_sms_requirements() is False
|
|
|
|
def test_check_sms_requirements_missing_token(self):
|
|
from gateway.platforms.sms import check_sms_requirements
|
|
|
|
env = {"TWILIO_ACCOUNT_SID": "ACtest"}
|
|
with patch.dict(os.environ, env, clear=True):
|
|
assert check_sms_requirements() is False
|
|
|
|
def test_check_sms_requirements_both_set(self):
|
|
from gateway.platforms.sms import check_sms_requirements
|
|
|
|
env = {
|
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
|
"TWILIO_AUTH_TOKEN": "tok",
|
|
}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
# Only returns True if aiohttp is also importable
|
|
result = check_sms_requirements()
|
|
try:
|
|
import aiohttp # noqa: F401
|
|
assert result is True
|
|
except ImportError:
|
|
assert result is False
|
|
|
|
|
|
# ── Toolset verification ───────────────────────────────────────────
|
|
|
|
# ── Webhook host configuration ─────────────────────────────────────
|
|
|
|
class TestWebhookHostConfig:
|
|
"""Verify SMS_WEBHOOK_HOST env var and default."""
|
|
|
|
def test_default_host_is_all_interfaces(self):
|
|
from gateway.platforms.sms import DEFAULT_WEBHOOK_HOST
|
|
assert DEFAULT_WEBHOOK_HOST == "0.0.0.0"
|
|
|
|
def test_host_from_env(self):
|
|
from gateway.platforms.sms import SmsAdapter
|
|
|
|
env = {
|
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
|
"TWILIO_AUTH_TOKEN": "tok",
|
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
|
"SMS_WEBHOOK_HOST": "127.0.0.1",
|
|
}
|
|
with patch.dict(os.environ, env):
|
|
pc = PlatformConfig(enabled=True, api_key="tok")
|
|
adapter = SmsAdapter(pc)
|
|
assert adapter._webhook_host == "127.0.0.1"
|
|
|
|
def test_webhook_url_from_env(self):
|
|
from gateway.platforms.sms import SmsAdapter
|
|
|
|
env = {
|
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
|
"TWILIO_AUTH_TOKEN": "tok",
|
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
|
"SMS_WEBHOOK_URL": "https://example.com/webhooks/twilio",
|
|
}
|
|
with patch.dict(os.environ, env):
|
|
pc = PlatformConfig(enabled=True, api_key="tok")
|
|
adapter = SmsAdapter(pc)
|
|
assert adapter._webhook_url == "https://example.com/webhooks/twilio"
|
|
|
|
def test_webhook_url_stripped(self):
|
|
from gateway.platforms.sms import SmsAdapter
|
|
|
|
env = {
|
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
|
"TWILIO_AUTH_TOKEN": "tok",
|
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
|
"SMS_WEBHOOK_URL": " https://example.com/webhooks/twilio ",
|
|
}
|
|
with patch.dict(os.environ, env):
|
|
pc = PlatformConfig(enabled=True, api_key="tok")
|
|
adapter = SmsAdapter(pc)
|
|
assert adapter._webhook_url == "https://example.com/webhooks/twilio"
|
|
|
|
|
|
# ── Startup guard (fail-closed) ────────────────────────────────────
|
|
|
|
class TestStartupGuard:
|
|
"""Adapter must refuse to start without SMS_WEBHOOK_URL."""
|
|
|
|
def _make_adapter(self, extra_env=None):
|
|
from gateway.platforms.sms import SmsAdapter
|
|
|
|
env = {
|
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
|
"TWILIO_AUTH_TOKEN": "tok",
|
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
|
}
|
|
if extra_env:
|
|
env.update(extra_env)
|
|
with patch.dict(os.environ, env, clear=False):
|
|
pc = PlatformConfig(enabled=True, api_key="tok")
|
|
adapter = SmsAdapter(pc)
|
|
return adapter
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refuses_start_without_webhook_url(self):
|
|
adapter = self._make_adapter()
|
|
result = await adapter.connect()
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insecure_flag_allows_start_without_url(self):
|
|
mock_session = AsyncMock()
|
|
with patch.dict(os.environ, {"SMS_INSECURE_NO_SIGNATURE": "true"}), \
|
|
patch("aiohttp.web.AppRunner") as mock_runner_cls, \
|
|
patch("aiohttp.web.TCPSite") as mock_site_cls, \
|
|
patch("aiohttp.ClientSession", return_value=mock_session):
|
|
mock_runner_cls.return_value.setup = AsyncMock()
|
|
mock_runner_cls.return_value.cleanup = AsyncMock()
|
|
mock_site_cls.return_value.start = AsyncMock()
|
|
adapter = self._make_adapter()
|
|
result = await adapter.connect()
|
|
assert result is True
|
|
await adapter.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_webhook_url_allows_start(self):
|
|
mock_session = AsyncMock()
|
|
with patch("aiohttp.web.AppRunner") as mock_runner_cls, \
|
|
patch("aiohttp.web.TCPSite") as mock_site_cls, \
|
|
patch("aiohttp.ClientSession", return_value=mock_session):
|
|
mock_runner_cls.return_value.setup = AsyncMock()
|
|
mock_runner_cls.return_value.cleanup = AsyncMock()
|
|
mock_site_cls.return_value.start = AsyncMock()
|
|
adapter = self._make_adapter(
|
|
extra_env={"SMS_WEBHOOK_URL": "https://example.com/webhooks/twilio"}
|
|
)
|
|
result = await adapter.connect()
|
|
assert result is True
|
|
await adapter.disconnect()
|
|
|
|
|
|
# ── Twilio signature validation ────────────────────────────────────
|
|
|
|
def _compute_twilio_signature(auth_token, url, params):
|
|
"""Reference implementation of Twilio's signature algorithm."""
|
|
data_to_sign = url
|
|
for key in sorted(params.keys()):
|
|
data_to_sign += key + params[key]
|
|
mac = hmac.new(
|
|
auth_token.encode("utf-8"),
|
|
data_to_sign.encode("utf-8"),
|
|
hashlib.sha1,
|
|
)
|
|
return base64.b64encode(mac.digest()).decode("utf-8")
|
|
|
|
|
|
class TestTwilioSignatureValidation:
|
|
"""Unit tests for SmsAdapter._validate_twilio_signature."""
|
|
|
|
def _make_adapter(self, auth_token="test_token_secret"):
|
|
from gateway.platforms.sms import SmsAdapter
|
|
|
|
env = {
|
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
|
"TWILIO_AUTH_TOKEN": auth_token,
|
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
|
}
|
|
with patch.dict(os.environ, env):
|
|
pc = PlatformConfig(enabled=True, api_key=auth_token)
|
|
adapter = SmsAdapter(pc)
|
|
return adapter
|
|
|
|
def test_valid_signature_accepted(self):
|
|
adapter = self._make_adapter()
|
|
url = "https://example.com/webhooks/twilio"
|
|
params = {"From": "+15551234567", "Body": "hello", "To": "+15550001111"}
|
|
sig = _compute_twilio_signature("test_token_secret", url, params)
|
|
assert adapter._validate_twilio_signature(url, params, sig) is True
|
|
|
|
def test_invalid_signature_rejected(self):
|
|
adapter = self._make_adapter()
|
|
url = "https://example.com/webhooks/twilio"
|
|
params = {"From": "+15551234567", "Body": "hello"}
|
|
assert adapter._validate_twilio_signature(url, params, "badsig") is False
|
|
|
|
def test_wrong_token_rejected(self):
|
|
adapter = self._make_adapter(auth_token="correct_token")
|
|
url = "https://example.com/webhooks/twilio"
|
|
params = {"From": "+15551234567", "Body": "hello"}
|
|
sig = _compute_twilio_signature("wrong_token", url, params)
|
|
assert adapter._validate_twilio_signature(url, params, sig) is False
|
|
|
|
def test_params_sorted_by_key(self):
|
|
"""Signature must be computed with params sorted alphabetically."""
|
|
adapter = self._make_adapter()
|
|
url = "https://example.com/webhooks/twilio"
|
|
params = {"Zebra": "last", "Alpha": "first", "Middle": "mid"}
|
|
sig = _compute_twilio_signature("test_token_secret", url, params)
|
|
assert adapter._validate_twilio_signature(url, params, sig) is True
|
|
|
|
def test_empty_param_values_included(self):
|
|
"""Blank values must be included in signature computation."""
|
|
adapter = self._make_adapter()
|
|
url = "https://example.com/webhooks/twilio"
|
|
params = {"From": "+15551234567", "Body": "", "SmsStatus": "received"}
|
|
sig = _compute_twilio_signature("test_token_secret", url, params)
|
|
assert adapter._validate_twilio_signature(url, params, sig) is True
|
|
|
|
def test_url_matters(self):
|
|
"""Different URLs produce different signatures."""
|
|
adapter = self._make_adapter()
|
|
params = {"Body": "hello"}
|
|
sig = _compute_twilio_signature(
|
|
"test_token_secret", "https://a.com/webhooks/twilio", params
|
|
)
|
|
assert adapter._validate_twilio_signature(
|
|
"https://b.com/webhooks/twilio", params, sig
|
|
) is False
|
|
|
|
def test_port_variant_443_matches_without_port(self):
|
|
"""Signature for https URL with :443 validates against URL without port."""
|
|
adapter = self._make_adapter()
|
|
params = {"From": "+15551234567", "Body": "hello"}
|
|
sig = _compute_twilio_signature(
|
|
"test_token_secret", "https://example.com:443/webhooks/twilio", params
|
|
)
|
|
assert adapter._validate_twilio_signature(
|
|
"https://example.com/webhooks/twilio", params, sig
|
|
) is True
|
|
|
|
def test_port_variant_without_port_matches_443(self):
|
|
"""Signature for https URL without port validates against URL with :443."""
|
|
adapter = self._make_adapter()
|
|
params = {"From": "+15551234567", "Body": "hello"}
|
|
sig = _compute_twilio_signature(
|
|
"test_token_secret", "https://example.com/webhooks/twilio", params
|
|
)
|
|
assert adapter._validate_twilio_signature(
|
|
"https://example.com:443/webhooks/twilio", params, sig
|
|
) is True
|
|
|
|
def test_non_standard_port_no_variant(self):
|
|
"""Non-standard port must NOT match URL without port."""
|
|
adapter = self._make_adapter()
|
|
params = {"From": "+15551234567", "Body": "hello"}
|
|
sig = _compute_twilio_signature(
|
|
"test_token_secret", "https://example.com/webhooks/twilio", params
|
|
)
|
|
assert adapter._validate_twilio_signature(
|
|
"https://example.com:8080/webhooks/twilio", params, sig
|
|
) is False
|
|
|
|
def test_port_variant_http_80(self):
|
|
"""Port variant also works for http with port 80."""
|
|
adapter = self._make_adapter()
|
|
params = {"From": "+15551234567", "Body": "hello"}
|
|
sig = _compute_twilio_signature(
|
|
"test_token_secret", "http://example.com:80/webhooks/twilio", params
|
|
)
|
|
assert adapter._validate_twilio_signature(
|
|
"http://example.com/webhooks/twilio", params, sig
|
|
) is True
|
|
|
|
|
|
# ── Webhook signature enforcement (handler-level) ──────────────────
|
|
|
|
class TestWebhookSignatureEnforcement:
|
|
"""Integration tests for signature validation in _handle_webhook."""
|
|
|
|
def _make_adapter(self, webhook_url=""):
|
|
from gateway.platforms.sms import SmsAdapter
|
|
|
|
env = {
|
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
|
"TWILIO_AUTH_TOKEN": "test_token_secret",
|
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
|
"SMS_WEBHOOK_URL": webhook_url,
|
|
}
|
|
with patch.dict(os.environ, env):
|
|
pc = PlatformConfig(enabled=True, api_key="test_token_secret")
|
|
adapter = SmsAdapter(pc)
|
|
adapter._message_handler = AsyncMock()
|
|
return adapter
|
|
|
|
def _mock_request(self, body, headers=None):
|
|
request = MagicMock()
|
|
request.read = AsyncMock(return_value=body)
|
|
request.headers = headers or {}
|
|
return request
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insecure_flag_skips_validation(self):
|
|
"""With SMS_INSECURE_NO_SIGNATURE=true and no URL, requests are accepted."""
|
|
env = {"SMS_INSECURE_NO_SIGNATURE": "true"}
|
|
with patch.dict(os.environ, env):
|
|
adapter = self._make_adapter(webhook_url="")
|
|
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
|
request = self._mock_request(body)
|
|
resp = await adapter._handle_webhook(request)
|
|
assert resp.status == 200
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insecure_flag_with_url_still_validates(self):
|
|
"""When both SMS_WEBHOOK_URL and SMS_INSECURE_NO_SIGNATURE are set,
|
|
validation stays active (URL takes precedence)."""
|
|
adapter = self._make_adapter(webhook_url="https://example.com/webhooks/twilio")
|
|
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
|
request = self._mock_request(body, headers={})
|
|
resp = await adapter._handle_webhook(request)
|
|
assert resp.status == 403
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_signature_returns_403(self):
|
|
adapter = self._make_adapter(webhook_url="https://example.com/webhooks/twilio")
|
|
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
|
request = self._mock_request(body, headers={})
|
|
resp = await adapter._handle_webhook(request)
|
|
assert resp.status == 403
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_signature_returns_403(self):
|
|
adapter = self._make_adapter(webhook_url="https://example.com/webhooks/twilio")
|
|
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
|
request = self._mock_request(body, headers={"X-Twilio-Signature": "invalid"})
|
|
resp = await adapter._handle_webhook(request)
|
|
assert resp.status == 403
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_valid_signature_returns_200(self):
|
|
webhook_url = "https://example.com/webhooks/twilio"
|
|
adapter = self._make_adapter(webhook_url=webhook_url)
|
|
params = {
|
|
"From": "+15551234567",
|
|
"To": "+15550001111",
|
|
"Body": "hello",
|
|
"MessageSid": "SM123",
|
|
}
|
|
sig = _compute_twilio_signature("test_token_secret", webhook_url, params)
|
|
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
|
request = self._mock_request(body, headers={"X-Twilio-Signature": sig})
|
|
resp = await adapter._handle_webhook(request)
|
|
assert resp.status == 200
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_port_variant_signature_returns_200(self):
|
|
"""Signature computed with :443 should pass when URL configured without port."""
|
|
webhook_url = "https://example.com/webhooks/twilio"
|
|
adapter = self._make_adapter(webhook_url=webhook_url)
|
|
params = {
|
|
"From": "+15551234567",
|
|
"To": "+15550001111",
|
|
"Body": "hello",
|
|
"MessageSid": "SM123",
|
|
}
|
|
sig = _compute_twilio_signature(
|
|
"test_token_secret", "https://example.com:443/webhooks/twilio", params
|
|
)
|
|
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
|
request = self._mock_request(body, headers={"X-Twilio-Signature": sig})
|
|
resp = await adapter._handle_webhook(request)
|
|
assert resp.status == 200
|