From a5e4801b09d42e4d671a7c60190c466eb3812e07 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 6 Mar 2026 21:15:02 +0100 Subject: [PATCH] fix(escalations): Tenant/User-ID Defaults + Routing-Klarheit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../sdk/v1/escalations/[[...path]]/route.ts | 7 + ai-compliance-sdk/cmd/server/main.go | 6 +- .../api/handlers/escalation_handlers.go | 4 + .../compliance/api/escalation_routes.py | 17 +- .../tests/test_escalation_routes.py | 347 +++++++++++++++++- 5 files changed, 371 insertions(+), 10 deletions(-) diff --git a/admin-compliance/app/api/sdk/v1/escalations/[[...path]]/route.ts b/admin-compliance/app/api/sdk/v1/escalations/[[...path]]/route.ts index 8f77cf2..90824d1 100644 --- a/admin-compliance/app/api/sdk/v1/escalations/[[...path]]/route.ts +++ b/admin-compliance/app/api/sdk/v1/escalations/[[...path]]/route.ts @@ -22,6 +22,8 @@ async function proxyRequest( try { const headers: HeadersInit = { 'Content-Type': 'application/json', + 'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e', + 'X-User-Id': 'admin', } const authHeader = request.headers.get('authorization') @@ -34,6 +36,11 @@ async function proxyRequest( headers['X-Tenant-Id'] = tenantHeader } + const userIdHeader = request.headers.get('x-user-id') + if (userIdHeader) { + headers['X-User-Id'] = userIdHeader + } + const fetchOptions: RequestInit = { method, headers, diff --git a/ai-compliance-sdk/cmd/server/main.go b/ai-compliance-sdk/cmd/server/main.go index c32344b..9b2c86f 100644 --- a/ai-compliance-sdk/cmd/server/main.go +++ b/ai-compliance-sdk/cmd/server/main.go @@ -345,7 +345,9 @@ func main() { uccaRoutes.GET("/wizard/schema", uccaHandlers.GetWizardSchema) 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/stats", escalationHandlers.GetEscalationStats) uccaRoutes.GET("/escalations/:id", escalationHandlers.GetEscalation) @@ -354,7 +356,7 @@ func main() { uccaRoutes.POST("/escalations/:id/review", escalationHandlers.StartReview) uccaRoutes.POST("/escalations/:id/decide", escalationHandlers.DecideEscalation) - // DSB Pool management + // DEPRECATED: DSB Pool management — see note above uccaRoutes.GET("/dsb-pool", escalationHandlers.ListDSBPool) uccaRoutes.POST("/dsb-pool", escalationHandlers.AddDSBPoolMember) diff --git a/ai-compliance-sdk/internal/api/handlers/escalation_handlers.go b/ai-compliance-sdk/internal/api/handlers/escalation_handlers.go index e864133..f95fb89 100644 --- a/ai-compliance-sdk/internal/api/handlers/escalation_handlers.go +++ b/ai-compliance-sdk/internal/api/handlers/escalation_handlers.go @@ -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 import ( diff --git a/backend-compliance/compliance/api/escalation_routes.py b/backend-compliance/compliance/api/escalation_routes.py index 2e503f8..35c469e 100644 --- a/backend-compliance/compliance/api/escalation_routes.py +++ b/backend-compliance/compliance/api/escalation_routes.py @@ -25,6 +25,8 @@ from classroom_engine.database import get_db logger = logging.getLogger(__name__) router = APIRouter(prefix="/escalations", tags=["escalations"]) +DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' + # ============================================================================= # Pydantic Schemas @@ -82,7 +84,7 @@ async def list_escalations( db: Session = Depends(get_db), ): """List escalations with optional filters.""" - tid = tenant_id or 'default' + tid = tenant_id or DEFAULT_TENANT_ID where_clauses = ["tenant_id = :tenant_id"] params: Dict[str, Any] = {"tenant_id": tid, "limit": limit, "offset": offset} @@ -121,10 +123,11 @@ async def list_escalations( async def create_escalation( request: EscalationCreate, 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), ): """Create a new escalation.""" - tid = tenant_id or 'default' + tid = tenant_id or DEFAULT_TENANT_ID row = db.execute( text( @@ -162,7 +165,7 @@ async def get_stats( db: Session = Depends(get_db), ): """Return counts per status and priority.""" - tid = tenant_id or 'default' + tid = tenant_id or DEFAULT_TENANT_ID status_rows = db.execute( text( @@ -218,7 +221,7 @@ async def get_escalation( db: Session = Depends(get_db), ): """Get a single escalation by ID.""" - tid = tenant_id or 'default' + tid = tenant_id or DEFAULT_TENANT_ID row = db.execute( text( "SELECT * FROM compliance_escalations " @@ -239,7 +242,7 @@ async def update_escalation( db: Session = Depends(get_db), ): """Update an escalation's fields.""" - tid = tenant_id or 'default' + tid = tenant_id or DEFAULT_TENANT_ID existing = db.execute( text( @@ -282,7 +285,7 @@ async def update_status( db: Session = Depends(get_db), ): """Update only the status of an escalation.""" - tid = tenant_id or 'default' + tid = tenant_id or DEFAULT_TENANT_ID existing = db.execute( text( @@ -322,7 +325,7 @@ async def delete_escalation( db: Session = Depends(get_db), ): """Delete an escalation.""" - tid = tenant_id or 'default' + tid = tenant_id or DEFAULT_TENANT_ID existing = db.execute( text( diff --git a/backend-compliance/tests/test_escalation_routes.py b/backend-compliance/tests/test_escalation_routes.py index 03f7555..9fb8037 100644 --- a/backend-compliance/tests/test_escalation_routes.py +++ b/backend-compliance/tests/test_escalation_routes.py @@ -1,16 +1,90 @@ """Tests for escalation routes and schemas (escalation_routes.py).""" import pytest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from datetime import datetime +from fastapi.testclient import TestClient +from fastapi import FastAPI from compliance.api.escalation_routes import ( + router, EscalationCreate, EscalationUpdate, EscalationStatusUpdate, _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 @@ -117,3 +191,274 @@ class TestRowToDict: result = _row_to_dict(row) assert result["description"] 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