Files
mywhoosh2garmin/docs/superpowers/plans/2026-08-15-foundation-admin-data.md
Bastian Wagner f6da346e18 plans + specs
2026-08-15 09:00:41 +02:00

32 KiB

Foundation, Admin UI, and Data Layer Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the runnable FastAPI application shell with SQLite persistence, encrypted per-user credentials, single-password admin authentication, CSRF protection, local user CRUD, and Docker packaging.

Architecture: Use one FastAPI process with synchronous SQLAlchemy sessions for low-volume SQLite access, server-rendered Jinja2 templates, signed cookie sessions, and Fernet encryption for stored service credentials. Deployment secrets and configuration come only from environment variables; persistent state lives under /data.

Tech Stack: Python 3.12, FastAPI, Starlette sessions, SQLAlchemy 2.x, Pydantic Settings, cryptography/Fernet, Jinja2, python-multipart, pytest, Docker.

Global Constraints

  • The admin UI is intended for local-network use only.
  • Use one admin password from ADMIN_PASSWORD; no username and no per-user web logins.
  • Use SQLite for persistent application state.
  • Store deployment secrets/configuration in environment variables, not in SQLite.
  • Encrypt MyWhoosh and Garmin credentials before writing them to SQLite.
  • Never return stored passwords to templates or API responses.
  • Use HttpOnly and SameSite=Lax session cookies.
  • Protect every state-changing web request with CSRF validation.
  • Keep all persistent application data under DATA_DIR, defaulting to /data.
  • Do not introduce Angular, React, Tailwind, Bootstrap, OAuth/OIDC, or an external queue in v1.

File Structure

pyproject.toml
Dockerfile
docker-compose.example.yml
.env.example
app/
  __init__.py
  main.py
  config.py
  auth/
    __init__.py
    admin.py
    csrf.py
  db/
    __init__.py
    models.py
    session.py
    repositories.py
  security/
    __init__.py
    credentials.py
  web/
    __init__.py
    routes.py
    forms.py
    templates/
      base.html
      login.html
      dashboard.html
      users/form.html
      users/detail.html
    static/
      app.css
tests/
  conftest.py
  test_config.py
  db/test_repositories.py
  security/test_credentials.py
  web/test_auth.py
  web/test_users.py

Task 1: Bootstrap configuration and application factory

Files:

  • Create: pyproject.toml
  • Create: app/config.py
  • Create: app/main.py
  • Create: tests/test_config.py
  • Create: tests/conftest.py

Interfaces:

  • Produces: Settings, get_settings(), create_app(settings: Settings | None = None) -> FastAPI.

  • Later tasks consume Settings.data_dir, Settings.database_url, Settings.admin_password, Settings.secret_key, and Settings.credential_encryption_key.

  • Step 1: Write failing configuration tests

# tests/test_config.py
from pathlib import Path

from app.config import Settings


def test_settings_build_default_data_paths(tmp_path: Path) -> None:
    settings = Settings(
        ADMIN_PASSWORD="admin-secret",
        SECRET_KEY="session-secret",
        CREDENTIAL_ENCRYPTION_KEY="ZmFrZS1rZXktZm9yLXRlc3RzLW11c3QtYmUtNDQtY2hhcnM=",
        DATA_DIR=str(tmp_path),
        SYNC_INTERVAL_MINUTES=5,
    )

    assert settings.data_dir == tmp_path
    assert settings.database_url == f"sqlite:///{tmp_path / 'app.db'}"
    assert settings.tokens_dir == tmp_path / "tokens"
    assert settings.activities_dir == tmp_path / "activities"


def test_sync_interval_must_be_positive(tmp_path: Path) -> None:
    try:
        Settings(
            ADMIN_PASSWORD="admin-secret",
            SECRET_KEY="session-secret",
            CREDENTIAL_ENCRYPTION_KEY="ZmFrZS1rZXktZm9yLXRlc3RzLW11c3QtYmUtNDQtY2hhcnM=",
            DATA_DIR=str(tmp_path),
            SYNC_INTERVAL_MINUTES=0,
        )
    except ValueError:
        return
    raise AssertionError("Expected validation failure for non-positive interval")
  • Step 2: Run the tests and verify they fail

Run: pytest tests/test_config.py -v

Expected: import/definition failure because app.config.Settings does not exist yet.

  • Step 3: Add project dependencies and implement Settings
# pyproject.toml
[project]
name = "mywhoosh-garmin-sync"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
  "fastapi>=0.115,<1",
  "uvicorn[standard]>=0.30,<1",
  "sqlalchemy>=2.0,<3",
  "pydantic-settings>=2.0,<3",
  "cryptography>=43,<50",
  "jinja2>=3.1,<4",
  "python-multipart>=0.0.9,<1",
]

[project.optional-dependencies]
test = [
  "pytest>=8,<9",
  "httpx>=0.27,<1",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
# app/config.py
from functools import lru_cache
from pathlib import Path

from pydantic import Field, PositiveInt, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False)

    admin_password: str = Field(alias="ADMIN_PASSWORD", min_length=1)
    secret_key: str = Field(alias="SECRET_KEY", min_length=16)
    credential_encryption_key: str = Field(alias="CREDENTIAL_ENCRYPTION_KEY", min_length=1)
    data_dir: Path = Field(default=Path("/data"), alias="DATA_DIR")
    database_url: str | None = Field(default=None, alias="DATABASE_URL")
    sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES")

    @model_validator(mode="after")
    def derive_paths(self) -> "Settings":
        self.data_dir = self.data_dir.expanduser().resolve()
        if self.database_url is None:
            self.database_url = f"sqlite:///{self.data_dir / 'app.db'}"
        return self

    @property
    def tokens_dir(self) -> Path:
        return self.data_dir / "tokens"

    @property
    def activities_dir(self) -> Path:
        return self.data_dir / "activities"


@lru_cache
def get_settings() -> Settings:
    return Settings()
  • Step 4: Implement a minimal application factory
# app/main.py
from fastapi import FastAPI

from app.config import Settings, get_settings


def create_app(settings: Settings | None = None) -> FastAPI:
    resolved = settings or get_settings()
    resolved.data_dir.mkdir(parents=True, exist_ok=True)
    resolved.tokens_dir.mkdir(parents=True, exist_ok=True)
    resolved.activities_dir.mkdir(parents=True, exist_ok=True)

    app = FastAPI(title="MyWhoosh Garmin Sync")
    app.state.settings = resolved

    @app.get("/healthz")
    def healthz() -> dict[str, str]:
        return {"status": "ok"}

    return app


app = create_app()
  • Step 5: Run tests and smoke-test the app factory

Run: pytest tests/test_config.py -v

Expected: PASS.

Run: python -c "from app.main import create_app; print(create_app)"

Expected: prints the function object without configuration-time crashes.

  • Step 6: Commit
git add pyproject.toml app/config.py app/main.py tests/test_config.py tests/conftest.py
git commit -m "feat: bootstrap FastAPI configuration"

Task 2: Add SQLite models and repositories

Files:

  • Create: app/db/models.py
  • Create: app/db/session.py
  • Create: app/db/repositories.py
  • Create: tests/db/test_repositories.py
  • Modify: app/main.py

Interfaces:

  • Produces: SyncUser, Activity, SyncRun, UserRepository, ActivityRepository, SyncRunRepository, create_db_engine(), create_session_factory().

  • Activity must enforce unique (user_id, mywhoosh_activity_id).

  • To make resumability explicit, store both status and last_completed_stage; status="failed" does not erase the last durable stage.

  • Step 1: Write repository tests for isolated users and idempotent activities

# tests/db/test_repositories.py
from app.db.models import ActivityStatus, HealthState


def test_create_two_independent_users(db_session, user_repository) -> None:
    first = user_repository.create(
        name="Max",
        enabled=True,
        health_state=HealthState.HEALTHY,
        mywhoosh_email_enc="mw-1",
        mywhoosh_password_enc="mw-pw-1",
        garmin_email_enc="g-1",
        garmin_password_enc="g-pw-1",
    )
    second = user_repository.create(
        name="Anna",
        enabled=True,
        health_state=HealthState.HEALTHY,
        mywhoosh_email_enc="mw-2",
        mywhoosh_password_enc="mw-pw-2",
        garmin_email_enc="g-2",
        garmin_password_enc="g-pw-2",
    )

    assert first.id != second.id
    assert {u.name for u in user_repository.list_enabled()} == {"Max", "Anna"}


def test_activity_external_id_is_unique_per_user(user_repository, activity_repository) -> None:
    user = user_repository.create(
        name="Max",
        enabled=True,
        health_state=HealthState.HEALTHY,
        mywhoosh_email_enc="a",
        mywhoosh_password_enc="b",
        garmin_email_enc="c",
        garmin_password_enc="d",
    )
    created, inserted = activity_repository.get_or_create_discovered(
        user_id=user.id,
        mywhoosh_activity_id="mw-123",
        activity_name="Morning Ride",
        activity_timestamp=None,
    )
    same, inserted_again = activity_repository.get_or_create_discovered(
        user_id=user.id,
        mywhoosh_activity_id="mw-123",
        activity_name="Morning Ride",
        activity_timestamp=None,
    )

    assert inserted is True
    assert inserted_again is False
    assert created.id == same.id
    assert same.status == ActivityStatus.DISCOVERED
  • Step 2: Run the repository tests and verify failure

Run: pytest tests/db/test_repositories.py -v

Expected: imports fail because database modules are not implemented.

  • Step 3: Define enums and models
# app/db/models.py
from __future__ import annotations

import enum
from datetime import datetime, timezone

from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


def utcnow() -> datetime:
    return datetime.now(timezone.utc)


class Base(DeclarativeBase):
    pass


class HealthState(str, enum.Enum):
    HEALTHY = "healthy"
    SYNCING = "syncing"
    DEGRADED = "degraded"
    ACTION_REQUIRED = "action_required"
    DISABLED = "disabled"


class ActivityStatus(str, enum.Enum):
    DISCOVERED = "discovered"
    DOWNLOADED = "downloaded"
    CONVERTED = "converted"
    IMPORTED = "imported"
    DUPLICATE = "duplicate"
    FAILED = "failed"


class SyncRunStatus(str, enum.Enum):
    RUNNING = "running"
    SUCCESS = "success"
    PARTIAL = "partial"
    FAILED = "failed"


class SyncUser(Base):
    __tablename__ = "sync_users"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(120), nullable=False)
    enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
    health_state: Mapped[HealthState] = mapped_column(Enum(HealthState), nullable=False, default=HealthState.HEALTHY)
    mywhoosh_state: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
    garmin_state: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
    action_reason: Mapped[str | None] = mapped_column(Text)
    mywhoosh_email_enc: Mapped[str] = mapped_column(Text, nullable=False)
    mywhoosh_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
    garmin_email_enc: Mapped[str] = mapped_column(Text, nullable=False)
    garmin_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)


class Activity(Base):
    __tablename__ = "activities"
    __table_args__ = (UniqueConstraint("user_id", "mywhoosh_activity_id", name="uq_activity_user_mywhoosh"),)

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("sync_users.id", ondelete="CASCADE"), nullable=False, index=True)
    mywhoosh_activity_id: Mapped[str] = mapped_column(String(255), nullable=False)
    activity_name: Mapped[str] = mapped_column(String(255), nullable=False)
    activity_timestamp: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    source_fit_path: Mapped[str | None] = mapped_column(Text)
    converted_fit_path: Mapped[str | None] = mapped_column(Text)
    status: Mapped[ActivityStatus] = mapped_column(Enum(ActivityStatus), nullable=False, default=ActivityStatus.DISCOVERED)
    last_completed_stage: Mapped[ActivityStatus] = mapped_column(Enum(ActivityStatus), nullable=False, default=ActivityStatus.DISCOVERED)
    retryable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
    garmin_activity_id: Mapped[str | None] = mapped_column(String(255))
    last_error: Mapped[str | None] = mapped_column(Text)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)


class SyncRun(Base):
    __tablename__ = "sync_runs"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("sync_users.id", ondelete="CASCADE"), nullable=False, index=True)
    started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
    finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    status: Mapped[SyncRunStatus] = mapped_column(Enum(SyncRunStatus), nullable=False, default=SyncRunStatus.RUNNING)
    discovered_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    imported_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    skipped_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    failed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    summary_error: Mapped[str | None] = mapped_column(Text)
  • Step 4: Implement session factory and focused repositories
# app/db/session.py
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker

from app.db.models import Base


def create_db_engine(database_url: str) -> Engine:
    connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
    return create_engine(database_url, connect_args=connect_args, future=True)


def create_session_factory(engine: Engine) -> sessionmaker[Session]:
    return sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)


def initialize_schema(engine: Engine) -> None:
    Base.metadata.create_all(engine)
# app/db/repositories.py
from datetime import datetime

from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app.db.models import Activity, ActivityStatus, HealthState, SyncUser


class UserRepository:
    def __init__(self, session: Session) -> None:
        self.session = session

    def create(self, **values) -> SyncUser:
        user = SyncUser(**values)
        self.session.add(user)
        self.session.commit()
        return user

    def get(self, user_id: int) -> SyncUser | None:
        return self.session.get(SyncUser, user_id)

    def list_enabled(self) -> list[SyncUser]:
        return list(self.session.scalars(select(SyncUser).where(SyncUser.enabled.is_(True)).order_by(SyncUser.id)))


class ActivityRepository:
    def __init__(self, session: Session) -> None:
        self.session = session

    def get_or_create_discovered(
        self,
        *,
        user_id: int,
        mywhoosh_activity_id: str,
        activity_name: str,
        activity_timestamp: datetime | None,
    ) -> tuple[Activity, bool]:
        existing = self.session.scalar(
            select(Activity).where(
                Activity.user_id == user_id,
                Activity.mywhoosh_activity_id == mywhoosh_activity_id,
            )
        )
        if existing is not None:
            return existing, False
        activity = Activity(
            user_id=user_id,
            mywhoosh_activity_id=mywhoosh_activity_id,
            activity_name=activity_name,
            activity_timestamp=activity_timestamp,
            status=ActivityStatus.DISCOVERED,
            last_completed_stage=ActivityStatus.DISCOVERED,
        )
        self.session.add(activity)
        try:
            self.session.commit()
        except IntegrityError:
            self.session.rollback()
            existing = self.session.scalar(
                select(Activity).where(
                    Activity.user_id == user_id,
                    Activity.mywhoosh_activity_id == mywhoosh_activity_id,
                )
            )
            if existing is None:
                raise
            return existing, False
        return activity, True
  • Step 5: Wire database initialization into create_app and add DB test fixtures
# add inside create_app in app/main.py
from app.db.session import create_db_engine, create_session_factory, initialize_schema

engine = create_db_engine(resolved.database_url)
initialize_schema(engine)
app.state.db_engine = engine
app.state.session_factory = create_session_factory(engine)
# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

from app.db.models import Base
from app.db.repositories import ActivityRepository, UserRepository


@pytest.fixture
def db_session() -> Session:
    engine = create_engine(
        "sqlite://",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(engine)
    factory = sessionmaker(bind=engine, expire_on_commit=False)
    with factory() as session:
        yield session


@pytest.fixture
def user_repository(db_session: Session) -> UserRepository:
    return UserRepository(db_session)


@pytest.fixture
def activity_repository(db_session: Session) -> ActivityRepository:
    return ActivityRepository(db_session)
  • Step 6: Run tests

Run: pytest tests/db/test_repositories.py -v

Expected: PASS.

  • Step 7: Commit
git add app/db app/main.py tests/conftest.py tests/db/test_repositories.py
git commit -m "feat: add SQLite persistence models"

Task 3: Encrypt credentials at rest

Files:

  • Create: app/security/credentials.py
  • Create: tests/security/test_credentials.py

Interfaces:

  • Produces: CredentialCipher.encrypt(value: str) -> str, CredentialCipher.decrypt(token: str) -> str.

  • The constructor accepts exactly the value of CREDENTIAL_ENCRYPTION_KEY.

  • Step 1: Write failing encryption tests

# tests/security/test_credentials.py
from cryptography.fernet import Fernet

from app.security.credentials import CredentialCipher


def test_round_trip_and_ciphertext_does_not_contain_plaintext() -> None:
    cipher = CredentialCipher(Fernet.generate_key().decode("ascii"))
    encrypted = cipher.encrypt("secret-password")

    assert "secret-password" not in encrypted
    assert cipher.decrypt(encrypted) == "secret-password"


def test_empty_credentials_are_rejected() -> None:
    cipher = CredentialCipher(Fernet.generate_key().decode("ascii"))
    try:
        cipher.encrypt("")
    except ValueError:
        return
    raise AssertionError("empty secrets must be rejected")
  • Step 2: Run tests and verify failure

Run: pytest tests/security/test_credentials.py -v

Expected: import failure.

  • Step 3: Implement the cipher
# app/security/credentials.py
from cryptography.fernet import Fernet, InvalidToken


class CredentialCipher:
    def __init__(self, key: str) -> None:
        try:
            self._fernet = Fernet(key.encode("ascii"))
        except Exception as exc:
            raise ValueError("CREDENTIAL_ENCRYPTION_KEY must be a valid Fernet key") from exc

    def encrypt(self, value: str) -> str:
        if not value:
            raise ValueError("credential value must not be empty")
        return self._fernet.encrypt(value.encode("utf-8")).decode("ascii")

    def decrypt(self, token: str) -> str:
        try:
            return self._fernet.decrypt(token.encode("ascii")).decode("utf-8")
        except InvalidToken as exc:
            raise ValueError("stored credential cannot be decrypted") from exc
  • Step 4: Run tests

Run: pytest tests/security/test_credentials.py -v

Expected: PASS.

  • Step 5: Commit
git add app/security/credentials.py tests/security/test_credentials.py
git commit -m "feat: encrypt stored service credentials"

Task 4: Add admin login, signed session, and CSRF protection

Files:

  • Create: app/auth/admin.py
  • Create: app/auth/csrf.py
  • Create: app/web/routes.py
  • Create: app/web/templates/base.html
  • Create: app/web/templates/login.html
  • Create: tests/web/test_auth.py
  • Modify: app/main.py

Interfaces:

  • Produces: require_admin(request), ensure_csrf_token(request), validate_csrf(request, submitted_token).

  • Session key for authentication is request.session["admin_authenticated"] is True.

  • Session key for CSRF is request.session["csrf_token"].

  • Step 1: Write failing auth and CSRF tests

# tests/web/test_auth.py
from fastapi.testclient import TestClient


def test_dashboard_redirects_when_not_logged_in(client: TestClient) -> None:
    response = client.get("/", follow_redirects=False)
    assert response.status_code == 303
    assert response.headers["location"] == "/login"


def extract_csrf(html: str) -> str:
    marker = 'name="csrf_token" value="'
    start = html.index(marker) + len(marker)
    return html[start:html.index('"', start)]


def test_login_rejects_wrong_password(client: TestClient) -> None:
    login_page = client.get("/login")
    csrf = extract_csrf(login_page.text)
    response = client.post(
        "/login",
        data={"password": "wrong", "csrf_token": csrf},
        follow_redirects=False,
    )
    assert response.status_code == 401


def test_login_accepts_configured_password(client: TestClient) -> None:
    login_page = client.get("/login")
    csrf = extract_csrf(login_page.text)
    response = client.post(
        "/login",
        data={"password": "admin-secret", "csrf_token": csrf},
        follow_redirects=False,
    )
    assert response.status_code == 303
    assert response.headers["location"] == "/"
  • Step 2: Run tests and verify failure

Run: pytest tests/web/test_auth.py -v

Expected: route/import failures.

  • Step 3: Implement admin comparison and CSRF helpers
# app/auth/admin.py
import hmac

from fastapi import HTTPException, Request, status


def password_matches(submitted: str, configured: str) -> bool:
    return hmac.compare_digest(submitted.encode("utf-8"), configured.encode("utf-8"))


def require_admin(request: Request) -> None:
    if request.session.get("admin_authenticated") is not True:
        raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"})
# app/auth/csrf.py
import hmac
import secrets

from fastapi import HTTPException, Request, status


def ensure_csrf_token(request: Request) -> str:
    token = request.session.get("csrf_token")
    if not isinstance(token, str):
        token = secrets.token_urlsafe(32)
        request.session["csrf_token"] = token
    return token


def validate_csrf(request: Request, submitted_token: str) -> None:
    expected = request.session.get("csrf_token")
    if not isinstance(expected, str) or not hmac.compare_digest(expected, submitted_token):
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token")
  • Step 4: Add login/dashboard routes and templates
# app/web/routes.py
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates

from app.auth.admin import password_matches, require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf

router = APIRouter()
templates = Jinja2Templates(directory="app/web/templates")


@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
    return templates.TemplateResponse(request, "login.html", {"csrf_token": ensure_csrf_token(request)})


@router.post("/login")
def login(
    request: Request,
    password: str = Form(...),
    csrf_token: str = Form(...),
):
    validate_csrf(request, csrf_token)
    settings = request.app.state.settings
    if not password_matches(password, settings.admin_password):
        return templates.TemplateResponse(
            request,
            "login.html",
            {"csrf_token": ensure_csrf_token(request), "error": "Invalid password"},
            status_code=401,
        )
    request.session["admin_authenticated"] = True
    return RedirectResponse("/", status_code=303)


@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request):
    require_admin(request)
    return templates.TemplateResponse(request, "dashboard.html", {"csrf_token": ensure_csrf_token(request), "users": []})
  • Step 5: Install SessionMiddleware and include routes
# app/main.py additions
from starlette.middleware.sessions import SessionMiddleware
from app.web.routes import router as web_router

app.add_middleware(
    SessionMiddleware,
    secret_key=resolved.secret_key,
    same_site="lax",
    https_only=False,
)
app.include_router(web_router)
  • Step 6: Add the concrete FastAPI test client fixture and run auth tests
# tests/conftest.py additions
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient

from app.config import Settings
from app.main import create_app


@pytest.fixture
def client(tmp_path: Path) -> TestClient:
    settings = Settings(
        ADMIN_PASSWORD="admin-secret",
        SECRET_KEY="0123456789abcdef0123456789abcdef",
        CREDENTIAL_ENCRYPTION_KEY=Fernet.generate_key().decode("ascii"),
        DATA_DIR=str(tmp_path),
        DATABASE_URL=f"sqlite:///{tmp_path / 'app.db'}",
        SYNC_INTERVAL_MINUTES=5,
    )
    return TestClient(create_app(settings))

Run: pytest tests/web/test_auth.py -v

Expected: PASS.

  • Step 7: Commit
git add app/auth app/web app/main.py tests/web/test_auth.py tests/conftest.py
git commit -m "feat: add local admin authentication"

Task 5: Add user CRUD without exposing stored secrets

Files:

  • Create: app/web/forms.py
  • Create: app/web/templates/users/form.html
  • Create: app/web/templates/users/detail.html
  • Modify: app/web/routes.py
  • Modify: app/db/repositories.py
  • Create: tests/web/test_users.py

Interfaces:

  • Produces routes: GET /users/new, POST /users, GET /users/{id}, GET /users/{id}/edit, POST /users/{id}.

  • Empty password fields on edit preserve existing encrypted passwords.

  • Templates receive only non-secret fields.

  • Step 1: Write failing user CRUD tests

# tests/web/test_users.py
from fastapi.testclient import TestClient


def login(client: TestClient) -> None:
    page = client.get("/login")
    csrf = extract_csrf(page.text)
    response = client.post(
        "/login",
        data={"password": "admin-secret", "csrf_token": csrf},
        follow_redirects=False,
    )
    assert response.status_code == 303


def extract_csrf(html: str) -> str:
    marker = 'name="csrf_token" value="'
    start = html.index(marker) + len(marker)
    end = html.index('"', start)
    return html[start:end]


def test_create_user_encrypts_credentials_and_never_renders_them(client: TestClient) -> None:
    login(client)
    page = client.get("/users/new")
    assert page.status_code == 200
    csrf = extract_csrf(page.text)
    response = client.post(
        "/users",
        data={
            "csrf_token": csrf,
            "name": "Max",
            "mywhoosh_email": "max@example.com",
            "mywhoosh_password": "mw-secret",
            "garmin_email": "max-garmin@example.com",
            "garmin_password": "garmin-secret",
            "enabled": "on",
        },
        follow_redirects=True,
    )
    assert response.status_code == 200
    assert "mw-secret" not in response.text
    assert "garmin-secret" not in response.text
  • Step 2: Run the test and verify failure

Run: pytest tests/web/test_users.py -v

Expected: missing routes/form support.

  • Step 3: Extend repository update methods
# app/db/repositories.py additions
    def list_all(self) -> list[SyncUser]:
        return list(self.session.scalars(select(SyncUser).order_by(SyncUser.name)))

    def update(self, user: SyncUser, **values) -> SyncUser:
        for key, value in values.items():
            setattr(user, key, value)
        self.session.commit()
        return user
  • Step 4: Implement create/edit request handling with encrypted fields
# app/web/forms.py
from dataclasses import dataclass


@dataclass(frozen=True)
class UserFormData:
    name: str
    mywhoosh_email: str
    mywhoosh_password: str
    garmin_email: str
    garmin_password: str
    enabled: bool

In app/web/routes.py, construct CredentialCipher(request.app.state.settings.credential_encryption_key) and encrypt all four service values before repository writes. On edit, only replace an encrypted password if the submitted password is non-empty. Decrypt emails for display; never decrypt passwords for a template.

Use the exact update payload shape:

values = {
    "name": form.name.strip(),
    "enabled": form.enabled,
    "mywhoosh_email_enc": cipher.encrypt(form.mywhoosh_email.strip()),
    "garmin_email_enc": cipher.encrypt(form.garmin_email.strip()),
}
if form.mywhoosh_password:
    values["mywhoosh_password_enc"] = cipher.encrypt(form.mywhoosh_password)
if form.garmin_password:
    values["garmin_password_enc"] = cipher.encrypt(form.garmin_password)

Every POST route must call validate_csrf(request, csrf_token) before changing state.

  • Step 5: Replace the hard-coded dashboard user list with repository data
with request.app.state.session_factory() as session:
    users = UserRepository(session).list_all()
return templates.TemplateResponse(
    request,
    "dashboard.html",
    {"users": users, "csrf_token": ensure_csrf_token(request)},
)
  • Step 6: Run web tests

Run: pytest tests/web/test_users.py tests/web/test_auth.py -v

Expected: PASS, including explicit assertion that passwords never occur in response HTML.

  • Step 7: Commit
git add app/db/repositories.py app/web tests/web/test_users.py
git commit -m "feat: add encrypted sync user management"

Task 6: Package the foundation as a local Docker service

Files:

  • Create: Dockerfile
  • Create: docker-compose.example.yml
  • Create: .env.example
  • Modify: app/main.py

Interfaces:

  • Produces a container exposing FastAPI on port 8080 and persisting /data.

  • Step 1: Add deterministic startup command

# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml /app/
RUN pip install --no-cache-dir .
COPY app /app/app
RUN mkdir -p /data && chmod 700 /data
ENV DATA_DIR=/data
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
  • Step 2: Add example environment and compose file
# .env.example
ADMIN_PASSWORD=change-me
SECRET_KEY=replace-with-at-least-16-random-characters
CREDENTIAL_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
SYNC_INTERVAL_MINUTES=5
DATA_DIR=/data
DATABASE_URL=sqlite:////data/app.db
# docker-compose.example.yml
services:
  sync:
    build: .
    env_file: .env
    ports:
      - "8080:8080"
    volumes:
      - ./data:/data
    restart: unless-stopped
  • Step 3: Build the image

Run: docker build -t mywhoosh-garmin-sync:test .

Expected: successful image build.

  • Step 4: Run a container smoke test

Run with a real generated Fernet key and test-only secrets:

docker run --rm -d --name mywhoosh-garmin-sync-test \
  -p 18080:8080 \
  -e ADMIN_PASSWORD=admin-secret \
  -e SECRET_KEY=0123456789abcdef0123456789abcdef \
  -e CREDENTIAL_ENCRYPTION_KEY="$(python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')" \
  -v "$PWD/.tmp-data:/data" \
  mywhoosh-garmin-sync:test

Run: curl -fsS http://127.0.0.1:18080/healthz

Expected: {"status":"ok"}.

Then run: docker stop mywhoosh-garmin-sync-test

  • Step 5: Run the foundation regression suite

Run: pytest tests/test_config.py tests/db tests/security tests/web -v

Expected: PASS.

  • Step 6: Commit
git add Dockerfile docker-compose.example.yml .env.example app/main.py
git commit -m "build: package local admin service"