""" 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 def test_api_attachments_x_content_type_options_nosniff(client: TestClient) -> None: """Globales Middleware: keine MIME-Sniffing-Heuristik für API/Health.""" r = client.get("/health") assert r.status_code == 200 assert r.headers.get("x-content-type-options") == "nosniff" r2 = client.get("/api/version") assert r2.status_code == 200 assert r2.headers.get("x-content-type-options") == "nosniff" def test_public_media_static_not_mounted_by_default() -> None: """/media/-StaticFiles-Mount darf ohne ALLOW_PUBLIC_MEDIA_STATIC nicht aktiv sein.""" snippet = """ from main import app mounted = [getattr(r, 'path', '') for r in app.routes] assert not any(p == '/media' for p in mounted), ( "ALLOW_PUBLIC_MEDIA_STATIC aktiv – /media oeffentlich erreichbar. Vor Deploy entfernen." ) """ proc = _run_fresh_import_int(snippet, {"ENVIRONMENT": "production"}) assert proc.returncode == 0, proc.stderr + proc.stdout def test_allow_public_media_static_activates_media_mount() -> None: """Dokumentiert: ALLOW_PUBLIC_MEDIA_STATIC=1 aktiviert /media ohne Authentifizierung.""" snippet = """ from main import app mounted = [getattr(r, 'path', '') for r in app.routes] assert any(p == '/media' for p in mounted), "/media-Mount wurde nicht aktiviert" """ proc = _run_fresh_import_int(snippet, {"ALLOW_PUBLIC_MEDIA_STATIC": "1"}) assert proc.returncode == 0, proc.stderr + proc.stdout