Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions gateway/platforms/sms.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

Gateway-specific env vars:
- SMS_WEBHOOK_PORT (default 8080)
- SMS_WEBHOOK_HOST (default 0.0.0.0)
- SMS_WEBHOOK_HOST (default 127.0.0.1)
- SMS_WEBHOOK_URL (public URL for Twilio signature validation — required)
- SMS_INSECURE_NO_SIGNATURE (true to disable signature validation — dev only)
- SMS_ALLOWED_USERS (comma-separated E.164 phone numbers)
Expand Down Expand Up @@ -41,7 +41,7 @@
TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"
MAX_SMS_LENGTH = 1600 # ~10 SMS segments
DEFAULT_WEBHOOK_PORT = 8080
DEFAULT_WEBHOOK_HOST = "0.0.0.0"
DEFAULT_WEBHOOK_HOST = "127.0.0.1"


def check_sms_requirements() -> bool:
Expand Down Expand Up @@ -91,19 +91,23 @@ async def connect(self) -> bool:
from aiohttp import web

if not self._from_number:
logger.error("[sms] TWILIO_PHONE_NUMBER not set — cannot send replies")
msg = "[sms] TWILIO_PHONE_NUMBER not set — cannot send replies"
logger.error(msg)
self._set_fatal_error("sms_missing_phone_number", msg, retryable=False)
return False

insecure_no_sig = os.getenv("SMS_INSECURE_NO_SIGNATURE", "").lower() == "true"

if not self._webhook_url and not insecure_no_sig:
logger.error(
msg = (
"[sms] Refusing to start: SMS_WEBHOOK_URL is required for Twilio "
"signature validation. Set it to the public URL configured in your "
"Twilio console (e.g. https://example.com/webhooks/twilio). "
"For local development without validation, set "
"SMS_INSECURE_NO_SIGNATURE=true (NOT recommended for production).",
"SMS_INSECURE_NO_SIGNATURE=true (NOT recommended for production)."
)
logger.error(msg)
self._set_fatal_error("sms_missing_webhook_url", msg, retryable=False)
return False

if insecure_no_sig and not self._webhook_url:
Expand Down
46 changes: 44 additions & 2 deletions tests/gateway/test_sms.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,9 @@ def test_check_sms_requirements_both_set(self):
class TestWebhookHostConfig:
"""Verify SMS_WEBHOOK_HOST env var and default."""

def test_default_host_is_all_interfaces(self):
def test_default_host_is_localhost(self):
from gateway.platforms.sms import DEFAULT_WEBHOOK_HOST
assert DEFAULT_WEBHOOK_HOST == "0.0.0.0"
assert DEFAULT_WEBHOOK_HOST == "127.0.0.1"

def test_host_from_env(self):
from gateway.platforms.sms import SmsAdapter
Expand Down Expand Up @@ -242,6 +242,48 @@ async def test_refuses_start_without_webhook_url(self):
result = await adapter.connect()
assert result is False

@pytest.mark.asyncio
async def test_missing_webhook_url_is_non_retryable(self):
adapter = self._make_adapter()
await adapter.connect()
assert adapter.has_fatal_error is True
assert adapter.fatal_error_retryable is False
assert "sms_missing_webhook_url" == adapter.fatal_error_code

@pytest.mark.asyncio
async def test_missing_phone_number_is_non_retryable(self):
from gateway.platforms.sms import SmsAdapter

env = {
"TWILIO_ACCOUNT_SID": "ACtest",
"TWILIO_AUTH_TOKEN": "tok",
"TWILIO_PHONE_NUMBER": "",
"SMS_WEBHOOK_URL": "",
}
with patch.dict(os.environ, env, clear=True):
pc = PlatformConfig(enabled=True, api_key="tok")
adapter = SmsAdapter(pc)
await adapter.connect()
assert adapter.has_fatal_error is True
assert adapter.fatal_error_retryable is False
assert adapter.fatal_error_code == "sms_missing_phone_number"

@pytest.mark.asyncio
async def test_insecure_flag_does_not_set_fatal_error(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
assert adapter.has_fatal_error is False
await adapter.disconnect()

@pytest.mark.asyncio
async def test_insecure_flag_allows_start_without_url(self):
mock_session = AsyncMock()
Expand Down
Loading