Files
intervalsicu-mcp/tests/test_store.py
Chris Farhood 31eb45c3f8
build-image / test (push) Successful in 19s
build-image / build (push) Successful in 19s
feat(multi-tenant): data layer, encryption, and per-request credential resolver
Foundation for multi-user operation (Phase 1):
- crypto.py: AES-256-GCM encrypt/decrypt for the per-user Intervals API key,
  key from INTERVALS_ENC_KEY (base64 32 bytes). Random nonce per message.
- db/models.py + db/session.py: SQLAlchemy 2.0 async User model (keyed on the
  Authentik sub; api_key stored encrypted; enabled = admin-approval gate) and a
  lazy async engine/sessionmaker from DATABASE_URL.
- store.py: async CRUD. New users created disabled; login never flips enabled;
  get_active_credentials returns decrypted creds only for an enabled user that
  has them.
- credentials.py: resolve_caller_credentials() maps get_access_token().subject
  to that user's stored creds, falling back to env config only when unauthenticated
  (stdio/local).
- Tests (SQLite in-memory, no infra): crypto round-trip/tamper, store gating,
  resolver paths. Suite 199 passing at 90.4% (gate holds).

Deps: sqlalchemy[asyncio], asyncpg, alembic (+ aiosqlite for tests). Pin py3.12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:07:10 -04:00

127 lines
4.5 KiB
Python

"""
Tests for intervals_mcp_server.store against an in-memory SQLite database.
Verifies the real behaviors that matter: new users are created disabled,
credentials are stored encrypted (never plaintext), and get_active_credentials
only returns decrypted creds for an enabled user that actually has them.
"""
import asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from intervals_mcp_server import crypto, store
from intervals_mcp_server.db.models import Base
KEY = crypto.generate_key_b64()
def _run_db(monkeypatch, body):
"""Run `body(sessionmaker)` against a fresh in-memory DB, all in one event loop."""
async def go():
monkeypatch.setenv("INTERVALS_ENC_KEY", KEY)
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)
maker = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(store, "get_sessionmaker", lambda: maker)
try:
await body(maker)
finally:
await engine.dispose()
asyncio.run(go())
def test_first_login_creates_disabled_user(monkeypatch):
async def body(maker):
async with maker() as s:
user = await store.upsert_login(s, "sub1", "a@b.com", "Alice")
assert user.enabled is False
assert user.has_credentials is False
assert user.last_login_at is not None
_run_db(monkeypatch, body)
def test_repeat_login_refreshes_profile_but_not_enabled(monkeypatch):
async def body(maker):
async with maker() as s:
await store.upsert_login(s, "sub1", "a@b.com", "Alice")
await store.set_enabled(s, "sub1", True)
# a later login must not touch the approval flag
user = await store.upsert_login(s, "sub1", "new@b.com", "Al")
assert user.email == "new@b.com"
assert user.enabled is True # unchanged by login
assert len(await store.list_users(s)) == 1
_run_db(monkeypatch, body)
def test_set_credentials_stores_encrypted(monkeypatch):
async def body(maker):
async with maker() as s:
await store.upsert_login(s, "sub1", "a@b.com", None)
assert await store.set_credentials(s, "sub1", "i123", "SECRET-KEY") is True
user = await store.get_user(s, "sub1")
assert user.athlete_id == "i123"
assert user.api_key_enc is not None
assert b"SECRET-KEY" not in user.api_key_enc # not plaintext
assert crypto.decrypt(user.api_key_enc) == "SECRET-KEY"
_run_db(monkeypatch, body)
def test_set_credentials_unknown_user(monkeypatch):
async def body(maker):
async with maker() as s:
assert await store.set_credentials(s, "ghost", "i1", "k") is False
_run_db(monkeypatch, body)
def test_active_credentials_gated_on_enabled_and_present(monkeypatch):
async def body(maker):
async with maker() as s:
await store.upsert_login(s, "sub1", "a@b.com", None)
await store.set_credentials(s, "sub1", "i123", "KEY")
# has creds but not enabled -> None
assert await store.get_active_credentials("sub1") is None
async with maker() as s:
await store.set_enabled(s, "sub1", True)
# enabled + creds -> decrypted tuple
assert await store.get_active_credentials("sub1") == ("i123", "KEY")
_run_db(monkeypatch, body)
def test_active_credentials_enabled_without_creds(monkeypatch):
async def body(maker):
async with maker() as s:
await store.upsert_login(s, "sub1", "a@b.com", None)
await store.set_enabled(s, "sub1", True)
assert await store.get_active_credentials("sub1") is None
_run_db(monkeypatch, body)
def test_clear_and_delete(monkeypatch):
async def body(maker):
async with maker() as s:
await store.upsert_login(s, "sub1", "a@b.com", None)
await store.set_credentials(s, "sub1", "i1", "k")
assert await store.clear_credentials(s, "sub1") is True
user = await store.get_user(s, "sub1")
assert user.athlete_id is None and user.api_key_enc is None
assert await store.delete_user(s, "sub1") is True
assert await store.get_user(s, "sub1") is None
assert await store.delete_user(s, "ghost") is False
_run_db(monkeypatch, body)