feat: implement local authentication mode and update related components

This commit is contained in:
Abhimanyu Saharan
2026-02-11 19:10:23 +05:30
parent 0ff645f795
commit 06ff1a9720
23 changed files with 563 additions and 93 deletions

View File

@@ -1,9 +1,15 @@
# ruff: noqa: INP001
"""Pytest configuration shared across backend tests."""
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
# Tests should fail fast if auth-mode wiring breaks, but still need deterministic
# defaults during import-time settings initialization.
os.environ.setdefault("AUTH_MODE", "local")
os.environ.setdefault("LOCAL_AUTH_TOKEN", "test-local-token")

View File

@@ -21,6 +21,9 @@ class _FakeSession:
async def test_get_auth_context_raises_401_when_clerk_signed_out(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(auth.settings, "auth_mode", "clerk")
monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy")
from clerk_backend_api.security.types import AuthStatus, RequestState
async def _fake_authenticate(_request: Any) -> RequestState:
@@ -42,6 +45,9 @@ async def test_get_auth_context_raises_401_when_clerk_signed_out(
async def test_get_auth_context_uses_request_state_payload_claims(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(auth.settings, "auth_mode", "clerk")
monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy")
from clerk_backend_api.security.types import AuthStatus, RequestState
async def _fake_authenticate(_request: Any) -> RequestState:
@@ -82,6 +88,9 @@ async def test_get_auth_context_uses_request_state_payload_claims(
async def test_get_auth_context_optional_returns_none_for_agent_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(auth.settings, "auth_mode", "clerk")
monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy")
async def _boom(_request: Any) -> Any: # pragma: no cover
raise AssertionError("_authenticate_clerk_request should not be called")
@@ -93,3 +102,46 @@ async def test_get_auth_context_optional_returns_none_for_agent_token(
session=_FakeSession(), # type: ignore[arg-type]
)
assert out is None
@pytest.mark.asyncio
async def test_get_auth_context_local_mode_requires_valid_bearer_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(auth.settings, "auth_mode", "local")
monkeypatch.setattr(auth.settings, "local_auth_token", "expected-token")
async def _fake_local_user(_session: Any) -> User:
return User(clerk_user_id="local-auth-user", email="local@localhost", name="Local User")
monkeypatch.setattr(auth, "_get_or_create_local_user", _fake_local_user)
ctx = await auth.get_auth_context( # type: ignore[arg-type]
request=SimpleNamespace(headers={"Authorization": "Bearer expected-token"}),
credentials=None,
session=_FakeSession(), # type: ignore[arg-type]
)
assert ctx.actor_type == "user"
assert ctx.user is not None
assert ctx.user.clerk_user_id == "local-auth-user"
@pytest.mark.asyncio
async def test_get_auth_context_optional_local_mode_returns_none_without_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(auth.settings, "auth_mode", "local")
monkeypatch.setattr(auth.settings, "local_auth_token", "expected-token")
async def _boom(_session: Any) -> User: # pragma: no cover
raise AssertionError("_get_or_create_local_user should not be called")
monkeypatch.setattr(auth, "_get_or_create_local_user", _boom)
out = await auth.get_auth_context_optional( # type: ignore[arg-type]
request=SimpleNamespace(headers={}),
credentials=None,
session=_FakeSession(), # type: ignore[arg-type]
)
assert out is None

View File

@@ -0,0 +1,99 @@
# ruff: noqa: INP001
"""Integration tests for local auth mode on protected API routes."""
from __future__ import annotations
from uuid import uuid4
import pytest
from fastapi import APIRouter, FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.users import router as users_router
from app.core import auth as auth_module
from app.core.config import settings
from app.db.session import get_session
async def _make_engine() -> AsyncEngine:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.connect() as conn, conn.begin():
await conn.run_sync(SQLModel.metadata.create_all)
return engine
def _build_test_app(
session_maker: async_sessionmaker[AsyncSession],
) -> FastAPI:
app = FastAPI()
api_v1 = APIRouter(prefix="/api/v1")
api_v1.include_router(users_router)
app.include_router(api_v1)
async def _override_get_session() -> AsyncSession:
async with session_maker() as session:
yield session
app.dependency_overrides[get_session] = _override_get_session
app.dependency_overrides[auth_module.get_session] = _override_get_session
return app
@pytest.mark.asyncio
async def test_local_auth_users_me_requires_and_accepts_valid_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
unique_suffix = uuid4().hex
expected_user_id = f"local-auth-integration-{unique_suffix}"
expected_email = f"local-{unique_suffix}@localhost"
expected_name = "Local Integration User"
monkeypatch.setattr(settings, "auth_mode", "local")
monkeypatch.setattr(settings, "local_auth_token", "integration-token")
monkeypatch.setattr(auth_module, "LOCAL_AUTH_USER_ID", expected_user_id)
monkeypatch.setattr(auth_module, "LOCAL_AUTH_EMAIL", expected_email)
monkeypatch.setattr(auth_module, "LOCAL_AUTH_NAME", expected_name)
engine = await _make_engine()
session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
app = _build_test_app(session_maker)
try:
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
missing = await client.get("/api/v1/users/me")
assert missing.status_code == 401
invalid = await client.get(
"/api/v1/users/me",
headers={"Authorization": "Bearer wrong-token"},
)
assert invalid.status_code == 401
authorized = await client.get(
"/api/v1/users/me",
headers={"Authorization": "Bearer integration-token"},
)
assert authorized.status_code == 200
payload = authorized.json()
assert payload["clerk_user_id"] == expected_user_id
assert payload["email"] == expected_email
assert payload["name"] == expected_name
repeat = await client.get(
"/api/v1/users/me",
headers={"Authorization": "Bearer integration-token"},
)
assert repeat.status_code == 200
assert repeat.json()["id"] == payload["id"]
finally:
await engine.dispose()