shinkan-jinkendo/backend/tests/test_security_release.py

151 lines
4.6 KiB
Python
Raw Normal View History

"""
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