The MCP server was created but never mounted — no client could reach
it. Mount it at /mcp/ over streamable HTTP with a combined lifespan,
resolving the gateway lazily so mounting happens at app construction.
Security and safety for the agent surface:
- One static API_TOKEN (SecretStr) enforced across REST (dependency),
WebSocket (query param/header before accept), and MCP
(StaticTokenVerifier). Startup refuses tokenless non-loopback binds.
- Emergency numbers (911/9911/112) always refused on make_call, plus a
MAX_CONCURRENT_CALLS cap; ValueError surfaces as 400/ToolError.
- Safe defaults: debug off, no credential in default DATABASE_URL,
SIP/LLM/TTS secrets as SecretStr.
Cleanups:
- Delete broken learn_call_flow tool (wrong ctor args, nonexistent
method) and the never-fed CallAnalytics service; keep
call_flow_learner for proper wiring later.
- Trim dial_plan to what is actually used (emergency guard, extension
allocation); delete the unreferenced matcher/normaliser.
- Register call_history before calls so /api/calls/history is no
longer shadowed by /api/calls/{call_id}.
- fastmcp pinned >=3.0 (http_app + StaticTokenVerifier).
New tests: MCP in-memory client (tool surface, lazy gateway, emergency
refusal, call cap) and API security (401 paths, route order, mount).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
91 lines
2.9 KiB
Python
91 lines
2.9 KiB
Python
"""
|
|
API surface tests — bearer-token enforcement and route registration order.
|
|
|
|
The app is exercised without its lifespan: auth runs before any handler,
|
|
so a 503 ("Gateway not initialized") proves the token was accepted.
|
|
"""
|
|
|
|
import httpx
|
|
import pytest
|
|
from pydantic import SecretStr
|
|
from starlette.routing import Match
|
|
|
|
import main
|
|
from config import get_settings
|
|
|
|
TOKEN = "test-token-for-suite"
|
|
|
|
|
|
@pytest.fixture
|
|
def token_enabled(monkeypatch):
|
|
monkeypatch.setattr(get_settings(), "api_token", SecretStr(TOKEN))
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
transport = httpx.ASGITransport(app=main.app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
class TestBearerToken:
|
|
async def test_missing_token_rejected(self, token_enabled, client):
|
|
resp = await client.get("/api/calls/active")
|
|
assert resp.status_code == 401
|
|
assert resp.headers["www-authenticate"] == "Bearer"
|
|
|
|
async def test_wrong_token_rejected(self, token_enabled, client):
|
|
resp = await client.get(
|
|
"/api/calls/active", headers={"Authorization": "Bearer wrong"}
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
async def test_valid_token_reaches_handler(self, token_enabled, client):
|
|
resp = await client.get(
|
|
"/api/calls/active", headers={"Authorization": f"Bearer {TOKEN}"}
|
|
)
|
|
# No lifespan ran, so the handler itself 503s — auth was accepted
|
|
assert resp.status_code == 503
|
|
|
|
async def test_empty_token_disables_auth(self, monkeypatch, client):
|
|
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
|
|
resp = await client.get("/api/calls/active")
|
|
assert resp.status_code == 503
|
|
|
|
async def test_all_api_routers_protected(self, token_enabled, client):
|
|
for path in ("/api/calls/active", "/api/call-flows/", "/api/devices/",
|
|
"/api/routing/rules", "/api/calls/history"):
|
|
resp = await client.get(path)
|
|
assert resp.status_code == 401, path
|
|
|
|
|
|
class TestRouteOrder:
|
|
def _resolve(self, path: str):
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": path,
|
|
"root_path": "",
|
|
"query_string": b"",
|
|
"headers": [],
|
|
}
|
|
for route in main.app.router.routes:
|
|
match, _ = route.matches(scope)
|
|
if match == Match.FULL:
|
|
return route
|
|
return None
|
|
|
|
def test_history_not_shadowed_by_call_id(self):
|
|
route = self._resolve("/api/calls/history")
|
|
assert route is not None
|
|
assert route.endpoint.__name__ == "list_history"
|
|
|
|
def test_call_id_still_matches(self):
|
|
route = self._resolve("/api/calls/call_abc123")
|
|
assert route is not None
|
|
assert route.endpoint.__name__ == "get_call"
|
|
|
|
def test_mcp_mounted(self):
|
|
mounted = [getattr(r, "path", "") for r in main.app.router.routes]
|
|
assert "/mcp" in mounted
|