docs: add Claude AI assistant rules and configuration
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s

Add comprehensive rule documentation for AI-assisted development covering
authentication surfaces, outbound-call safety invariants, and other project
conventions to guide Claude's understanding of critical system behaviors.
This commit is contained in:
2026-07-28 19:01:38 -04:00
parent 016d8be71d
commit 4a3c14d4af
40 changed files with 2851 additions and 202 deletions

View File

@@ -1,24 +1,53 @@
"""
API surface tests — bearer-token enforcement and route registration order.
API surface tests — owner 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.
so a 503 ("Gateway not initialized") proves the caller was accepted as
owner. Auth internals (JWT/PAT resolution) are covered in test_auth.py;
here we assert the routers are gated and the routes register in the right
order. In dev-owner mode (SSO disabled) a tokenless request is the owner.
"""
import httpx
import pytest
from pydantic import SecretStr
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from starlette.routing import Match
import db.database as dbmod
import main
from config import get_settings
TOKEN = "test-token-for-suite"
from db.database import Base
@pytest.fixture
def token_enabled(monkeypatch):
monkeypatch.setattr(get_settings(), "api_token", SecretStr(TOKEN))
async def mem_db(monkeypatch):
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(dbmod, "_engine", engine)
monkeypatch.setattr(dbmod, "_session_factory", factory)
yield factory
await engine.dispose()
@pytest.fixture
def dev_owner(monkeypatch):
"""SSO disabled — every request resolves to the dev owner."""
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
@pytest.fixture
def sso_enabled(monkeypatch):
"""SSO enabled with no credentials supplied → 401 on protected routes."""
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
@pytest.fixture
@@ -28,45 +57,34 @@ async def client():
yield c
class TestBearerToken:
async def test_missing_token_rejected(self, token_enabled, client):
class TestOwnerGate:
async def test_dev_owner_reaches_handler(self, dev_owner, mem_db, client):
resp = await client.get("/api/v1/calls/active")
assert resp.status_code == 503 # dev-owner accepted; handler 503s (no lifespan)
async def test_sso_missing_credentials_rejected(self, sso_enabled, mem_db, client):
resp = await client.get("/api/v1/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/v1/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/v1/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/v1/calls/active")
assert resp.status_code == 503
async def test_query_param_token_accepted(self, token_enabled, client):
"""<audio>/<a> elements can't set headers — ?token= must work."""
resp = await client.get(f"/api/v1/calls/active?token={TOKEN}")
assert resp.status_code == 503 # auth accepted, handler 503s (no lifespan)
async def test_wrong_query_param_token_rejected(self, token_enabled, client):
resp = await client.get("/api/v1/calls/active?token=wrong")
assert resp.status_code == 401
async def test_all_api_routers_protected(self, token_enabled, client):
for path in ("/api/v1/calls/active", "/api/v1/call-flows/", "/api/v1/devices/",
"/api/v1/routing/rules", "/api/v1/calls/history"):
async def test_all_api_routers_protected(self, sso_enabled, mem_db, client):
for path in (
"/api/v1/calls/active",
"/api/v1/call-flows/",
"/api/v1/devices/",
"/api/v1/routing/rules",
"/api/v1/calls/history",
"/api/v1/tokens",
):
resp = await client.get(path)
assert resp.status_code == 401, path
async def test_auth_routes_are_public(self, sso_enabled, mem_db, client):
"""The OIDC endpoints must be reachable without a token."""
# /auth/me with no token → 401 (not 403); /auth/login → redirect to Casdoor
resp = await client.get("/auth/login", follow_redirects=False)
assert resp.status_code in (302, 307)
class TestRouteOrder:
def _resolve(self, path: str):

239
tests/test_auth.py Normal file
View File

@@ -0,0 +1,239 @@
"""
Auth tests — Casdoor JWT + PAT resolution, owner gating, dev-owner mode.
No live Casdoor: we generate an RSA keypair, stub the JWKS client so
`_decode_casdoor_jwt` trusts our public key, and mint RS256 JWTs locally.
The app is exercised without its lifespan, so a 503 ("Gateway not
initialized") proves auth was accepted and the request reached a handler.
DB access (resolve_bearer → users/PATs) hits an in-memory SQLite database
wired in via the `mem_db` fixture, mirroring tests/test_data_layer.py.
"""
import time
import uuid
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
import auth as authmod
import db.database as dbmod
import main
from config import get_settings
from db.database import Base, PersonalAccessToken, User
ENDPOINT = "https://id.example.test"
OWNER = "owner@example.test"
# ── RSA keypair + JWKS stub ──────────────────────────────────────────────────
@pytest.fixture(scope="module")
def keypair():
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
return private_pem, key.public_key()
def _mint(private_pem, *, sub, name, email=None, exp_delta=3600):
claims = {
"iss": ENDPOINT,
"sub": sub,
"name": name,
"displayName": name,
"exp": int(time.time()) + exp_delta,
"iat": int(time.time()),
}
if email:
claims["email"] = email
return jwt.encode(claims, private_pem, algorithm="RS256")
class _StubJWKS:
"""Stands in for jwt.PyJWKClient — returns our fixed public key."""
def __init__(self, public_key):
self._key = public_key
def get_signing_key_from_jwt(self, token):
class _K:
key = self._key
return _K()
def fetch_data(self):
pass
@pytest.fixture
def sso_enabled(monkeypatch, keypair):
"""Enable Casdoor SSO with a known owner and a stubbed JWKS client."""
_, public_key = keypair
settings = get_settings()
monkeypatch.setattr(settings.casdoor, "enabled", True)
monkeypatch.setattr(settings.casdoor, "endpoint", ENDPOINT)
monkeypatch.setattr(settings, "owner_name", OWNER)
monkeypatch.setattr(authmod, "_jwks_client", _StubJWKS(public_key))
@pytest.fixture
async def mem_db(monkeypatch):
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(dbmod, "_engine", engine)
monkeypatch.setattr(dbmod, "_session_factory", factory)
yield factory
await engine.dispose()
@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
async def _seed_user(factory, *, name, casdoor_sub=None, email=None) -> str:
uid = uuid.uuid4().hex
async with factory() as session:
session.add(
User(id=uid, name=name, display_name=name, email=email, casdoor_sub=casdoor_sub)
)
await session.commit()
return uid
async def _seed_pat(factory, user_id, *, revoked=False, expires_at=None) -> str:
from datetime import UTC, datetime
plaintext = authmod.PAT_PREFIX + uuid.uuid4().hex
async with factory() as session:
pat = PersonalAccessToken(
id=uuid.uuid4().hex,
user_id=user_id,
name="test",
token_hash=authmod.hash_token(plaintext),
token_prefix=plaintext[: len(authmod.PAT_PREFIX) + 4],
revoked_at=(datetime.now(UTC) if revoked else None),
expires_at=expires_at,
)
session.add(pat)
await session.commit()
return plaintext
PROTECTED = "/api/v1/calls/active"
# ── JWT paths ────────────────────────────────────────────────────────────────
class TestCasdoorJWT:
async def test_owner_jwt_reaches_handler(self, sso_enabled, mem_db, client, keypair):
private_pem, _ = keypair
token = _mint(private_pem, sub="s-owner", name=OWNER, email=OWNER)
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 503 # auth accepted; no lifespan → handler 503s
async def test_non_owner_jwt_forbidden(self, sso_enabled, mem_db, client, keypair):
private_pem, _ = keypair
token = _mint(private_pem, sub="s-guest", name="guest@example.test")
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 403
async def test_expired_jwt_unauthenticated(self, sso_enabled, mem_db, client, keypair):
private_pem, _ = keypair
token = _mint(private_pem, sub="s-owner", name=OWNER, exp_delta=-10)
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 401
async def test_garbage_token_unauthenticated(self, sso_enabled, mem_db, client):
resp = await client.get(PROTECTED, headers={"Authorization": "Bearer not.a.jwt"})
assert resp.status_code == 401
async def test_no_credentials_unauthenticated(self, sso_enabled, mem_db, client):
resp = await client.get(PROTECTED)
assert resp.status_code == 401
assert resp.headers["www-authenticate"] == "Bearer"
# ── PAT paths ────────────────────────────────────────────────────────────────
class TestPAT:
async def test_owner_pat_reaches_handler(self, sso_enabled, mem_db, client):
uid = await _seed_user(mem_db, name=OWNER, casdoor_sub="s-owner", email=OWNER)
pat = await _seed_pat(mem_db, uid)
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
assert resp.status_code == 503
async def test_non_owner_pat_forbidden(self, sso_enabled, mem_db, client):
uid = await _seed_user(mem_db, name="guest@example.test", casdoor_sub="s-guest")
pat = await _seed_pat(mem_db, uid)
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
assert resp.status_code == 403
async def test_revoked_pat_unauthenticated(self, sso_enabled, mem_db, client):
uid = await _seed_user(mem_db, name=OWNER, casdoor_sub="s-owner", email=OWNER)
pat = await _seed_pat(mem_db, uid, revoked=True)
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
assert resp.status_code == 401
async def test_expired_pat_unauthenticated(self, sso_enabled, mem_db, client):
from datetime import UTC, datetime, timedelta
uid = await _seed_user(mem_db, name=OWNER, casdoor_sub="s-owner", email=OWNER)
pat = await _seed_pat(mem_db, uid, expires_at=datetime.now(UTC) - timedelta(minutes=1))
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
assert resp.status_code == 401
async def test_unknown_pat_unauthenticated(self, sso_enabled, mem_db, client):
resp = await client.get(
PROTECTED, headers={"Authorization": f"Bearer {authmod.PAT_PREFIX}nope"}
)
assert resp.status_code == 401
# ── Dev-owner mode (SSO disabled) ────────────────────────────────────────────
class TestDevOwnerMode:
async def test_tokenless_request_is_owner(self, monkeypatch, mem_db, client):
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
resp = await client.get(PROTECTED)
assert resp.status_code == 503 # dev-owner resolved; handler 503s (no lifespan)
async def test_auth_me_reports_owner(self, monkeypatch, mem_db, client):
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
resp = await client.get("/auth/me")
assert resp.status_code == 200
body = resp.json()
assert body["is_owner"] is True
# ── /auth/me for a non-owner (200 + is_owner:false, not a hard 401) ──────────
class TestAuthMe:
async def test_non_owner_gets_200_not_owner(self, sso_enabled, mem_db, client, keypair):
private_pem, _ = keypair
token = _mint(private_pem, sub="s-guest", name="guest@example.test")
resp = await client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert resp.json()["is_owner"] is False

View File

@@ -6,12 +6,32 @@ Uses the FastMCP in-memory client (no network, no mounted app).
import pytest
from fastmcp import Client
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from config import Settings
import db.database as dbmod
from config import Settings, get_settings
from core.dial_plan import is_emergency_number
from core.gateway import AIPSTNGateway
from db.database import Base
from mcp_server.server import create_mcp_server
@pytest.fixture
async def mem_db(monkeypatch):
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(dbmod, "_engine", engine)
monkeypatch.setattr(dbmod, "_session_factory", factory)
yield factory
await engine.dispose()
EXPECTED_TOOLS = {
"make_call",
"get_call_status",
@@ -43,11 +63,64 @@ class TestToolSurface:
tools = {t.name for t in await client.list_tools()}
assert tools == EXPECTED_TOOLS
async def test_auth_configured_when_token_given(self):
assert create_mcp_server(lambda: None, api_token="sekrit").auth is not None
async def test_no_fastmcp_auth_configured(self):
# Auth for /mcp is enforced by the ASGI _owner_only_mcp guard in
# main.py, not on the FastMCP instance itself.
assert create_mcp_server(lambda: None).auth is None
class TestMcpOwnerGuard:
"""The ASGI wrapper gates /mcp before the inner app runs."""
def _wrapped(self):
import main
calls = {"inner": 0}
async def inner(scope, receive, send):
calls["inner"] += 1
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
return main._owner_only_mcp(inner), calls
async def _run(self, app, headers):
sent = []
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(msg):
sent.append(msg)
scope = {
"type": "http",
"method": "POST",
"path": "/mcp/",
"headers": headers,
"query_string": b"",
}
await app(scope, receive, send)
status = next(m["status"] for m in sent if m["type"] == "http.response.start")
return status
async def test_missing_token_401(self, monkeypatch, mem_db):
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
app, calls = self._wrapped()
status = await self._run(app, headers=[])
assert status == 401
assert calls["inner"] == 0
async def test_dev_owner_passes(self, monkeypatch, mem_db):
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
app, calls = self._wrapped()
status = await self._run(app, headers=[])
assert status == 200
assert calls["inner"] == 1
class TestGatewayResolution:
async def test_tool_errors_cleanly_before_gateway_ready(self):
mcp = create_mcp_server(lambda: None)

View File

@@ -0,0 +1,77 @@
"""
OAuth discovery metadata tests (RFC 9728 / RFC 8414 / RFC 7591).
MCP clients that get a 401 from /mcp perform OAuth discovery. These
endpoints are unauthenticated and served straight from main.app.
"""
import httpx
import pytest
import main
from config import get_settings
@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 TestProtectedResourceMetadata:
async def test_resource_advertises_mcp_path(self, client):
resp = await client.get("/.well-known/oauth-protected-resource")
assert resp.status_code == 200
body = resp.json()
# mcp-remote verifies this matches the URL it connected to.
assert body["resource"] == "http://test/mcp"
assert body["authorization_servers"] == ["http://test"]
async def test_mcp_suffixed_variant(self, client):
resp = await client.get("/.well-known/oauth-protected-resource/mcp")
assert resp.status_code == 200
assert resp.json()["resource"] == "http://test/mcp"
class TestAuthorizationServerMetadata:
async def test_advertises_casdoor_when_enabled(self, monkeypatch, client):
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
resp = await client.get("/.well-known/oauth-authorization-server")
assert resp.status_code == 200
body = resp.json()
assert body["issuer"] == "https://id.example.test"
assert body["jwks_uri"] == "https://id.example.test/.well-known/jwks"
assert body["registration_endpoint"] == "http://test/register"
async def test_dev_mode_advertises_local(self, monkeypatch, client):
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
resp = await client.get("/.well-known/oauth-authorization-server")
assert resp.status_code == 200
body = resp.json()
assert body["issuer"] == "http://test"
assert body["authorization_endpoint"] == "http://test/auth/login"
class TestDynamicRegistration:
async def test_registers_client(self, client):
resp = await client.post(
"/register",
json={"redirect_uris": ["http://localhost/cb"], "client_name": "test"},
)
assert resp.status_code == 201
body = resp.json()
assert "client_id" in body
assert body["redirect_uris"] == ["http://localhost/cb"]
async def test_rejects_missing_redirect_uris(self, client):
resp = await client.post("/register", json={"client_name": "test"})
assert resp.status_code == 400
assert resp.json()["error"] == "invalid_redirect_uri"
async def test_rejects_non_json(self, client):
resp = await client.post(
"/register", content=b"not json", headers={"content-type": "application/json"}
)
assert resp.status_code == 400

View File

@@ -9,7 +9,6 @@ through the shared data layer in services/call_persistence.py.
import httpx
import pytest
from pydantic import SecretStr
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
@@ -116,7 +115,9 @@ class TestInboundPolicy:
@pytest.fixture
async def client(monkeypatch):
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
# Dev-owner mode: tokenless requests resolve to the owner. auth's DB
# session comes through the same get_db override below.
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",

View File

@@ -1,27 +1,57 @@
"""
WebSocket event-stream tests.
The socket is refused (4401) without the bearer token, and an
authorized client immediately receives the synthetic trunk-status
event followed by the replayed recent history.
The socket is owner-gated: refused (4401) when SSO is enabled and no
credential is supplied, and — in dev-owner mode (SSO disabled) — an
authorized client immediately receives the synthetic trunk-status event
followed by the replayed recent history.
The WS `_authorize` resolves the owner via a DB session, so an in-memory
SQLite database is wired in (StaticPool, shared across the TestClient
thread) mirroring tests/test_data_layer.py.
"""
import asyncio
import pytest
from pydantic import SecretStr
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
import db.database as dbmod
import main
from config import Settings, get_settings
from core.gateway import AIPSTNGateway
from db.database import Base
from models.events import EventType, GatewayEvent
@pytest.fixture
def ws_app(monkeypatch):
monkeypatch.setattr(get_settings(), "api_token", SecretStr("tok"))
def mem_db(monkeypatch):
"""Synchronous setup of an in-memory SQLite DB shared with the app."""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async def _create():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
asyncio.run(_create())
factory = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(dbmod, "_engine", engine)
monkeypatch.setattr(dbmod, "_session_factory", factory)
yield
asyncio.run(engine.dispose())
@pytest.fixture
def ws_app(monkeypatch, mem_db):
"""Dev-owner mode: a tokenless WS connect resolves the owner."""
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
gateway = AIPSTNGateway(settings=Settings())
main.app.state.gateway = gateway
yield gateway
@@ -38,7 +68,11 @@ def _publish(gateway, call_id: str) -> None:
class TestEventStream:
def test_refused_without_token(self, ws_app):
def test_refused_without_credential(self, monkeypatch, mem_db):
"""SSO enabled + no token → the socket is closed with 4401."""
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
client = TestClient(main.app)
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect("/ws/events"):
@@ -50,7 +84,7 @@ class TestEventStream:
_publish(ws_app, "call_ws2")
client = TestClient(main.app)
with client.websocket_connect("/ws/events?token=tok") as ws:
with client.websocket_connect("/ws/events") as ws:
first = ws.receive_json()
assert first["type"] == EventType.SIP_TRUNK_REGISTRATION_FAILED.value
replayed = [ws.receive_json() for _ in range(2)]
@@ -58,9 +92,7 @@ class TestEventStream:
def test_per_call_stream_filters(self, ws_app):
client = TestClient(main.app)
with client.websocket_connect(
"/ws/calls/call_target/events?token=tok"
) as ws:
with client.websocket_connect("/ws/calls/call_target/events") as ws:
_publish(ws_app, "call_other")
_publish(ws_app, "call_target")
msg = ws.receive_json()