feat: Intervals.icu MCP portal (FastAPI + Authentik OIDC)
build-image / test (push) Successful in 37s
build-image / build (push) Successful in 25s

- OIDC login via Authentik; first login creates a disabled user (admin approval).
- Users set/replace their Intervals.icu athlete ID + API key; the key is validated
  against Intervals.icu and stored AES-256-GCM encrypted (shared key with the MCP
  server). Same users table (schema owned by the MCP server's migrations).
- Admin page (gated on the intervalsicu-mcp-admins group claim): list, approve,
  disable, delete users.
- 29 tests @ 93% (OIDC routes integration-only); Dockerfile asserts templates are
  packaged; .gitea CI test-gates the image build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 20:51:46 -04:00
commit bb5e9d518c
21 changed files with 2096 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
"""Route tests via TestClient (OIDC bypassed with a dependency override)."""
import asyncio
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from intervalsicu_mcp_ui import app as appmod
from intervalsicu_mcp_ui import crypto, db
from intervalsicu_mcp_ui.config import Config
KEY = crypto.generate_key_b64()
USER = {"sub": "sub-user", "email": "user@x.com", "name": "User", "is_admin": False}
ADMIN = {"sub": "sub-admin", "email": "admin@x.com", "name": "Admin", "is_admin": True}
@pytest.fixture
def app_url(tmp_path, monkeypatch):
monkeypatch.setenv("INTERVALS_ENC_KEY", KEY)
url = f"sqlite+aiosqlite:///{tmp_path / 'ui.db'}"
async def init():
engine = create_async_engine(url)
async with engine.begin() as conn:
await conn.run_sync(db.Base.metadata.create_all)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as s:
await db.upsert_login(s, "sub-user", "user@x.com", "User")
await db.upsert_login(s, "sub-admin", "admin@x.com", "Admin")
await engine.dispose()
asyncio.run(init())
cfg = Config(
database_url=url,
oidc_issuer="https://auth.example/application/o/x/",
oidc_client_id="cid",
oidc_client_secret="sec",
oidc_redirect_url="https://ui.example/auth/callback",
session_secret="x" * 32,
admin_group="intervalsicu-mcp-admins",
intervals_api_base="https://intervals.icu/api/v1",
)
return appmod.create_app(cfg), url
def _as(app, user):
app.dependency_overrides[appmod.current_user] = lambda: user
def _fetch_user(url, sub):
async def go():
engine = create_async_engine(url)
async with async_sessionmaker(engine, expire_on_commit=False)() as s:
u = await db.get_user(s, sub)
# detach copy of the bits we assert on
data = None if u is None else {"athlete_id": u.athlete_id, "enc": u.api_key_enc, "enabled": u.enabled}
await engine.dispose()
return data
return asyncio.run(go())
def test_health(app_url):
app, _ = app_url
assert TestClient(app).get("/healthz").json() == {"status": "ok"}
def test_unauthenticated_account_redirects_to_login(app_url):
app, _ = app_url
_as(app, None)
r = TestClient(app).get("/account", follow_redirects=False)
assert r.status_code in (302, 307) and r.headers["location"] == "/login"
def test_account_shows_pending_for_disabled_user(app_url):
app, _ = app_url
_as(app, USER)
assert "pending admin approval" in TestClient(app).get("/account").text.lower()
def test_save_valid_credentials_stores_encrypted(app_url, monkeypatch):
app, url = app_url
_as(app, USER)
async def ok(_base, _aid, _key):
return True, "Connected as Chris."
monkeypatch.setattr(appmod, "validate_credentials", ok)
r = TestClient(app).post("/account", data={"athlete_id": "i123", "api_key": "secret"})
assert "Connected as Chris" in r.text
rec = _fetch_user(url, "sub-user")
assert rec["athlete_id"] == "i123"
assert b"secret" not in rec["enc"] and crypto.decrypt(rec["enc"]) == "secret"
def test_save_invalid_credentials_not_stored(app_url, monkeypatch):
app, url = app_url
_as(app, USER)
async def bad(_base, _aid, _key):
return False, "Invalid API key, or it doesn't have access to that athlete."
monkeypatch.setattr(appmod, "validate_credentials", bad)
r = TestClient(app).post("/account", data={"athlete_id": "i123", "api_key": "bad"})
assert "Invalid API key" in r.text
assert _fetch_user(url, "sub-user")["enc"] is None # nothing stored
def test_admin_page_forbidden_for_non_admin(app_url):
app, _ = app_url
_as(app, USER)
r = TestClient(app).get("/admin", follow_redirects=False)
assert r.headers["location"] == "/account"
def test_admin_lists_and_approves(app_url):
app, url = app_url
_as(app, ADMIN)
client = TestClient(app)
listing = client.get("/admin").text
assert "user@x.com" in listing and "admin@x.com" in listing
r = client.post("/admin/sub-user/enable", follow_redirects=False)
assert r.status_code == 303
assert _fetch_user(url, "sub-user")["enabled"] is True
def test_admin_delete(app_url):
app, url = app_url
_as(app, ADMIN)
TestClient(app).post("/admin/sub-user/delete", follow_redirects=False)
assert _fetch_user(url, "sub-user") is None
+74
View File
@@ -0,0 +1,74 @@
"""
Tests for intervals_mcp_server.crypto (AES-256-GCM for the user API key).
Covers round-trip, per-message nonce randomness, tamper/wrong-key detection,
and key loading/validation from the environment.
"""
import base64
import pytest
from intervalsicu_mcp_ui import crypto
from intervalsicu_mcp_ui.crypto import CryptoError
KEY = crypto.load_key(crypto.generate_key_b64())
def test_roundtrip():
assert crypto.decrypt(crypto.encrypt("s3cr3t-api-key", KEY), KEY) == "s3cr3t-api-key"
def test_same_plaintext_encrypts_differently():
a = crypto.encrypt("same", KEY)
b = crypto.encrypt("same", KEY)
assert a != b # random nonce per message
assert crypto.decrypt(a, KEY) == crypto.decrypt(b, KEY) == "same"
def test_ciphertext_does_not_contain_plaintext():
assert b"api-key" not in crypto.encrypt("my-api-key", KEY)
def test_wrong_key_rejected():
blob = crypto.encrypt("x", KEY)
with pytest.raises(CryptoError):
crypto.decrypt(blob, crypto.load_key(crypto.generate_key_b64()))
def test_tampered_ciphertext_rejected():
blob = bytearray(crypto.encrypt("x", KEY))
blob[-1] ^= 0x01 # flip a bit in the GCM tag
with pytest.raises(CryptoError):
crypto.decrypt(bytes(blob), KEY)
def test_short_ciphertext_rejected():
with pytest.raises(CryptoError):
crypto.decrypt(b"tiny", KEY)
def test_load_key_from_env(monkeypatch):
monkeypatch.setenv("INTERVALS_ENC_KEY", crypto.generate_key_b64())
assert len(crypto.load_key()) == 32
def test_load_key_missing(monkeypatch):
monkeypatch.delenv("INTERVALS_ENC_KEY", raising=False)
with pytest.raises(CryptoError):
crypto.load_key()
def test_load_key_wrong_length():
with pytest.raises(CryptoError):
crypto.load_key(base64.b64encode(b"too-short").decode())
def test_load_key_bad_base64():
with pytest.raises(CryptoError):
crypto.load_key("!!!not-base64!!!")
def test_encrypt_defaults_to_env_key(monkeypatch):
monkeypatch.setenv("INTERVALS_ENC_KEY", crypto.generate_key_b64())
assert crypto.decrypt(crypto.encrypt("hello")) == "hello"
+60
View File
@@ -0,0 +1,60 @@
"""Store operations against in-memory SQLite (mirrors the MCP server's schema)."""
import asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from intervalsicu_mcp_ui import crypto, db
KEY = crypto.generate_key_b64()
def _run_db(monkeypatch, body):
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(db.Base.metadata.create_all)
maker = async_sessionmaker(engine, expire_on_commit=False)
try:
await body(maker)
finally:
await engine.dispose()
asyncio.run(go())
def test_first_login_disabled_then_credentials_encrypted(monkeypatch):
async def body(maker):
async with maker() as s:
user = await db.upsert_login(s, "sub1", "a@b.com", "Alice")
assert user.enabled is False and user.has_credentials is False
assert await db.set_credentials(s, "sub1", "i123", "SECRET") is True
again = await db.get_user(s, "sub1")
assert again.athlete_id == "i123"
assert b"SECRET" not in again.api_key_enc
assert crypto.decrypt(again.api_key_enc) == "SECRET"
assert again.has_credentials is True
_run_db(monkeypatch, body)
def test_enable_disable_delete_and_list(monkeypatch):
async def body(maker):
async with maker() as s:
await db.upsert_login(s, "sub1", "a@b.com", None)
await db.upsert_login(s, "sub2", "b@b.com", None)
assert len(await db.list_users(s)) == 2
assert await db.set_enabled(s, "sub1", True) is True
assert (await db.get_user(s, "sub1")).enabled is True
assert await db.clear_credentials(s, "sub1") is True
assert await db.delete_user(s, "sub2") is True
assert await db.get_user(s, "sub2") is None
assert await db.delete_user(s, "ghost") is False
_run_db(monkeypatch, body)
+58
View File
@@ -0,0 +1,58 @@
"""Tests for the Intervals.icu credential validator (mocked HTTP)."""
import httpx
import pytest
from intervalsicu_mcp_ui import intervals
BASE = "https://intervals.icu/api/v1"
def _mock(monkeypatch, handler):
transport = httpx.MockTransport(handler)
real_init = httpx.AsyncClient.__init__
def patched_init(self, *args, **kwargs):
kwargs["transport"] = transport
real_init(self, *args, **kwargs)
monkeypatch.setattr(httpx.AsyncClient, "__init__", patched_init)
async def test_valid_credentials(monkeypatch):
_mock(monkeypatch, lambda req: httpx.Response(200, json={"name": "Chris"}))
ok, msg = await intervals.validate_credentials(BASE, "i123", "key")
assert ok is True and "Chris" in msg
async def test_bad_key(monkeypatch):
_mock(monkeypatch, lambda req: httpx.Response(401))
ok, msg = await intervals.validate_credentials(BASE, "i123", "key")
assert ok is False and "Invalid API key" in msg
async def test_not_found(monkeypatch):
_mock(monkeypatch, lambda req: httpx.Response(404))
ok, msg = await intervals.validate_credentials(BASE, "i999", "key")
assert ok is False and "not found" in msg
async def test_other_status(monkeypatch):
_mock(monkeypatch, lambda req: httpx.Response(500))
ok, msg = await intervals.validate_credentials(BASE, "i123", "key")
assert ok is False and "500" in msg
async def test_network_error(monkeypatch):
def boom(req):
raise httpx.ConnectError("refused")
_mock(monkeypatch, boom)
ok, msg = await intervals.validate_credentials(BASE, "i123", "key")
assert ok is False and "Could not reach" in msg
@pytest.mark.parametrize("aid,key", [("", "k"), ("i1", ""), (" ", "k")])
async def test_missing_inputs(aid, key):
ok, msg = await intervals.validate_credentials(BASE, aid, key)
assert ok is False and "required" in msg