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):
|
||||
|
||||
Reference in New Issue
Block a user