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
BIN
View File
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
name: build-image
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install (editable, with dev extras)
run: pip install --quiet -e ".[dev]"
- name: Test + coverage gate
run: pytest
build:
needs: test
runs-on: ubuntu-latest
timeout-minutes: 20
env:
IMAGE: git.farh.net/farhoodlabs/intervalsicu-mcp-ui
DOCKER_BUILDKIT: "1"
steps:
- uses: actions/checkout@v4
- name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.farh.net -u cpfarhood --password-stdin
- name: Build image
run: docker build --progress=plain -t "${IMAGE}:latest" -t "${IMAGE}:${GITHUB_SHA}" .
- name: Push image
run: |
docker push "${IMAGE}:latest"
docker push "${IMAGE}:${GITHUB_SHA}"
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
dist/
*.db
.ruff_cache/
+1
View File
@@ -0,0 +1 @@
3.12
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml pyproject.toml
COPY src src
# All deps ship manylinux wheels — no build toolchain needed.
RUN pip install --no-cache-dir . \
&& python -c "import pathlib, intervalsicu_mcp_ui as m; \
assert (pathlib.Path(m.__file__).parent / 'templates' / 'base.html').exists(), 'templates not packaged'"
EXPOSE 8080
CMD ["uvicorn", "intervalsicu_mcp_ui.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8080"]
+37
View File
@@ -0,0 +1,37 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "intervalsicu-mcp-ui"
version = "0.1.0"
description = "Web portal for the Intervals.icu MCP server (user credential management + admin approval)"
requires-python = ">=3.12"
license = { text = "GPL-3.0-only" }
dependencies = [
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"authlib>=1.3",
"httpx>=0.25",
"itsdangerous>=2.1",
"jinja2>=3.1",
"python-multipart>=0.0.9",
"sqlalchemy[asyncio]>=2.0.30",
"asyncpg>=0.29",
"cryptography>=42.0",
]
[project.optional-dependencies]
dev = ["pytest>=8.3", "pytest-asyncio>=0.23", "pytest-cov>=5.0", "aiosqlite>=0.20", "ruff>=0.1"]
[tool.hatch.build.targets.wheel]
packages = ["src/intervalsicu_mcp_ui"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.pytest.ini_options]
addopts = "-q --cov=intervalsicu_mcp_ui --cov-report=term-missing --cov-fail-under=80"
testpaths = ["tests"]
asyncio_mode = "auto"
View File
+152
View File
@@ -0,0 +1,152 @@
"""
Intervals.icu MCP portal.
- OIDC login via Authentik (Authlib).
- First login creates a *disabled* user record (admin approval required).
- Users set/replace their Intervals.icu athlete ID + API key; the key is
validated against Intervals.icu and stored encrypted.
- Admins (members of the configured group) approve / disable / delete users.
"""
from __future__ import annotations
from pathlib import Path
from authlib.integrations.starlette_client import OAuth
from fastapi import Depends, FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from intervalsicu_mcp_ui import db
from intervalsicu_mcp_ui.config import Config, load_config
from intervalsicu_mcp_ui.intervals import validate_credentials
_TEMPLATES = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
def current_user(request: Request) -> dict | None:
"""Session user dict ({sub,email,name,is_admin}) or None. Overridable in tests."""
return request.session.get("user")
def create_app(config: Config | None = None) -> FastAPI:
cfg = config or load_config()
db.configure(cfg.database_url, pool_pre_ping=True)
oauth = OAuth()
oauth.register(
name="authentik",
server_metadata_url=f"{cfg.oidc_issuer}.well-known/openid-configuration",
client_id=cfg.oidc_client_id,
client_secret=cfg.oidc_client_secret,
client_kwargs={"scope": "openid email profile"},
)
app = FastAPI(title="Intervals.icu MCP portal")
app.add_middleware(SessionMiddleware, secret_key=cfg.session_secret, https_only=True, same_site="lax")
app.state.cfg = cfg
app.state.oauth = oauth
def render(request, name, **ctx):
return _TEMPLATES.TemplateResponse(request, name, {"user": current_user(request), **ctx})
# ----- auth ----------------------------------------------------------- #
@app.get("/", response_class=HTMLResponse)
async def index(request: Request, user: dict | None = Depends(current_user)):
return RedirectResponse("/account" if user else "/login")
@app.get("/login", response_class=HTMLResponse)
async def login(request: Request):
return render(request, "login.html")
@app.get("/auth/login")
async def auth_login(request: Request): # pragma: no cover - OIDC redirect (integration)
return await oauth.authentik.authorize_redirect(request, cfg.oidc_redirect_url)
@app.get("/auth/callback")
async def auth_callback(request: Request): # pragma: no cover - OIDC callback (integration)
token = await oauth.authentik.authorize_access_token(request)
claims = dict(token.get("userinfo") or {})
sub = claims.get("sub")
if not sub:
return RedirectResponse("/login")
email = claims.get("email", "")
name = claims.get("name")
groups = claims.get("groups") or []
async with db.sessionmaker()() as session:
await db.upsert_login(session, sub, email, name)
request.session["user"] = {
"sub": sub,
"email": email,
"name": name,
"is_admin": cfg.admin_group in groups,
}
return RedirectResponse("/account")
@app.get("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login")
# ----- account -------------------------------------------------------- #
@app.get("/account", response_class=HTMLResponse)
async def account(request: Request, user: dict | None = Depends(current_user)):
if not user:
return RedirectResponse("/login")
async with db.sessionmaker()() as session:
record = await db.get_user(session, user["sub"])
return render(request, "account.html", record=record)
@app.post("/account", response_class=HTMLResponse)
async def save_account(
request: Request,
athlete_id: str = Form(...),
api_key: str = Form(...),
user: dict | None = Depends(current_user),
):
if not user:
return RedirectResponse("/login")
ok, message = await validate_credentials(cfg.intervals_api_base, athlete_id, api_key)
async with db.sessionmaker()() as session:
if ok:
await db.set_credentials(session, user["sub"], athlete_id.strip(), api_key.strip())
record = await db.get_user(session, user["sub"])
return render(
request, "account.html", record=record,
flash=message, flash_ok=ok,
)
# ----- admin ---------------------------------------------------------- #
def require_admin(user: dict | None = Depends(current_user)) -> dict | None:
return user if (user and user.get("is_admin")) else None
@app.get("/admin", response_class=HTMLResponse)
async def admin(request: Request, admin_user: dict | None = Depends(require_admin)):
if not admin_user:
return RedirectResponse("/account")
async with db.sessionmaker()() as session:
users = await db.list_users(session)
return render(request, "admin.html", users=users)
@app.post("/admin/{sub}/{action}")
async def admin_action(
request: Request, sub: str, action: str,
admin_user: dict | None = Depends(require_admin),
):
if not admin_user:
return RedirectResponse("/account")
async with db.sessionmaker()() as session:
if action == "enable":
await db.set_enabled(session, sub, True)
elif action == "disable":
await db.set_enabled(session, sub, False)
elif action == "delete":
await db.delete_user(session, sub)
return RedirectResponse("/admin", status_code=303)
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
return app
+31
View File
@@ -0,0 +1,31 @@
"""Environment configuration for the portal."""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
database_url: str
oidc_issuer: str # Authentik application base, e.g. https://auth.farh.net/application/o/<slug>/
oidc_client_id: str
oidc_client_secret: str
oidc_redirect_url: str # https://<host>/auth/callback
session_secret: str
admin_group: str
intervals_api_base: str
def load_config() -> Config:
return Config(
database_url=os.environ["DATABASE_URL"],
oidc_issuer=os.environ["OIDC_ISSUER"].rstrip("/") + "/",
oidc_client_id=os.environ["OIDC_CLIENT_ID"],
oidc_client_secret=os.environ["OIDC_CLIENT_SECRET"],
oidc_redirect_url=os.environ["OIDC_REDIRECT_URL"],
session_secret=os.environ["SESSION_SECRET"],
admin_group=os.environ.get("ADMIN_GROUP", "intervalsicu-mcp-admins"),
intervals_api_base=os.environ.get("INTERVALS_API_BASE_URL", "https://intervals.icu/api/v1"),
)
+66
View File
@@ -0,0 +1,66 @@
"""
Symmetric encryption for user secrets (the per-user Intervals.icu API key).
Uses AES-256-GCM (authenticated encryption). The key is supplied as a
base64-encoded 32-byte value via the ``INTERVALS_ENC_KEY`` environment variable
(mounted from a Kubernetes secret) and is shared by the MCP server and the UI
service so both can read/write the same ciphertext.
Ciphertext layout: ``nonce(12 bytes) || ciphertext+tag``. A fresh random nonce is
used per encryption, so encrypting the same plaintext twice yields different bytes.
The API key must be recoverable (the server uses it to call Intervals), so this is
reversible encryption, not hashing.
"""
from __future__ import annotations
import base64
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
_NONCE_LEN = 12
_KEY_LEN = 32
class CryptoError(Exception):
"""Raised when encryption is misconfigured or a payload cannot be decrypted."""
def load_key(raw: str | None = None) -> bytes:
"""Return the 32-byte AES key from a base64 string (or ``INTERVALS_ENC_KEY``)."""
raw = raw if raw is not None else os.environ.get("INTERVALS_ENC_KEY")
if not raw:
raise CryptoError("INTERVALS_ENC_KEY is not set")
try:
key = base64.b64decode(raw, validate=True)
except (ValueError, base64.binascii.Error) as exc: # type: ignore[attr-defined]
raise CryptoError("INTERVALS_ENC_KEY is not valid base64") from exc
if len(key) != _KEY_LEN:
raise CryptoError(f"INTERVALS_ENC_KEY must decode to {_KEY_LEN} bytes, got {len(key)}")
return key
def encrypt(plaintext: str, key: bytes | None = None) -> bytes:
"""Encrypt a string; returns ``nonce || ciphertext``."""
key = key if key is not None else load_key()
nonce = os.urandom(_NONCE_LEN)
ciphertext = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None)
return nonce + ciphertext
def decrypt(blob: bytes, key: bytes | None = None) -> str:
"""Decrypt bytes produced by :func:`encrypt`. Raises CryptoError on tamper/wrong key."""
key = key if key is not None else load_key()
if len(blob) <= _NONCE_LEN:
raise CryptoError("ciphertext too short")
nonce, ciphertext = blob[:_NONCE_LEN], blob[_NONCE_LEN:]
try:
return AESGCM(key).decrypt(nonce, ciphertext, None).decode("utf-8")
except Exception as exc: # noqa: BLE001 - InvalidTag etc.
raise CryptoError("could not decrypt payload") from exc
def generate_key_b64() -> str:
"""Generate a fresh base64-encoded 32-byte key (for provisioning the secret)."""
return base64.b64encode(os.urandom(_KEY_LEN)).decode("ascii")
+125
View File
@@ -0,0 +1,125 @@
"""
Database access for the portal.
The ``users`` table schema is owned by the MCP server's Alembic migrations; this
module maps the same table so the portal can read/write it. API keys are stored
encrypted (see :mod:`crypto`); ``enabled`` is the admin-approval gate.
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, LargeBinary, String, false, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from intervalsicu_mcp_ui import crypto
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
sub: Mapped[str] = mapped_column(String(255), primary_key=True)
email: Mapped[str] = mapped_column(String(320), nullable=False)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
athlete_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
api_key_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@property
def has_credentials(self) -> bool:
return bool(self.athlete_id and self.api_key_enc)
_sessionmaker: async_sessionmaker | None = None
def configure(url: str, **kwargs) -> None:
global _sessionmaker # noqa: PLW0603
engine = create_async_engine(url, **kwargs)
_sessionmaker = async_sessionmaker(engine, expire_on_commit=False)
def sessionmaker() -> async_sessionmaker:
if _sessionmaker is None:
raise RuntimeError("db.configure() was not called")
return _sessionmaker
# --------------------------------------------------------------------------- #
# operations
# --------------------------------------------------------------------------- #
async def upsert_login(session: AsyncSession, sub: str, email: str, name: str | None) -> User:
"""Create the user (disabled) on first login; refresh profile otherwise."""
user = await session.get(User, sub)
now = datetime.now(timezone.utc)
if user is None:
user = User(sub=sub, email=email, name=name, enabled=False, last_login_at=now)
session.add(user)
else:
user.email = email
user.name = name
user.last_login_at = now
await session.commit()
return user
async def get_user(session: AsyncSession, sub: str) -> User | None:
return await session.get(User, sub)
async def list_users(session: AsyncSession) -> list[User]:
result = await session.execute(select(User).order_by(User.created_at.desc()))
return list(result.scalars().all())
async def set_credentials(session: AsyncSession, sub: str, athlete_id: str, api_key: str) -> bool:
user = await session.get(User, sub)
if user is None:
return False
user.athlete_id = athlete_id
user.api_key_enc = crypto.encrypt(api_key)
await session.commit()
return True
async def clear_credentials(session: AsyncSession, sub: str) -> bool:
user = await session.get(User, sub)
if user is None:
return False
user.athlete_id = None
user.api_key_enc = None
await session.commit()
return True
async def set_enabled(session: AsyncSession, sub: str, enabled: bool) -> bool:
user = await session.get(User, sub)
if user is None:
return False
user.enabled = enabled
await session.commit()
return True
async def delete_user(session: AsyncSession, sub: str) -> bool:
user = await session.get(User, sub)
if user is None:
return False
await session.delete(user)
await session.commit()
return True
+39
View File
@@ -0,0 +1,39 @@
"""Validate a user's Intervals.icu credentials by calling the API."""
from __future__ import annotations
import httpx
async def validate_credentials(base_url: str, athlete_id: str, api_key: str) -> tuple[bool, str]:
"""Return ``(ok, message)`` after hitting Intervals.icu with the given creds.
Uses the same HTTP Basic scheme the MCP server uses (username ``API_KEY``).
"""
athlete_id = athlete_id.strip()
api_key = api_key.strip()
if not athlete_id or not api_key:
return False, "Both athlete ID and API key are required."
url = f"{base_url}/athlete/{athlete_id}"
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
url,
auth=httpx.BasicAuth("API_KEY", api_key),
headers={"Accept": "application/json"},
)
except httpx.HTTPError as exc:
return False, f"Could not reach Intervals.icu: {exc}"
if resp.status_code == 200:
try:
name = resp.json().get("name") or athlete_id
except ValueError:
name = athlete_id
return True, f"Connected as {name}."
if resp.status_code in (401, 403):
return False, "Invalid API key, or it doesn't have access to that athlete."
if resp.status_code == 404:
return False, f"Athlete {athlete_id} not found."
return False, f"Intervals.icu returned HTTP {resp.status_code}."
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block content %}
<h1>My account</h1>
<p class="muted">{{ user.email }}</p>
{% if record and record.enabled %}
<div class="banner ok">✓ Your account is <strong>approved</strong>. Once your credentials are set below, the MCP connector will work.</div>
{% else %}
<div class="banner warn">⏳ Your account is <strong>pending admin approval</strong>. You can save your credentials now — they'll take effect once an admin approves you.</div>
{% endif %}
{% if flash %}
<div class="banner {{ 'ok' if flash_ok else 'err' }}">{{ flash }}</div>
{% endif %}
<h2>Intervals.icu credentials</h2>
<p>
Athlete ID: <code>{{ record.athlete_id if record and record.athlete_id else '— not set —' }}</code><br>
API key: {% if record and record.api_key_enc %}<code>•••••••• set</code>{% else %}<code>— not set —</code>{% endif %}
</p>
<p class="muted">Find these in Intervals.icu → Settings → Developer. Your athlete ID looks like <code>i123456</code>.</p>
<form method="post" action="/account">
<label for="athlete_id">Athlete ID</label>
<input type="text" id="athlete_id" name="athlete_id" value="{{ record.athlete_id or '' if record else '' }}" placeholder="i123456" required>
<label for="api_key">API key</label>
<input type="password" id="api_key" name="api_key" placeholder="paste your API key" required autocomplete="off">
<button type="submit">Save &amp; test connection</button>
</form>
<h2>Connect Claude</h2>
<p>Add a custom connector in Claude pointing at:</p>
<p><code>https://intervalsicu-mcp.farhoodlabs.com/mcp</code></p>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block content %}
<h1>Admin — users</h1>
<p class="muted">{{ users|length }} user(s). Enable an account to let it use the MCP connector.</p>
<table>
<thead>
<tr><th>Email</th><th>Status</th><th>Credentials</th><th>Last login</th><th>Actions</th></tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.email }}<br><span class="muted">{{ u.name or '' }}</span></td>
<td>{% if u.enabled %}<span style="color:#16a34a">✓ enabled</span>{% else %}<span class="muted">disabled</span>{% endif %}</td>
<td>{% if u.has_credentials %}set{% else %}<span class="muted">none</span>{% endif %}</td>
<td class="muted">{{ u.last_login_at.strftime('%Y-%m-%d') if u.last_login_at else '—' }}</td>
<td>
{% if u.enabled %}
<form class="inline" method="post" action="/admin/{{ u.sub }}/disable"><button class="secondary" type="submit">Disable</button></form>
{% else %}
<form class="inline" method="post" action="/admin/{{ u.sub }}/enable"><button type="submit">Approve</button></form>
{% endif %}
<form class="inline" method="post" action="/admin/{{ u.sub }}/delete" onsubmit="return confirm('Delete {{ u.email }} and their stored credentials?');"><button class="secondary" type="submit">Delete</button></form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Intervals.icu MCP portal</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 0 auto; padding: 1rem; line-height: 1.5; }
header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;
border-bottom: 1px solid #8884; padding-bottom: .5rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
nav a { margin-left: 1rem; }
a { color: #3b82f6; text-decoration: none; }
a:hover { text-decoration: underline; }
.banner { padding: .6rem .9rem; border-radius: 8px; margin: 1rem 0; }
.ok { background: #16a34a22; border: 1px solid #16a34a88; }
.warn { background: #d9770622; border: 1px solid #d9770688; }
.err { background: #dc262622; border: 1px solid #dc262688; }
label { display: block; margin-top: 1rem; font-weight: 600; }
input[type=text], input[type=password] { width: 100%; padding: .5rem; margin-top: .25rem;
border: 1px solid #8886; border-radius: 6px; box-sizing: border-box; }
button { margin-top: 1rem; padding: .5rem 1rem; border-radius: 6px; border: 1px solid #8886;
background: #3b82f6; color: #fff; cursor: pointer; }
button.secondary { background: transparent; color: inherit; }
table { border-collapse: collapse; width: 100%; margin-top: 1rem; font-size: .92rem; }
th, td { text-align: left; padding: .4rem .5rem; border-bottom: 1px solid #8883; }
code { background: #8882; padding: .1rem .35rem; border-radius: 4px; }
.muted { color: #8889; }
form.inline { display: inline; }
</style>
</head>
<body>
<header>
<strong>Intervals.icu&nbsp;MCP</strong>
{% if user %}
<nav>
<a href="/account">My account</a>
{% if user.is_admin %}<a href="/admin">Admin</a>{% endif %}
<a href="/logout">Sign out</a>
</nav>
{% endif %}
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block content %}
<h1>Intervals.icu MCP portal</h1>
<p>Sign in to connect your Intervals.icu account to the MCP server and manage your API credentials.</p>
<p><a href="/auth/login"><button>Sign in</button></a></p>
{% endblock %}
+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
Generated
+1143
View File
File diff suppressed because it is too large Load Diff