A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
320 lines
12 KiB
Python
320 lines
12 KiB
Python
"""
|
|
Tests for System API endpoints (local-ip, health, etc.)
|
|
|
|
Diese Tests pruefen:
|
|
1. /api/v1/system/local-ip - Gibt die lokale IP-Adresse zurueck
|
|
2. /health - Health check endpoint
|
|
|
|
Bug Prevention:
|
|
- QR-Code fuer mobile PDF-Upload benoetigte die lokale IP
|
|
- localhost:8086 funktioniert nicht auf iPhone (anderes Geraet)
|
|
- Diese Tests stellen sicher, dass die IP-Erkennung korrekt funktioniert
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
from fastapi.testclient import TestClient
|
|
import os
|
|
import re
|
|
|
|
|
|
class TestLocalIPEndpoint:
|
|
"""Tests fuer den /api/v1/system/local-ip Endpoint"""
|
|
|
|
@pytest.fixture
|
|
def client(self):
|
|
"""TestClient fuer die FastAPI App"""
|
|
# Import hier um Circular Imports zu vermeiden
|
|
import sys
|
|
sys.path.insert(0, '/app')
|
|
|
|
from main import app
|
|
|
|
# Mock os.getenv fuer LOCAL_NETWORK_IP - use yield to keep patch active
|
|
with patch.dict(os.environ, {'LOCAL_NETWORK_IP': '192.168.178.157'}):
|
|
yield TestClient(app)
|
|
|
|
def test_local_ip_returns_json(self, client):
|
|
"""Test: Endpoint gibt JSON zurueck"""
|
|
response = client.get("/api/v1/system/local-ip")
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"] == "application/json"
|
|
|
|
def test_local_ip_has_ip_field(self, client):
|
|
"""Test: Response enthaelt 'ip' Feld"""
|
|
response = client.get("/api/v1/system/local-ip")
|
|
data = response.json()
|
|
assert "ip" in data
|
|
assert data["ip"] is not None
|
|
|
|
def test_local_ip_is_valid_ipv4(self, client):
|
|
"""Test: IP ist ein gueltiges IPv4-Format"""
|
|
response = client.get("/api/v1/system/local-ip")
|
|
data = response.json()
|
|
ip = data["ip"]
|
|
|
|
# Regex fuer IPv4 Adresse
|
|
ipv4_pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
|
|
assert re.match(ipv4_pattern, ip), f"IP '{ip}' ist kein gueltiges IPv4-Format"
|
|
|
|
# Pruefe dass alle Oktets zwischen 0-255 sind
|
|
octets = ip.split('.')
|
|
for octet in octets:
|
|
assert 0 <= int(octet) <= 255, f"Oktet {octet} ist nicht im Bereich 0-255"
|
|
|
|
def test_local_ip_not_localhost(self, client):
|
|
"""Test: IP ist nicht localhost (127.0.0.1)"""
|
|
response = client.get("/api/v1/system/local-ip")
|
|
data = response.json()
|
|
ip = data["ip"]
|
|
|
|
assert ip != "127.0.0.1", "IP sollte nicht localhost sein"
|
|
assert ip != "localhost", "IP sollte nicht 'localhost' sein"
|
|
|
|
def test_local_ip_not_docker_internal(self, client):
|
|
"""Test: IP ist eine gueltige private Adresse."""
|
|
# In CI/Docker ist 172.x.x.x eine normale interne IP
|
|
response = client.get("/api/v1/system/local-ip")
|
|
data = response.json()
|
|
ip = data["ip"]
|
|
|
|
# Pruefen ob wir in CI/Docker laufen
|
|
in_ci = os.environ.get("CI", "").lower() in ("true", "1", "woodpecker")
|
|
in_docker = os.path.exists('/.dockerenv') or ip.startswith("172.") or in_ci
|
|
|
|
if not in_docker:
|
|
# Ausserhalb Docker: sollte keine Docker-interne IP sein
|
|
assert not ip.startswith("172."), f"IP '{ip}' sieht nach Docker-interner Adresse aus"
|
|
else:
|
|
# In Docker: IP sollte zumindest im privaten Bereich sein
|
|
octets = [int(x) for x in ip.split('.')]
|
|
is_private = (
|
|
ip.startswith("192.168.") or
|
|
ip.startswith("10.") or
|
|
(octets[0] == 172 and 16 <= octets[1] <= 31)
|
|
)
|
|
assert is_private, f"IP '{ip}' ist nicht im privaten Bereich"
|
|
|
|
def test_local_ip_is_private_range(self, client):
|
|
"""Test: IP ist im privaten Adressbereich (fuer lokales Netzwerk)"""
|
|
response = client.get("/api/v1/system/local-ip")
|
|
data = response.json()
|
|
ip = data["ip"]
|
|
octets = [int(x) for x in ip.split('.')]
|
|
|
|
# Private Adressbereiche:
|
|
# 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
|
is_private = (
|
|
(octets[0] == 10) or
|
|
(octets[0] == 172 and 16 <= octets[1] <= 31) or
|
|
(octets[0] == 192 and octets[1] == 168)
|
|
)
|
|
|
|
assert is_private, f"IP '{ip}' ist nicht im privaten Adressbereich"
|
|
|
|
|
|
class TestLocalIPEnvironmentVariable:
|
|
"""Tests fuer die Konfiguration via Environment Variable"""
|
|
|
|
def test_local_ip_uses_env_variable(self):
|
|
"""Test: Endpoint verwendet LOCAL_NETWORK_IP Umgebungsvariable"""
|
|
test_ip = "10.0.0.100"
|
|
|
|
with patch.dict(os.environ, {'LOCAL_NETWORK_IP': test_ip}):
|
|
# Reimport um neue Umgebungsvariable zu laden
|
|
import importlib
|
|
import sys
|
|
|
|
# In Docker ist der Pfad /app
|
|
if '/app' not in sys.path:
|
|
sys.path.insert(0, '/app')
|
|
|
|
# Dieser Test funktioniert nur wenn main.py reimportiert wird
|
|
# In der Praxis wird die Variable beim Container-Start gesetzt
|
|
|
|
def test_local_ip_has_default_fallback(self):
|
|
"""Test: Es gibt einen Default-Wert wenn keine Umgebungsvariable gesetzt ist"""
|
|
# Der Default-Wert ist 192.168.178.157 (hardcoded)
|
|
default_ip = os.getenv("LOCAL_NETWORK_IP", "192.168.178.157")
|
|
assert default_ip == "192.168.178.157"
|
|
|
|
|
|
class TestHealthEndpoint:
|
|
"""Tests fuer den /health Endpoint"""
|
|
|
|
@pytest.fixture
|
|
def client(self):
|
|
"""TestClient fuer die FastAPI App"""
|
|
import sys
|
|
sys.path.insert(0, '/app')
|
|
|
|
from main import app
|
|
|
|
with patch.dict(os.environ, {'LOCAL_NETWORK_IP': '192.168.178.157'}):
|
|
yield TestClient(app)
|
|
|
|
def test_health_returns_200(self, client):
|
|
"""Test: Health Endpoint gibt 200 zurueck"""
|
|
response = client.get("/health")
|
|
assert response.status_code == 200
|
|
|
|
def test_health_returns_healthy_status(self, client):
|
|
"""Test: Health Endpoint meldet 'healthy' Status"""
|
|
response = client.get("/health")
|
|
data = response.json()
|
|
assert data["status"] == "healthy"
|
|
|
|
def test_health_returns_service_name(self, client):
|
|
"""Test: Health Endpoint enthaelt Service-Namen"""
|
|
response = client.get("/health")
|
|
data = response.json()
|
|
assert "service" in data
|
|
assert data["service"] == "breakpilot-backend"
|
|
|
|
|
|
class TestMobileUploadURLGeneration:
|
|
"""
|
|
Tests zur Validierung der URL-Generierung fuer Mobile Upload.
|
|
|
|
Problem-Historie:
|
|
- Urspruenglich wurde localhost:8086 verwendet
|
|
- iPhones koennen localhost nicht erreichen (anderes Geraet)
|
|
- Loesung: Backend gibt lokale Netzwerk-IP zurueck
|
|
"""
|
|
|
|
def test_mobile_upload_url_format(self):
|
|
"""Test: Mobile Upload URL hat korrektes Format"""
|
|
# Simuliere die URL-Generierung aus dem Frontend
|
|
hostname = "192.168.178.157"
|
|
port = 8086
|
|
path = "/api/v1/upload/mobile"
|
|
|
|
upload_url = f"http://{hostname}:{port}{path}"
|
|
|
|
# Validiere URL-Format
|
|
assert upload_url.startswith("http://")
|
|
assert ":8086" in upload_url
|
|
assert "/api/v1/upload/mobile" in upload_url
|
|
assert "localhost" not in upload_url
|
|
assert "127.0.0.1" not in upload_url
|
|
|
|
def test_mobile_upload_url_reachable_format(self):
|
|
"""Test: URL ist im fuer Mobile erreichbaren Format"""
|
|
# Die URL muss von einem externen Geraet (iPhone) erreichbar sein
|
|
test_ips = [
|
|
("192.168.178.157", True), # Private IP - OK
|
|
("192.168.1.100", True), # Private IP - OK
|
|
("10.0.0.50", True), # Private IP - OK
|
|
("localhost", False), # Nicht erreichbar
|
|
("127.0.0.1", False), # Nicht erreichbar
|
|
("172.24.0.6", False), # Docker-intern - nicht erreichbar
|
|
]
|
|
|
|
for ip, expected_reachable in test_ips:
|
|
is_reachable = not (
|
|
ip == "localhost" or
|
|
ip == "127.0.0.1" or
|
|
ip.startswith("172.") # Docker-intern
|
|
)
|
|
assert is_reachable == expected_reachable, \
|
|
f"IP '{ip}' sollte {'erreichbar' if expected_reachable else 'nicht erreichbar'} sein"
|
|
|
|
|
|
class TestQRCodeURLValidation:
|
|
"""
|
|
Tests zur Validierung der QR-Code URL.
|
|
|
|
Diese Tests stellen sicher, dass die QR-Code URL korrekt ist
|
|
BEVOR der QR-Code generiert wird.
|
|
"""
|
|
|
|
def test_qr_url_length_acceptable(self):
|
|
"""Test: URL-Laenge ist fuer QR-Code akzeptabel"""
|
|
# QR-Code Version 3 kann ca. 77 alphanumerische Zeichen
|
|
# URL wie "http://192.168.178.157:8086/api/v1/upload/mobile" = 49 Zeichen
|
|
test_url = "http://192.168.178.157:8086/api/v1/upload/mobile"
|
|
|
|
# QR-Code funktioniert bis ca. 4000 Zeichen (Version 40)
|
|
# Unsere URLs sollten unter 100 Zeichen sein
|
|
assert len(test_url) < 100, f"URL ist zu lang: {len(test_url)} Zeichen"
|
|
|
|
def test_qr_url_no_special_chars_problems(self):
|
|
"""Test: URL enthaelt keine problematischen Zeichen fuer QR"""
|
|
test_url = "http://192.168.178.157:8086/api/v1/upload/mobile"
|
|
|
|
# Diese Zeichen sind OK in URLs: a-z, A-Z, 0-9, :, /, ., -, _
|
|
import string
|
|
allowed_chars = string.ascii_letters + string.digits + ":/.?&=-_"
|
|
|
|
for char in test_url:
|
|
assert char in allowed_chars, \
|
|
f"Zeichen '{char}' koennte Probleme bei QR-Generierung verursachen"
|
|
|
|
def test_qr_url_is_valid_url(self):
|
|
"""Test: URL ist eine gueltige HTTP-URL"""
|
|
from urllib.parse import urlparse
|
|
|
|
test_url = "http://192.168.178.157:8086/api/v1/upload/mobile"
|
|
parsed = urlparse(test_url)
|
|
|
|
assert parsed.scheme in ["http", "https"], "URL muss HTTP(S) sein"
|
|
assert parsed.netloc, "URL muss einen Host haben"
|
|
assert parsed.path, "URL muss einen Pfad haben"
|
|
|
|
|
|
# Integration Test (nur ausfuehren wenn Backend laeuft)
|
|
class TestIntegration:
|
|
"""
|
|
Integration Tests - benoetigten laufenden Backend-Container.
|
|
|
|
Diese Tests werden uebersprungen wenn das Backend nicht erreichbar ist.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def live_backend_url(self):
|
|
"""URL zum laufenden Backend (Docker oder lokal)"""
|
|
return "http://localhost:8000"
|
|
|
|
@pytest.mark.integration
|
|
def test_live_local_ip_endpoint(self, live_backend_url):
|
|
"""Integration Test: Echter Aufruf des local-ip Endpoints"""
|
|
import httpx
|
|
|
|
try:
|
|
response = httpx.get(f"{live_backend_url}/api/v1/system/local-ip", timeout=5.0)
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert "ip" in data
|
|
|
|
ip = data["ip"]
|
|
# Pruefe dass es eine gueltige private IP ist (inkl. Docker 172.16-31.x.x)
|
|
octets = [int(x) for x in ip.split('.')]
|
|
is_private = (
|
|
ip.startswith("192.168.") or
|
|
ip.startswith("10.") or
|
|
(octets[0] == 172 and 16 <= octets[1] <= 31) # Full 172.16.0.0/12 range
|
|
)
|
|
assert is_private, f"IP '{ip}' is not a valid private IP"
|
|
|
|
except httpx.ConnectError:
|
|
pytest.skip("Backend nicht erreichbar - Integration Test uebersprungen")
|
|
|
|
@pytest.mark.integration
|
|
def test_live_health_endpoint(self, live_backend_url):
|
|
"""Integration Test: Echter Aufruf des Health Endpoints"""
|
|
import httpx
|
|
|
|
try:
|
|
response = httpx.get(f"{live_backend_url}/health", timeout=5.0)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "healthy"
|
|
|
|
except httpx.ConnectError:
|
|
pytest.skip("Backend nicht erreichbar - Integration Test uebersprungen")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Fuehre Unit Tests aus (ohne Integration Tests)
|
|
pytest.main([__file__, "-v", "-m", "not integration"])
|