feat: enhance API and profile management with environment configurations
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 23s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 24s
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 23s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 24s
- Added functions to determine production environment and OpenAPI exposure settings, improving API documentation control. - Updated FastAPI initialization to conditionally set OpenAPI and documentation URLs based on environment variables. - Refactored health check response to limit detail exposure in production environments, enhancing security. - Streamlined profile management by removing legacy ID retrieval and ensuring session-based profile access for security improvements.
This commit is contained in:
parent
18fa4de055
commit
c2d9eac151
|
|
@ -29,6 +29,12 @@ Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Aud
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Changelog (Fortführung)
|
||||||
|
|
||||||
|
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Hinweis `GET /training-units`
|
### Hinweis `GET /training-units`
|
||||||
|
|
||||||
Kein impliziter Filter nach `effective_club_id` (Multi-Verein-Kalender); bei Bedarf `club_id` Query setzen.
|
Kein impliziter Filter nach `effective_club_id` (Multi-Verein-Kalender); bei Bedarf `club_id` Query setzen.
|
||||||
|
|
|
||||||
82
.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md
Normal file
82
.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# Produktionsreife: Audit-Ergebnis & Umsetzungsplan (Stand 2026-05-07)
|
||||||
|
|
||||||
|
**Zweck:** Einheitliche Referenz gegen **Drift** zwischen Sicherheits-/Betriebsanforderungen und Code.
|
||||||
|
**Bezug:** Zugriffsschicht `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, Cursor-Regel `.cursor/rules/access-layer.mdc`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil A — Audit-Ergebnis (Kurzfassung)
|
||||||
|
|
||||||
|
### A.1 Kritisch / Hoch (behoben oder geplant)
|
||||||
|
|
||||||
|
| ID | Befund | Status (2026-05-07) |
|
||||||
|
|----|--------|----------------------|
|
||||||
|
| SEC-01 | `GET /api/profile` (Legacy): ohne Header wurde das **erste Profil der DB** geliefert → IDOR / Datenleck | **Behoben:** Profil immer aus Session |
|
||||||
|
| SEC-02 | OpenAPI `/docs`, `/redoc`, `/openapi.json` in Produktion exponiert | **Behoben:** bei `ENVIRONMENT=production` aus; Override `PUBLIC_OPENAPI=1` |
|
||||||
|
| SEC-03 | `/api/health/ready` mit Tabellendetails / Migrationszähler öffentlich | **Behoben:** in Prod nur kompakte Antwort; Detail via `HEALTH_READY_PUBLIC_DETAIL=1` |
|
||||||
|
| SEC-04 | Statische Auslieferung `/media` ohne Auth (Link-Leak) | **Geplant** (Phase 2): geschützter Download oder signierte URLs |
|
||||||
|
| OPS-01 | PostgreSQL-Hostport in `docker-compose.yml` | **Dokumentiert:** nur intern binden / Firewall |
|
||||||
|
| OPS-02 | Fehlende Security-Header (CSP, X-Frame-Options, …) | **Teilweise:** nginx-Basis-Header ergänzt |
|
||||||
|
|
||||||
|
### A.2 Mittel / Konsistenz
|
||||||
|
|
||||||
|
| ID | Befund | Status |
|
||||||
|
|----|--------|--------|
|
||||||
|
| CON-01 | Frontend: direktes `fetch('/api/profiles')` ohne zentralen Client | **Behoben** (AdminCatalogsPage → `api.listProfiles`) |
|
||||||
|
| CON-02 | `ProfileContext.jsx` (unused): falsches Token-Feld / `session` | **Behoben** (`user` + `getCurrentProfile` / `listProfiles`) |
|
||||||
|
| CON-03 | Sparten (`division`) vs. Zielbild ACCESS_LAYER | **Offen** (Roadmap Stufe D) |
|
||||||
|
|
||||||
|
### A.3 Niedrig
|
||||||
|
|
||||||
|
| ID | Befund | Status |
|
||||||
|
|----|--------|--------|
|
||||||
|
| MISC-01 | `auth.py` Debug-`print` beim Import | **Behoben** (entfernt) |
|
||||||
|
| MISC-02 | `delete_profile`: DELETE auf Mitai-Tabellen schlägt fehl, wenn Tabelle fehlt | **Behoben:** nur löschen, wenn Tabelle existiert |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil B — Priorisierter Umsetzungsplan
|
||||||
|
|
||||||
|
### Phase 1 — Sofort (Beta-Blocker) ✅ umgesetzt in Repo-Stand
|
||||||
|
|
||||||
|
1. Legacy-Endpunkte `/api/profile` GET/PUT: **nur Session-`profile_id`**, kein „erstes Profil“-Fallback.
|
||||||
|
2. OpenAPI in Produktion deaktivieren; opt-in für Notfall-Debugging.
|
||||||
|
3. Readiness-Endpoint in Prod entschärfen; operatives Detail per Env.
|
||||||
|
4. Automatisierte **Security-Release-Tests** (`tests/test_security_release.py`) + Script `scripts/security_release_checks.py` in CI.
|
||||||
|
5. Profil-Löschung: optionale Tabellen nur bei Existenz.
|
||||||
|
6. Frontend: Admin-Kataloge Profilliste über `api.js`.
|
||||||
|
|
||||||
|
### Phase 2 — Kurzfristig (vor breitem Go-Live)
|
||||||
|
|
||||||
|
1. **Medien:** Zugriff an Übungs-/Governance-Rechte koppeln (kein anonymes `/media/...` für sensible Objekte).
|
||||||
|
2. **Mitgliederverzeichnis:** E-Mail-Sichtbarkeit rollenbasierend oder Richtlinie/DSE.
|
||||||
|
3. **DB-Port:** Prod-Compose ohne öffentliche DB-Port-Publikation oder strikte Bindung.
|
||||||
|
4. `GET /api/maturity-models/{id}`: Leserechte mit Admin/Matrix-Politik abstimmen.
|
||||||
|
|
||||||
|
### Phase 3 — Mittelfristig
|
||||||
|
|
||||||
|
1. CSP und restliche Header fein abstimmen (PWA, eingebettete Medien).
|
||||||
|
2. `ACCESS_LAYER_ENDPOINT_AUDIT.md` bei Änderungen an Tenant/Governance aktualisieren (Arbeitsdisziplin).
|
||||||
|
3. Division-/Sparten-Durchsetzung laut ARCHITEKTUR-Roadmap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil C — CI / Qualitätssicherung
|
||||||
|
|
||||||
|
- **ACCESS_LAYER_STRICT:** `scripts/check_access_layer_hints.py` (unverändert Pflicht).
|
||||||
|
- **Security-Release:** `python scripts/security_release_checks.py` (pytest-Modul `test_security_release`).
|
||||||
|
- **Regression Profil:** `tests/test_profiles_read_access.py` (Legacy-Route).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil D — Umgebungsvariablen (Referenz)
|
||||||
|
|
||||||
|
| Variable | Bedeutung |
|
||||||
|
|----------|-----------|
|
||||||
|
| `ENVIRONMENT` | `production` / `prod` → OpenAPI aus, Health-Ready kompakt |
|
||||||
|
| `PUBLIC_OPENAPI` | `1` / `true` → OpenAPI trotz Prod einschalten (nur Debugging) |
|
||||||
|
| `HEALTH_READY_PUBLIC_DETAIL` | `1` / `true` → volle Ready-JSON inkl. Tabellenliste in Prod |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Pflege:** Nach jeder relevanten Änderung **Status-Spalte** und ggf. **Phase** aktualisieren; bei neuem Audit Datum im Titel anpassen oder Abschnitt „Changelog“ ergänzen.
|
||||||
|
|
@ -38,6 +38,7 @@ jobs:
|
||||||
pip install -r /app/requirements-dev.txt &&
|
pip install -r /app/requirements-dev.txt &&
|
||||||
cd /app &&
|
cd /app &&
|
||||||
ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py &&
|
ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py &&
|
||||||
|
python scripts/security_release_checks.py &&
|
||||||
ACCESS_LAYER_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
|
ACCESS_LAYER_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
|
||||||
"
|
"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,10 @@ ALLOWED_ORIGINS=https://shinkan.jinkendo.de
|
||||||
MEDIA_DIR=/app/media
|
MEDIA_DIR=/app/media
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Produktions-/Sicherheitsaudit (Drift vermeiden)
|
||||||
|
|
||||||
|
Aktuelle Befunde und Umsetzungsstände: `.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md` (Fortlaufend pflegen.)
|
||||||
|
|
||||||
## Kritische Regeln für Claude Code
|
## Kritische Regeln für Claude Code
|
||||||
|
|
||||||
### Must-Do:
|
### Must-Do:
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ import bcrypt
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
from db import get_db, get_cursor
|
||||||
|
|
||||||
print("[AUTH.PY] Module loaded - require_auth_flexible will be defined")
|
|
||||||
|
|
||||||
|
|
||||||
def hash_pin(pin: str) -> str:
|
def hash_pin(pin: str) -> str:
|
||||||
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""
|
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,20 @@ from slowapi.errors import RateLimitExceeded
|
||||||
|
|
||||||
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
|
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def _is_production_environment() -> bool:
|
||||||
|
return os.getenv("ENVIRONMENT", "development").strip().lower() in ("production", "prod")
|
||||||
|
|
||||||
|
|
||||||
|
def _public_openapi_enabled() -> bool:
|
||||||
|
return os.getenv("PUBLIC_OPENAPI", "").strip().lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _health_ready_public_detail_enabled() -> bool:
|
||||||
|
"""In Prod standardmäßig keine Tabellen-/Migrations-Details (Information Disclosure)."""
|
||||||
|
return os.getenv("HEALTH_READY_PUBLIC_DETAIL", "").strip().lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
# Run database migrations before API start — halbes Schema ist schlimmer als kein Start
|
# Run database migrations before API start — halbes Schema ist schlimmer als kein Start
|
||||||
# Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1
|
# Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1
|
||||||
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
|
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
|
||||||
|
|
@ -39,11 +53,20 @@ else:
|
||||||
|
|
||||||
from routers.auth import limiter as auth_rate_limiter
|
from routers.auth import limiter as auth_rate_limiter
|
||||||
|
|
||||||
|
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1
|
||||||
|
_expose_docs = (not _is_production_environment()) or _public_openapi_enabled()
|
||||||
|
_openapi_url = "/openapi.json" if _expose_docs else None
|
||||||
|
_docs_url = "/docs" if _expose_docs else None
|
||||||
|
_redoc_url = "/redoc" if _expose_docs else None
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Shinkan Jinkendo API",
|
title="Shinkan Jinkendo API",
|
||||||
description="Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung",
|
description="Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung",
|
||||||
version=APP_VERSION
|
version=APP_VERSION,
|
||||||
|
openapi_url=_openapi_url,
|
||||||
|
docs_url=_docs_url,
|
||||||
|
redoc_url=_redoc_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
# SlowAPI: Rate Limits auf /api/auth/* (Decorator in routers/auth.py)
|
# SlowAPI: Rate Limits auf /api/auth/* (Decorator in routers/auth.py)
|
||||||
|
|
@ -132,7 +155,7 @@ def health_ready():
|
||||||
migration_count = 0
|
migration_count = 0
|
||||||
|
|
||||||
complete = bool(err is None and all(tables.get(t) for t in REQUIRED))
|
complete = bool(err is None and all(tables.get(t) for t in REQUIRED))
|
||||||
return {
|
body = {
|
||||||
"status": "ready" if complete else "degraded",
|
"status": "ready" if complete else "degraded",
|
||||||
"database": err is None,
|
"database": err is None,
|
||||||
"detail": err,
|
"detail": err,
|
||||||
|
|
@ -140,18 +163,27 @@ def health_ready():
|
||||||
"tables": tables,
|
"tables": tables,
|
||||||
"schema_migrations_count": migration_count,
|
"schema_migrations_count": migration_count,
|
||||||
}
|
}
|
||||||
|
if _is_production_environment() and not _health_ready_public_detail_enabled():
|
||||||
|
return {
|
||||||
|
"status": body["status"],
|
||||||
|
"database": body["database"],
|
||||||
|
"schema_complete": body["schema_complete"],
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
# Root Endpoint
|
# Root Endpoint
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
"""Root endpoint - API info"""
|
"""Root endpoint - API info"""
|
||||||
return {
|
out = {
|
||||||
"app": "Shinkan Jinkendo API",
|
"app": "Shinkan Jinkendo API",
|
||||||
"version": APP_VERSION,
|
"version": APP_VERSION,
|
||||||
"docs": "/docs",
|
"health": "/health",
|
||||||
"health": "/health"
|
|
||||||
}
|
}
|
||||||
|
if _expose_docs:
|
||||||
|
out["docs"] = "/docs"
|
||||||
|
return out
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
|
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
|
||||||
|
|
|
||||||
|
|
@ -22,19 +22,6 @@ router = APIRouter(prefix="/api", tags=["profiles"])
|
||||||
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
|
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
|
||||||
|
|
||||||
|
|
||||||
# ── Helper ────────────────────────────────────────────────────────────────────
|
|
||||||
def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str:
|
|
||||||
"""Get profile_id - from header for legacy endpoints."""
|
|
||||||
if x_profile_id:
|
|
||||||
return x_profile_id
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1")
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row: return row['id']
|
|
||||||
raise HTTPException(400, "Kein Profil gefunden")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Current User Profile ──────────────────────────────────────────────────────
|
# ── Current User Profile ──────────────────────────────────────────────────────
|
||||||
@router.get("/profiles/me")
|
@router.get("/profiles/me")
|
||||||
def get_current_profile(
|
def get_current_profile(
|
||||||
|
|
@ -310,26 +297,45 @@ def delete_profile(pid: str, session=Depends(require_auth)):
|
||||||
cur.execute("SELECT COUNT(*) as count FROM profiles")
|
cur.execute("SELECT COUNT(*) as count FROM profiles")
|
||||||
count = cur.fetchone()['count']
|
count = cur.fetchone()['count']
|
||||||
if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden")
|
if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden")
|
||||||
for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']:
|
# Mitai-Überbleibsel: nur löschen, wenn die Tabelle im Schema existiert (Shinkan-DB ohne diese Tabellen).
|
||||||
cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
|
_optional_mitai_tables = (
|
||||||
|
"weight_log",
|
||||||
|
"circumference_log",
|
||||||
|
"caliper_log",
|
||||||
|
"nutrition_log",
|
||||||
|
"activity_log",
|
||||||
|
"ai_insights",
|
||||||
|
)
|
||||||
|
for table in _optional_mitai_tables:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = %s
|
||||||
|
) AS t_exists
|
||||||
|
""",
|
||||||
|
(table,),
|
||||||
|
)
|
||||||
|
ex = cur.fetchone()
|
||||||
|
if ex and next(iter(ex.values())):
|
||||||
|
cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
|
||||||
cur.execute("DELETE FROM profiles WHERE id=%s", (pid,))
|
cur.execute("DELETE FROM profiles WHERE id=%s", (pid,))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
# ── Current User Profile ──────────────────────────────────────────────────────
|
# ── Current User Profile ──────────────────────────────────────────────────────
|
||||||
@router.get("/profile")
|
@router.get("/profile")
|
||||||
def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
|
def get_active_profile(session: dict = Depends(require_auth)):
|
||||||
"""Legacy endpoint – returns active profile."""
|
"""Legacy-Alias für das eingeloggte Profil — immer Session, kein X-Profile-Id (SECURITY: kein IDOR)."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = str(session["profile_id"])
|
||||||
return profile_document(pid)
|
return profile_document(pid)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/profile")
|
@router.put("/profile")
|
||||||
def update_active_profile(
|
def update_active_profile(
|
||||||
p: ProfileUpdate,
|
p: ProfileUpdate,
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
tenant: TenantContext = Depends(get_tenant_context),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
"""Update current user's profile."""
|
"""Profil des eingeloggten Nutzers aktualisieren — dieselbe Quelle wie GET /profile."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = str(tenant.profile_id)
|
||||||
return _run_profile_update(pid, p, tenant)
|
return _run_profile_update(pid, p, tenant)
|
||||||
|
|
|
||||||
32
backend/scripts/security_release_checks.py
Normal file
32
backend/scripts/security_release_checks.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
CI-Sicherheitschecks: schlanke pytest-Sammlung ohne Integrations-DB.
|
||||||
|
|
||||||
|
Repo-Root ist egal — arbeitet relativ zu diesem Script (backend/).
|
||||||
|
|
||||||
|
Usage (aus backend/):
|
||||||
|
python scripts/security_release_checks.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
test_file = root / "tests" / "test_security_release.py"
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
str(test_file),
|
||||||
|
"-v",
|
||||||
|
"--tb=short",
|
||||||
|
]
|
||||||
|
return subprocess.run(cmd, cwd=str(root)).returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
116
backend/tests/test_security_release.py
Normal file
116
backend/tests/test_security_release.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""
|
||||||
|
Sicherheits-Regression für Release-Konfiguration (OpenAPI, Health-Ready, Legacy-Routen).
|
||||||
|
|
||||||
|
Läuft ohne PostgreSQL, wo möglich (TestClient + Subprocess mit frischem Import).
|
||||||
|
CI: scripts/security_release_checks.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||||
|
|
||||||
|
from auth import require_auth
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_auth_override():
|
||||||
|
yield
|
||||||
|
app.dependency_overrides.pop(require_auth, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_get_profile_uses_session_only(client: TestClient) -> None:
|
||||||
|
"""SEC-01: /api/profile darf nur das Session-Profil liefern."""
|
||||||
|
|
||||||
|
def auth_user() -> dict:
|
||||||
|
return {"profile_id": 901, "role": "trainer"}
|
||||||
|
|
||||||
|
app.dependency_overrides[require_auth] = auth_user
|
||||||
|
with patch("routers.profiles.profile_document") as pd_mock:
|
||||||
|
pd_mock.return_value = {"id": 901, "name": "T"}
|
||||||
|
r = client.get("/api/profile", headers={"X-Auth-Token": "dummy"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
pd_mock.assert_called_once_with("901")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_fresh_import_int(code: str, env: dict) -> subprocess.CompletedProcess:
|
||||||
|
merged = {**os.environ, **env, "SKIP_DB_MIGRATE": "1"}
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-c", code],
|
||||||
|
cwd=str(BACKEND_ROOT),
|
||||||
|
env=merged,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"extra_env, expect_openapi",
|
||||||
|
[
|
||||||
|
({"ENVIRONMENT": "development"}, True),
|
||||||
|
({"ENVIRONMENT": "production"}, False),
|
||||||
|
({"ENVIRONMENT": "production", "PUBLIC_OPENAPI": "true"}, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_openapi_urls_match_environment(extra_env: dict, expect_openapi: bool) -> None:
|
||||||
|
snippet = f"""
|
||||||
|
from main import app
|
||||||
|
assert (app.openapi_url is not None) == {expect_openapi}
|
||||||
|
assert (app.docs_url is not None) == {expect_openapi}
|
||||||
|
"""
|
||||||
|
proc = _run_fresh_import_int(snippet, extra_env)
|
||||||
|
assert proc.returncode == 0, proc.stderr + proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_ready_minimal_body_in_production() -> None:
|
||||||
|
snippet = """
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from main import app
|
||||||
|
c = TestClient(app)
|
||||||
|
r = c.get("/api/health/ready")
|
||||||
|
r.raise_for_status()
|
||||||
|
j = r.json()
|
||||||
|
assert "tables" not in j
|
||||||
|
assert "detail" not in j
|
||||||
|
assert "schema_migrations_count" not in j
|
||||||
|
assert "status" in j and "database" in j and "schema_complete" in j
|
||||||
|
"""
|
||||||
|
proc = _run_fresh_import_int(
|
||||||
|
snippet,
|
||||||
|
{"ENVIRONMENT": "production"},
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr + proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_ready_full_detail_when_flag_in_production() -> None:
|
||||||
|
snippet = """
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from main import app
|
||||||
|
c = TestClient(app)
|
||||||
|
r = c.get("/api/health/ready")
|
||||||
|
r.raise_for_status()
|
||||||
|
j = r.json()
|
||||||
|
assert "tables" in j
|
||||||
|
assert "schema_migrations_count" in j
|
||||||
|
"""
|
||||||
|
proc = _run_fresh_import_int(
|
||||||
|
snippet,
|
||||||
|
{"ENVIRONMENT": "production", "HEALTH_READY_PUBLIC_DETAIL": "1"},
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr + proc.stdout
|
||||||
|
|
@ -4,6 +4,11 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
# Docker-Embedded DNS: Hostname »backend« bei Container-Neustarts neu auflösen
|
# Docker-Embedded DNS: Hostname »backend« bei Container-Neustarts neu auflösen
|
||||||
# — verringert sporadische 502, wenn sich nur die Backend-Container-IP geändert hat.
|
# — verringert sporadische 502, wenn sich nur die Backend-Container-IP geändert hat.
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,70 @@
|
||||||
import { createContext, useContext, useState, useEffect } from 'react'
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
import { useAuth } from './AuthContext'
|
import { useAuth } from './AuthContext'
|
||||||
|
import { getCurrentProfile, listProfiles } from '../utils/api'
|
||||||
|
|
||||||
const ProfileContext = createContext(null)
|
const ProfileContext = createContext(null)
|
||||||
|
|
||||||
export function ProfileProvider({ children }) {
|
export function ProfileProvider({ children }) {
|
||||||
const { session } = useAuth()
|
const { user, isAuthenticated } = useAuth()
|
||||||
const [profiles, setProfiles] = useState([])
|
const [profiles, setProfiles] = useState([])
|
||||||
const [activeProfile, setActiveProfileState] = useState(null)
|
const [activeProfile, setActiveProfileState] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const loadProfiles = async () => {
|
const loadProfiles = async (authUser) => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('bodytrack_token') || ''
|
if (!authUser?.id) return []
|
||||||
const res = await fetch('/api/profiles', {
|
const admin = authUser.role === 'admin' || authUser.role === 'superadmin'
|
||||||
headers: { 'X-Auth-Token': token }
|
if (admin) {
|
||||||
})
|
try {
|
||||||
if (!res.ok) return []
|
return await listProfiles()
|
||||||
return await res.json()
|
} catch {
|
||||||
} catch(e) { return [] }
|
const me = await getCurrentProfile()
|
||||||
|
return me ? [me] : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const me = await getCurrentProfile()
|
||||||
|
return me ? [me] : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-load whenever session changes (login/logout/switch)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) {
|
if (!isAuthenticated || !user?.id) {
|
||||||
setActiveProfileState(null)
|
setActiveProfileState(null)
|
||||||
setProfiles([])
|
setProfiles([])
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
loadProfiles().then(data => {
|
loadProfiles(user).then((data) => {
|
||||||
setProfiles(data)
|
const rows = Array.isArray(data) ? data : []
|
||||||
// Always use the profile_id from the session token – not localStorage
|
setProfiles(rows)
|
||||||
const match = data.find(p => p.id === session.profile_id)
|
const uid = user.id
|
||||||
setActiveProfileState(match || data[0] || null)
|
const match = rows.find((p) => String(p.id) === String(uid))
|
||||||
|
setActiveProfileState(match || rows[0] || null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
}, [session?.profile_id]) // re-runs when profile changes
|
}, [isAuthenticated, user?.id, user?.role])
|
||||||
|
|
||||||
const setActiveProfile = (profile) => {
|
const setActiveProfile = (profile) => {
|
||||||
setActiveProfileState(profile)
|
setActiveProfileState(profile)
|
||||||
localStorage.setItem('bodytrack_active_profile', profile.id)
|
localStorage.setItem('shinkan_active_profile', String(profile.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshProfiles = () => loadProfiles().then(data => {
|
const refreshProfiles = () =>
|
||||||
setProfiles(data)
|
loadProfiles(user).then((data) => {
|
||||||
if (activeProfile) {
|
setProfiles(Array.isArray(data) ? data : [])
|
||||||
const updated = data.find(p => p.id === activeProfile.id)
|
if (activeProfile) {
|
||||||
if (updated) setActiveProfileState(updated)
|
const updated = data.find((p) => String(p.id) === String(activeProfile.id))
|
||||||
}
|
if (updated) setActiveProfileState(updated)
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProfileContext.Provider value={{ profiles, activeProfile, setActiveProfile, refreshProfiles, loading }}>
|
<ProfileContext.Provider
|
||||||
|
value={{ profiles, activeProfile, setActiveProfile, refreshProfiles, loading }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ProfileContext.Provider>
|
</ProfileContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ export default function AdminCatalogsPage() {
|
||||||
} else if (activeTab === 'trainer-assignments') {
|
} else if (activeTab === 'trainer-assignments') {
|
||||||
const [assignments, profs, areas] = await Promise.all([
|
const [assignments, profs, areas] = await Promise.all([
|
||||||
api.listTrainerFocusAreas(),
|
api.listTrainerFocusAreas(),
|
||||||
fetch('/api/profiles').then(r => r.json()),
|
api.listProfiles(),
|
||||||
api.listFocusAreas()
|
api.listFocusAreas()
|
||||||
])
|
])
|
||||||
setTrainerAssignments(assignments)
|
setTrainerAssignments(assignments)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user