fix: address final review findings for foundation plan

- C1: drop module-level app singleton in app/main.py so importing the
  package no longer validates Settings or creates DATA_DIR; run uvicorn
  with --factory in the Dockerfile. pytest now collects and passes with
  no ambient env vars.
- I2: add missing app/auth, app/security, app/web __init__.py so
  setuptools discovers all five packages.
- I3: resolve the Jinja2 template directory relative to __file__ instead
  of the process CWD.
- I4: add .gitignore covering .env, data/, .venv/, caches and build
  artifacts so example deployment secrets cannot be committed.
- I5: assert UserRepository.list_enabled() excludes disabled users.
- M6: encode both operands before hmac.compare_digest in validate_csrf so
  a non-ASCII token yields 403 instead of an unhandled 500.
- M9: remove unused relationship / HealthState imports.
- M11: make session cookie https_only configurable via SESSION_HTTPS_ONLY
  (default unchanged: false).
- M13: dispose SQLAlchemy engines in the db_session and client fixtures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-15 10:14:26 +02:00
parent 5931a57f82
commit 49aba8efb4
15 changed files with 70 additions and 12 deletions

0
app/auth/__init__.py Normal file
View File

View File

@@ -14,5 +14,7 @@ def ensure_csrf_token(request: Request) -> str:
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):
if not isinstance(expected, str) or not hmac.compare_digest(
expected.encode("utf-8"), submitted_token.encode("utf-8")
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token")

View File

@@ -14,6 +14,7 @@ class Settings(BaseSettings):
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")
session_https_only: bool = Field(default=False, alias="SESSION_HTTPS_ONLY")
@model_validator(mode="after")
def derive_paths(self) -> "Settings":

View File

@@ -4,7 +4,7 @@ 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
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
def utcnow() -> datetime:

View File

@@ -4,7 +4,7 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.db.models import Activity, ActivityStatus, HealthState, SyncUser
from app.db.models import Activity, ActivityStatus, SyncUser
class UserRepository:

View File

@@ -24,7 +24,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
SessionMiddleware,
secret_key=resolved.secret_key,
same_site="lax",
https_only=False,
https_only=resolved.session_https_only,
)
app.include_router(web_router)
@@ -33,6 +33,3 @@ def create_app(settings: Settings | None = None) -> FastAPI:
return {"status": "ok"}
return app
app = create_app()

0
app/security/__init__.py Normal file
View File

0
app/web/__init__.py Normal file
View File

View File

@@ -1,3 +1,5 @@
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
@@ -10,7 +12,7 @@ from app.security.credentials import CredentialCipher
from app.web.forms import UserFormData
router = APIRouter()
templates = Jinja2Templates(directory="app/web/templates")
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
def _get_user_or_404(repository: UserRepository, user_id: int) -> SyncUser: