docs: add Claude AI assistant rules and configuration
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:
@@ -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
239
tests/test_auth.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
77
tests/test_oauth_metadata.py
Normal file
77
tests/test_oauth_metadata.py
Normal 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
|
||||
@@ -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:",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user