test: 467 -> 574 Tests (+107) — DDD, abgeordnetenwatch, monitoring, v2, Bug-Regressions
Neue Tests in dieser Migration:
- test_database.py (Merkliste-CRUD, Subscriptions, abgeordnetenwatch-Joins)
- test_clustering.py (82% Coverage)
- test_drucksache_typen.py (100%)
- test_mail.py (86%)
- test_monitoring.py (23 Tests)
- test_abgeordnetenwatch.py (23 Tests, inkl. Drucksache-Extraction)
- test_redline_parser.py (20 Tests fuer §INS§/§DEL§-Marker)
- test_bug_regressions.py (PRAGMA, JWT-azp, CDU-PDF, PFLICHT-FRAKTIONEN, NRW-Titel)
- test_embeddings_v3_v4.py (WRITE/READ-Pattern)
- test_wahlprogramm_check.py (#128)
- test_wahlprogramm_fetch.py (#138)
- test_antrag/bewertung/abonnement_repository.py + test_llm_bewerter.py (DDD)
- test_domain_behavior.py (5 Domain-Methoden boundary tests)
- tests/e2e/test_ui.py (Playwright)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
|
|
|
"""Tests for app/auth.py — Keycloak JWT authentication (#43, #129).
|
2026-04-10 16:25:51 +02:00
|
|
|
|
|
|
|
|
These tests cover the auth module WITHOUT a running Keycloak server:
|
|
|
|
|
- Token extraction from headers/cookies
|
|
|
|
|
- Auth-disabled detection (Dev-Modus)
|
test: 467 -> 574 Tests (+107) — DDD, abgeordnetenwatch, monitoring, v2, Bug-Regressions
Neue Tests in dieser Migration:
- test_database.py (Merkliste-CRUD, Subscriptions, abgeordnetenwatch-Joins)
- test_clustering.py (82% Coverage)
- test_drucksache_typen.py (100%)
- test_mail.py (86%)
- test_monitoring.py (23 Tests)
- test_abgeordnetenwatch.py (23 Tests, inkl. Drucksache-Extraction)
- test_redline_parser.py (20 Tests fuer §INS§/§DEL§-Marker)
- test_bug_regressions.py (PRAGMA, JWT-azp, CDU-PDF, PFLICHT-FRAKTIONEN, NRW-Titel)
- test_embeddings_v3_v4.py (WRITE/READ-Pattern)
- test_wahlprogramm_check.py (#128)
- test_wahlprogramm_fetch.py (#138)
- test_antrag/bewertung/abonnement_repository.py + test_llm_bewerter.py (DDD)
- test_domain_behavior.py (5 Domain-Methoden boundary tests)
- tests/e2e/test_ui.py (Playwright)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
|
|
|
- direct_login — Keycloak Direct Access Grant (gemockt via httpx)
|
2026-04-10 16:25:51 +02:00
|
|
|
- _pick_best_title helper (in main.py, tested here for convenience)
|
|
|
|
|
"""
|
|
|
|
|
import sys
|
|
|
|
|
import types
|
|
|
|
|
|
|
|
|
|
# Stub jose if not installed locally
|
|
|
|
|
if "jose" not in sys.modules:
|
|
|
|
|
jose_stub = types.ModuleType("jose")
|
|
|
|
|
jose_jwt = types.ModuleType("jose.jwt")
|
|
|
|
|
jose_stub.jwt = jose_jwt
|
|
|
|
|
jose_stub.JWTError = Exception
|
|
|
|
|
jose_stub.ExpiredSignatureError = Exception
|
|
|
|
|
jose_jwt.get_unverified_header = lambda t: {"kid": "test"}
|
|
|
|
|
jose_jwt.decode = lambda *a, **kw: {"sub": "test", "email": "t@t.de"}
|
|
|
|
|
sys.modules["jose"] = jose_stub
|
|
|
|
|
sys.modules["jose.jwt"] = jose_jwt
|
|
|
|
|
|
|
|
|
|
import pytest
|
test: 467 -> 574 Tests (+107) — DDD, abgeordnetenwatch, monitoring, v2, Bug-Regressions
Neue Tests in dieser Migration:
- test_database.py (Merkliste-CRUD, Subscriptions, abgeordnetenwatch-Joins)
- test_clustering.py (82% Coverage)
- test_drucksache_typen.py (100%)
- test_mail.py (86%)
- test_monitoring.py (23 Tests)
- test_abgeordnetenwatch.py (23 Tests, inkl. Drucksache-Extraction)
- test_redline_parser.py (20 Tests fuer §INS§/§DEL§-Marker)
- test_bug_regressions.py (PRAGMA, JWT-azp, CDU-PDF, PFLICHT-FRAKTIONEN, NRW-Titel)
- test_embeddings_v3_v4.py (WRITE/READ-Pattern)
- test_wahlprogramm_check.py (#128)
- test_wahlprogramm_fetch.py (#138)
- test_antrag/bewertung/abonnement_repository.py + test_llm_bewerter.py (DDD)
- test_domain_behavior.py (5 Domain-Methoden boundary tests)
- tests/e2e/test_ui.py (Playwright)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
|
|
|
import asyncio
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
from fastapi import HTTPException
|
2026-04-10 16:25:51 +02:00
|
|
|
from app.auth import _extract_token, _is_auth_enabled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExtractToken:
|
|
|
|
|
def test_bearer_header(self):
|
|
|
|
|
req = MagicMock()
|
|
|
|
|
req.headers = {"authorization": "Bearer abc123"}
|
|
|
|
|
req.cookies = {}
|
|
|
|
|
assert _extract_token(req) == "abc123"
|
|
|
|
|
|
|
|
|
|
def test_cookie_fallback(self):
|
|
|
|
|
req = MagicMock()
|
|
|
|
|
req.headers = {}
|
|
|
|
|
req.cookies = {"access_token": "cookie_token"}
|
|
|
|
|
assert _extract_token(req) == "cookie_token"
|
|
|
|
|
|
|
|
|
|
def test_no_token(self):
|
|
|
|
|
req = MagicMock()
|
|
|
|
|
req.headers = {}
|
|
|
|
|
req.cookies = {}
|
|
|
|
|
assert _extract_token(req) is None
|
|
|
|
|
|
|
|
|
|
def test_non_bearer_header_ignored(self):
|
|
|
|
|
req = MagicMock()
|
|
|
|
|
req.headers = {"authorization": "Basic dXNlcjpwYXNz"}
|
|
|
|
|
req.cookies = {}
|
|
|
|
|
assert _extract_token(req) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestIsAuthEnabled:
|
|
|
|
|
def test_disabled_when_url_empty(self, monkeypatch):
|
|
|
|
|
from app import config
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_url", "")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_realm", "test")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_client_id", "test")
|
|
|
|
|
assert _is_auth_enabled() is False
|
|
|
|
|
|
|
|
|
|
def test_disabled_when_realm_empty(self, monkeypatch):
|
|
|
|
|
from app import config
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_url", "https://sso.test")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_realm", "")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_client_id", "test")
|
|
|
|
|
assert _is_auth_enabled() is False
|
|
|
|
|
|
|
|
|
|
def test_enabled_when_all_set(self, monkeypatch):
|
|
|
|
|
from app import config
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_url", "https://sso.test")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_realm", "realm")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_client_id", "client")
|
|
|
|
|
assert _is_auth_enabled() is True
|
|
|
|
|
|
|
|
|
|
|
test: 467 -> 574 Tests (+107) — DDD, abgeordnetenwatch, monitoring, v2, Bug-Regressions
Neue Tests in dieser Migration:
- test_database.py (Merkliste-CRUD, Subscriptions, abgeordnetenwatch-Joins)
- test_clustering.py (82% Coverage)
- test_drucksache_typen.py (100%)
- test_mail.py (86%)
- test_monitoring.py (23 Tests)
- test_abgeordnetenwatch.py (23 Tests, inkl. Drucksache-Extraction)
- test_redline_parser.py (20 Tests fuer §INS§/§DEL§-Marker)
- test_bug_regressions.py (PRAGMA, JWT-azp, CDU-PDF, PFLICHT-FRAKTIONEN, NRW-Titel)
- test_embeddings_v3_v4.py (WRITE/READ-Pattern)
- test_wahlprogramm_check.py (#128)
- test_wahlprogramm_fetch.py (#138)
- test_antrag/bewertung/abonnement_repository.py + test_llm_bewerter.py (DDD)
- test_domain_behavior.py (5 Domain-Methoden boundary tests)
- tests/e2e/test_ui.py (Playwright)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
|
|
|
class TestDirectLogin:
|
|
|
|
|
"""Tests für direct_login() in auth.py — Keycloak Direct Access Grant (#129).
|
|
|
|
|
|
|
|
|
|
Alle Keycloak-HTTP-Calls werden via unittest.mock.patch gemockt.
|
|
|
|
|
Kein laufender Keycloak-Server nötig.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _run(self, coro):
|
|
|
|
|
return asyncio.get_event_loop().run_until_complete(coro)
|
|
|
|
|
|
|
|
|
|
def _make_resp(self, status_code: int, body: dict):
|
|
|
|
|
resp = MagicMock()
|
|
|
|
|
resp.status_code = status_code
|
|
|
|
|
resp.json.return_value = body
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
def test_success_returns_token_data(self, monkeypatch):
|
|
|
|
|
"""Bei 200 von Keycloak gibt direct_login das Token-Dict zurück."""
|
|
|
|
|
from app import config
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_url", "https://sso.test")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_realm", "testrealm")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_client_id", "testclient")
|
|
|
|
|
|
|
|
|
|
token_response = {
|
|
|
|
|
"access_token": "eyABC",
|
|
|
|
|
"refresh_token": "ryDEF",
|
|
|
|
|
"expires_in": 300,
|
|
|
|
|
"refresh_expires_in": 1800,
|
|
|
|
|
}
|
|
|
|
|
mock_resp = self._make_resp(200, token_response)
|
|
|
|
|
|
|
|
|
|
async def _mock_post(*args, **kwargs):
|
|
|
|
|
return mock_resp
|
|
|
|
|
|
|
|
|
|
mock_client = AsyncMock()
|
|
|
|
|
mock_client.post = _mock_post
|
|
|
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
|
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
with patch("app.auth.httpx.AsyncClient", return_value=mock_client):
|
|
|
|
|
from app.auth import direct_login
|
|
|
|
|
result = self._run(direct_login("user", "pw"))
|
|
|
|
|
|
|
|
|
|
assert result["access_token"] == "eyABC"
|
|
|
|
|
assert result["refresh_token"] == "ryDEF"
|
|
|
|
|
assert result["expires_in"] == 300
|
|
|
|
|
|
|
|
|
|
def test_invalid_credentials_raises_401(self, monkeypatch):
|
|
|
|
|
"""Bei 401 von Keycloak wirft direct_login HTTPException(status_code=401)."""
|
|
|
|
|
from app import config
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_url", "https://sso.test")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_realm", "testrealm")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_client_id", "testclient")
|
|
|
|
|
|
|
|
|
|
mock_resp = self._make_resp(401, {"error": "invalid_grant", "error_description": "Ungültige Anmeldedaten"})
|
|
|
|
|
|
|
|
|
|
mock_client = AsyncMock()
|
|
|
|
|
mock_client.post = AsyncMock(return_value=mock_resp)
|
|
|
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
|
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
with patch("app.auth.httpx.AsyncClient", return_value=mock_client):
|
|
|
|
|
from app.auth import direct_login
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
self._run(direct_login("user", "falsch"))
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 401
|
|
|
|
|
assert "Ungültige Anmeldedaten" in exc_info.value.detail
|
|
|
|
|
|
|
|
|
|
def test_keycloak_error_raises_non_401(self, monkeypatch):
|
|
|
|
|
"""Bei 500 von Keycloak wirft direct_login HTTPException mit dem Keycloak-Statuscode."""
|
|
|
|
|
from app import config
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_url", "https://sso.test")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_realm", "testrealm")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_client_id", "testclient")
|
|
|
|
|
|
|
|
|
|
mock_resp = self._make_resp(500, {"error_description": "Internal Server Error"})
|
|
|
|
|
|
|
|
|
|
mock_client = AsyncMock()
|
|
|
|
|
mock_client.post = AsyncMock(return_value=mock_resp)
|
|
|
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
|
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
with patch("app.auth.httpx.AsyncClient", return_value=mock_client):
|
|
|
|
|
from app.auth import direct_login
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
self._run(direct_login("user", "pw"))
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 500
|
|
|
|
|
|
|
|
|
|
def test_auth_disabled_raises_400(self, monkeypatch):
|
|
|
|
|
"""Wenn Auth nicht konfiguriert ist, wirft direct_login HTTPException(400)."""
|
|
|
|
|
from app import config
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_url", "")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_realm", "")
|
|
|
|
|
monkeypatch.setattr(config.settings, "keycloak_client_id", "")
|
|
|
|
|
|
|
|
|
|
from app.auth import direct_login
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
self._run(direct_login("user", "pw"))
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
|
|
|
|
|
2026-04-10 16:25:51 +02:00
|
|
|
try:
|
|
|
|
|
from app.main import _pick_best_title
|
|
|
|
|
_HAS_MAIN = True
|
|
|
|
|
except ImportError:
|
|
|
|
|
_HAS_MAIN = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_MAIN, reason="app.main not importable (missing slowapi/etc)")
|
|
|
|
|
class TestPickBestTitle:
|
|
|
|
|
"""Test _pick_best_title from main.py."""
|
|
|
|
|
|
|
|
|
|
def test_prefer_real_doc_title(self):
|
|
|
|
|
assert _pick_best_title(
|
|
|
|
|
"LLM-Titel", "Echte Antrag-Bezeichnung aus OPAL", "18/123"
|
|
|
|
|
) == "Echte Antrag-Bezeichnung aus OPAL"
|
|
|
|
|
|
|
|
|
|
def test_reject_generic_doc_title(self):
|
|
|
|
|
from app.main import _pick_best_title
|
|
|
|
|
result = _pick_best_title(
|
|
|
|
|
"Lehrkräfte stärken", "Drucksache 18/18085", "18/18085"
|
|
|
|
|
)
|
|
|
|
|
assert result == "Lehrkräfte stärken"
|
|
|
|
|
|
|
|
|
|
def test_fallback_to_llm_title(self):
|
|
|
|
|
assert _pick_best_title("LLM-Titel", None, "18/123") == "LLM-Titel"
|
|
|
|
|
|
|
|
|
|
def test_fallback_to_generic(self):
|
|
|
|
|
assert _pick_best_title("", None, "18/123") == "Drucksache 18/123"
|
|
|
|
|
|
|
|
|
|
def test_empty_doc_title_uses_llm(self):
|
|
|
|
|
assert _pick_best_title("Guter LLM-Titel", "", "18/123") == "Guter LLM-Titel"
|