feat: Intervals.icu MCP portal (FastAPI + Authentik OIDC)
- 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:
@@ -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}"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
dist/
|
||||||
|
*.db
|
||||||
|
.ruff_cache/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
+15
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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 & 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 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 %}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user