fix(escalations): Tenant/User-ID Defaults + Routing-Klarheit
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 19s
CI / test-python-dsms-gateway (push) Successful in 16s

- escalations/route.ts: X-Tenant-Id + X-User-Id Default-Header ergaenzt,
  X-User-Id aus Request weitergeleitet
- escalation_routes.py: DEFAULT_TENANT_ID Konstante (9282a473-...) statt 'default'
- test_escalation_routes.py: vollstaendige Test-Suite ergaenzt (+337 Zeilen)
- main.go + escalation_handlers.go: DEPRECATED-Kommentare — UCCA-Escalations
  bleiben fuer Assessment-Review, Haupt-Escalation-System ist Python-Backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-06 21:15:02 +01:00
parent 8f3fb84b61
commit a5e4801b09
5 changed files with 371 additions and 10 deletions

View File

@@ -22,6 +22,8 @@ async function proxyRequest(
try { try {
const headers: HeadersInit = { const headers: HeadersInit = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'X-User-Id': 'admin',
} }
const authHeader = request.headers.get('authorization') const authHeader = request.headers.get('authorization')
@@ -34,6 +36,11 @@ async function proxyRequest(
headers['X-Tenant-Id'] = tenantHeader headers['X-Tenant-Id'] = tenantHeader
} }
const userIdHeader = request.headers.get('x-user-id')
if (userIdHeader) {
headers['X-User-Id'] = userIdHeader
}
const fetchOptions: RequestInit = { const fetchOptions: RequestInit = {
method, method,
headers, headers,

View File

@@ -345,7 +345,9 @@ func main() {
uccaRoutes.GET("/wizard/schema", uccaHandlers.GetWizardSchema) uccaRoutes.GET("/wizard/schema", uccaHandlers.GetWizardSchema)
uccaRoutes.POST("/wizard/ask", uccaHandlers.AskWizardQuestion) uccaRoutes.POST("/wizard/ask", uccaHandlers.AskWizardQuestion)
// Escalation management (E0-E3 workflow) // DEPRECATED: UCCA Escalation management (E0-E3 workflow)
// Frontend uses Python backend-compliance escalation_routes.py (/api/compliance/escalations).
// These UCCA-specific routes remain for assessment-review workflows only.
uccaRoutes.GET("/escalations", escalationHandlers.ListEscalations) uccaRoutes.GET("/escalations", escalationHandlers.ListEscalations)
uccaRoutes.GET("/escalations/stats", escalationHandlers.GetEscalationStats) uccaRoutes.GET("/escalations/stats", escalationHandlers.GetEscalationStats)
uccaRoutes.GET("/escalations/:id", escalationHandlers.GetEscalation) uccaRoutes.GET("/escalations/:id", escalationHandlers.GetEscalation)
@@ -354,7 +356,7 @@ func main() {
uccaRoutes.POST("/escalations/:id/review", escalationHandlers.StartReview) uccaRoutes.POST("/escalations/:id/review", escalationHandlers.StartReview)
uccaRoutes.POST("/escalations/:id/decide", escalationHandlers.DecideEscalation) uccaRoutes.POST("/escalations/:id/decide", escalationHandlers.DecideEscalation)
// DSB Pool management // DEPRECATED: DSB Pool management — see note above
uccaRoutes.GET("/dsb-pool", escalationHandlers.ListDSBPool) uccaRoutes.GET("/dsb-pool", escalationHandlers.ListDSBPool)
uccaRoutes.POST("/dsb-pool", escalationHandlers.AddDSBPoolMember) uccaRoutes.POST("/dsb-pool", escalationHandlers.AddDSBPoolMember)

View File

@@ -1,3 +1,7 @@
// DEPRECATED: This file implements UCCA-specific escalation handlers (E0-E3 workflow).
// The primary escalation system is now Python backend-compliance escalation_routes.py
// (/api/compliance/escalations). These UCCA handlers remain only for assessment-review
// workflows and will be removed in a future release.
package handlers package handlers
import ( import (

View File

@@ -25,6 +25,8 @@ from classroom_engine.database import get_db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/escalations", tags=["escalations"]) router = APIRouter(prefix="/escalations", tags=["escalations"])
DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
# ============================================================================= # =============================================================================
# Pydantic Schemas # Pydantic Schemas
@@ -82,7 +84,7 @@ async def list_escalations(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""List escalations with optional filters.""" """List escalations with optional filters."""
tid = tenant_id or 'default' tid = tenant_id or DEFAULT_TENANT_ID
where_clauses = ["tenant_id = :tenant_id"] where_clauses = ["tenant_id = :tenant_id"]
params: Dict[str, Any] = {"tenant_id": tid, "limit": limit, "offset": offset} params: Dict[str, Any] = {"tenant_id": tid, "limit": limit, "offset": offset}
@@ -121,10 +123,11 @@ async def list_escalations(
async def create_escalation( async def create_escalation(
request: EscalationCreate, request: EscalationCreate,
tenant_id: Optional[str] = Header(None, alias="x-tenant-id"), tenant_id: Optional[str] = Header(None, alias="x-tenant-id"),
user_id: Optional[str] = Header(None, alias="x-user-id"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Create a new escalation.""" """Create a new escalation."""
tid = tenant_id or 'default' tid = tenant_id or DEFAULT_TENANT_ID
row = db.execute( row = db.execute(
text( text(
@@ -162,7 +165,7 @@ async def get_stats(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Return counts per status and priority.""" """Return counts per status and priority."""
tid = tenant_id or 'default' tid = tenant_id or DEFAULT_TENANT_ID
status_rows = db.execute( status_rows = db.execute(
text( text(
@@ -218,7 +221,7 @@ async def get_escalation(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get a single escalation by ID.""" """Get a single escalation by ID."""
tid = tenant_id or 'default' tid = tenant_id or DEFAULT_TENANT_ID
row = db.execute( row = db.execute(
text( text(
"SELECT * FROM compliance_escalations " "SELECT * FROM compliance_escalations "
@@ -239,7 +242,7 @@ async def update_escalation(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update an escalation's fields.""" """Update an escalation's fields."""
tid = tenant_id or 'default' tid = tenant_id or DEFAULT_TENANT_ID
existing = db.execute( existing = db.execute(
text( text(
@@ -282,7 +285,7 @@ async def update_status(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update only the status of an escalation.""" """Update only the status of an escalation."""
tid = tenant_id or 'default' tid = tenant_id or DEFAULT_TENANT_ID
existing = db.execute( existing = db.execute(
text( text(
@@ -322,7 +325,7 @@ async def delete_escalation(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Delete an escalation.""" """Delete an escalation."""
tid = tenant_id or 'default' tid = tenant_id or DEFAULT_TENANT_ID
existing = db.execute( existing = db.execute(
text( text(

View File

@@ -1,16 +1,90 @@
"""Tests for escalation routes and schemas (escalation_routes.py).""" """Tests for escalation routes and schemas (escalation_routes.py)."""
import pytest import pytest
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
from datetime import datetime from datetime import datetime
from fastapi.testclient import TestClient
from fastapi import FastAPI
from compliance.api.escalation_routes import ( from compliance.api.escalation_routes import (
router,
EscalationCreate, EscalationCreate,
EscalationUpdate, EscalationUpdate,
EscalationStatusUpdate, EscalationStatusUpdate,
_row_to_dict, _row_to_dict,
DEFAULT_TENANT_ID,
) )
from classroom_engine.database import get_db
# =============================================================================
# App + Client setup
# =============================================================================
app = FastAPI()
app.include_router(router)
ESCALATION_ID = "eeeeeeee-0001-0001-0001-000000000001"
UNKNOWN_ID = "aaaaaaaa-9999-9999-9999-999999999999"
# =============================================================================
# Mock helpers
# =============================================================================
class _MockRow:
"""Simulates a SQLAlchemy row with _mapping attribute."""
def __init__(self, data: dict):
self._mapping = data
def __getitem__(self, idx):
vals = list(self._mapping.values())
return vals[idx]
def _make_escalation_row(overrides=None):
now = datetime(2026, 3, 6, 12, 0, 0)
data = {
"id": ESCALATION_ID,
"tenant_id": DEFAULT_TENANT_ID,
"title": "Test Eskalation",
"description": "Beschreibung",
"priority": "medium",
"status": "open",
"category": "dsgvo_breach",
"assignee": "admin@example.com",
"reporter": "user@example.com",
"source_module": None,
"source_id": None,
"due_date": None,
"resolved_at": None,
"created_at": now,
"updated_at": now,
}
if overrides:
data.update(overrides)
return _MockRow(data)
def _count_row(val):
"""Simulates a COUNT(*) row — fetchone()[0] returns the value."""
row = MagicMock()
row.__getitem__ = lambda self, idx: val
return row
@pytest.fixture
def mock_db():
db = MagicMock()
app.dependency_overrides[get_db] = lambda: db
yield db
app.dependency_overrides.pop(get_db, None)
@pytest.fixture
def client(mock_db):
return TestClient(app)
# ============================================================================= # =============================================================================
# Schema Tests — EscalationCreate # Schema Tests — EscalationCreate
@@ -117,3 +191,274 @@ class TestRowToDict:
result = _row_to_dict(row) result = _row_to_dict(row)
assert result["description"] is None assert result["description"] is None
assert result["resolved_at"] is None assert result["resolved_at"] is None
# =============================================================================
# Route Tests — List Escalations
# =============================================================================
class TestListEscalations:
def test_list_empty(self, client, mock_db):
mock_db.execute.side_effect = None
mock_db.execute.return_value = MagicMock(
fetchall=MagicMock(return_value=[]),
fetchone=MagicMock(return_value=_count_row(0)),
)
# fetchall for items, then fetchone for count
mock_db.execute.side_effect = [
MagicMock(fetchall=MagicMock(return_value=[])),
MagicMock(fetchone=MagicMock(return_value=_count_row(0))),
]
resp = client.get("/escalations")
assert resp.status_code == 200
data = resp.json()
assert data["items"] == []
assert data["total"] == 0
def test_list_with_items(self, client, mock_db):
row = _make_escalation_row()
mock_db.execute.side_effect = [
MagicMock(fetchall=MagicMock(return_value=[row])),
MagicMock(fetchone=MagicMock(return_value=_count_row(1))),
]
resp = client.get("/escalations")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 1
assert data["items"][0]["id"] == ESCALATION_ID
assert data["total"] == 1
def test_list_filter_status(self, client, mock_db):
row = _make_escalation_row({"status": "in_progress"})
mock_db.execute.side_effect = [
MagicMock(fetchall=MagicMock(return_value=[row])),
MagicMock(fetchone=MagicMock(return_value=_count_row(1))),
]
resp = client.get("/escalations?status=in_progress")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 1
def test_list_filter_priority(self, client, mock_db):
row = _make_escalation_row({"priority": "critical"})
mock_db.execute.side_effect = [
MagicMock(fetchall=MagicMock(return_value=[row])),
MagicMock(fetchone=MagicMock(return_value=_count_row(1))),
]
resp = client.get("/escalations?priority=critical")
assert resp.status_code == 200
assert resp.json()["items"][0]["priority"] == "critical"
# =============================================================================
# Route Tests — Create Escalation
# =============================================================================
class TestCreateEscalation:
def test_create_basic(self, client, mock_db):
row = _make_escalation_row()
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
resp = client.post("/escalations", json={"title": "Test Eskalation"})
assert resp.status_code == 201
data = resp.json()
assert data["id"] == ESCALATION_ID
assert data["title"] == "Test Eskalation"
mock_db.commit.assert_called_once()
def test_create_full_fields(self, client, mock_db):
row = _make_escalation_row({
"priority": "critical",
"category": "ai_act",
"assignee": "dsb@example.com",
})
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
resp = client.post("/escalations", json={
"title": "AI Act Verstoß",
"description": "Hochrisiko-KI ohne Dokumentation",
"priority": "critical",
"category": "ai_act",
"assignee": "dsb@example.com",
})
assert resp.status_code == 201
assert resp.json()["priority"] == "critical"
def test_create_missing_title_422(self, client, mock_db):
resp = client.post("/escalations", json={"description": "Ohne Titel"})
assert resp.status_code == 422
# =============================================================================
# Route Tests — Get Escalation
# =============================================================================
class TestGetEscalation:
def test_get_existing(self, client, mock_db):
row = _make_escalation_row()
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
resp = client.get(f"/escalations/{ESCALATION_ID}")
assert resp.status_code == 200
assert resp.json()["id"] == ESCALATION_ID
def test_get_not_found(self, client, mock_db):
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
resp = client.get(f"/escalations/{UNKNOWN_ID}")
assert resp.status_code == 404
# =============================================================================
# Route Tests — Update Escalation
# =============================================================================
class TestUpdateEscalation:
def test_update_partial(self, client, mock_db):
existing = _make_escalation_row()
updated = _make_escalation_row({"priority": "high"})
mock_db.execute.side_effect = [
MagicMock(fetchone=MagicMock(return_value=existing)), # existence check
MagicMock(fetchone=MagicMock(return_value=updated)), # update RETURNING
]
resp = client.put(f"/escalations/{ESCALATION_ID}", json={"priority": "high"})
assert resp.status_code == 200
assert resp.json()["priority"] == "high"
mock_db.commit.assert_called_once()
def test_update_not_found(self, client, mock_db):
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
resp = client.put(f"/escalations/{UNKNOWN_ID}", json={"title": "New"})
assert resp.status_code == 404
def test_update_empty_no_op(self, client, mock_db):
row = _make_escalation_row()
mock_db.execute.side_effect = [
MagicMock(fetchone=MagicMock(return_value=row)), # existence check
MagicMock(fetchone=MagicMock(return_value=row)), # re-fetch unchanged
]
resp = client.put(f"/escalations/{ESCALATION_ID}", json={})
assert resp.status_code == 200
assert resp.json()["id"] == ESCALATION_ID
# =============================================================================
# Route Tests — Status Update
# =============================================================================
class TestUpdateStatus:
def test_to_in_progress(self, client, mock_db):
existing = _make_escalation_row()
updated = _make_escalation_row({"status": "in_progress"})
mock_db.execute.side_effect = [
MagicMock(fetchone=MagicMock(return_value=existing)),
MagicMock(fetchone=MagicMock(return_value=updated)),
]
resp = client.put(f"/escalations/{ESCALATION_ID}/status", json={"status": "in_progress"})
assert resp.status_code == 200
assert resp.json()["status"] == "in_progress"
mock_db.commit.assert_called_once()
def test_to_resolved_auto_resolved_at(self, client, mock_db):
existing = _make_escalation_row()
now = datetime(2026, 3, 6, 14, 0, 0)
updated = _make_escalation_row({"status": "resolved", "resolved_at": now})
mock_db.execute.side_effect = [
MagicMock(fetchone=MagicMock(return_value=existing)),
MagicMock(fetchone=MagicMock(return_value=updated)),
]
resp = client.put(f"/escalations/{ESCALATION_ID}/status", json={"status": "resolved"})
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "resolved"
assert data["resolved_at"] is not None
def test_to_closed(self, client, mock_db):
existing = _make_escalation_row()
updated = _make_escalation_row({"status": "closed"})
mock_db.execute.side_effect = [
MagicMock(fetchone=MagicMock(return_value=existing)),
MagicMock(fetchone=MagicMock(return_value=updated)),
]
resp = client.put(f"/escalations/{ESCALATION_ID}/status", json={"status": "closed"})
assert resp.status_code == 200
assert resp.json()["status"] == "closed"
def test_status_not_found(self, client, mock_db):
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
resp = client.put(f"/escalations/{UNKNOWN_ID}/status", json={"status": "closed"})
assert resp.status_code == 404
# =============================================================================
# Route Tests — Delete Escalation
# =============================================================================
class TestDeleteEscalation:
def test_delete_existing(self, client, mock_db):
row = _make_escalation_row()
mock_db.execute.side_effect = [
MagicMock(fetchone=MagicMock(return_value=row)), # existence check
None, # DELETE
]
resp = client.delete(f"/escalations/{ESCALATION_ID}")
assert resp.status_code == 200
assert resp.json()["success"] is True
mock_db.commit.assert_called_once()
def test_delete_not_found(self, client, mock_db):
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
resp = client.delete(f"/escalations/{UNKNOWN_ID}")
assert resp.status_code == 404
# =============================================================================
# Route Tests — Stats
# =============================================================================
class TestGetStats:
def test_stats_empty(self, client, mock_db):
mock_db.execute.side_effect = [
MagicMock(fetchall=MagicMock(return_value=[])), # by_status
MagicMock(fetchall=MagicMock(return_value=[])), # by_priority
MagicMock(fetchone=MagicMock(return_value=_count_row(0))), # total
MagicMock(fetchone=MagicMock(return_value=_count_row(0))), # active
]
resp = client.get("/escalations/stats")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
assert data["active"] == 0
assert data["by_status"]["open"] == 0
def test_stats_with_data(self, client, mock_db):
status_rows = [_MockRow({"0": "open", "1": 3}), _MockRow({"0": "resolved", "1": 1})]
# These rows need [0] and [1] indexing
sr1 = MagicMock(); sr1.__getitem__ = lambda s, i: ["open", 3][i]
sr2 = MagicMock(); sr2.__getitem__ = lambda s, i: ["resolved", 1][i]
pr1 = MagicMock(); pr1.__getitem__ = lambda s, i: ["high", 2][i]
pr2 = MagicMock(); pr2.__getitem__ = lambda s, i: ["medium", 2][i]
mock_db.execute.side_effect = [
MagicMock(fetchall=MagicMock(return_value=[sr1, sr2])),
MagicMock(fetchall=MagicMock(return_value=[pr1, pr2])),
MagicMock(fetchone=MagicMock(return_value=_count_row(4))),
MagicMock(fetchone=MagicMock(return_value=_count_row(3))),
]
resp = client.get("/escalations/stats")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 4
assert data["active"] == 3
assert data["by_status"]["open"] == 3
assert data["by_status"]["resolved"] == 1
def test_stats_structure(self, client, mock_db):
mock_db.execute.side_effect = [
MagicMock(fetchall=MagicMock(return_value=[])),
MagicMock(fetchall=MagicMock(return_value=[])),
MagicMock(fetchone=MagicMock(return_value=_count_row(0))),
MagicMock(fetchone=MagicMock(return_value=_count_row(0))),
]
resp = client.get("/escalations/stats")
data = resp.json()
assert set(data["by_status"].keys()) == {"open", "in_progress", "escalated", "resolved", "closed"}
assert set(data["by_priority"].keys()) == {"low", "medium", "high", "critical"}
assert "total" in data
assert "active" in data