From 3467bce2228b7f6b16677151bebb1d86b419947a Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 6 Mar 2026 21:31:14 +0100 Subject: [PATCH] feat(obligations): Go PARTIAL DEPRECATED, Python x-user-id, UCCA Proxy Headers, 62 Tests - Go obligations_handlers.go: CRUD-Overlap als deprecated markiert, AI-Features (Assess/Gap/TOM/Export) bleiben aktiv - Python obligation_routes.py: x-user-id Header + Audit-Logging an 4 Write-Endpoints - 3 UCCA Proxy-Dateien: Default X-Tenant-ID + X-User-ID Headers - Tests von 39 auf 62 erweitert (+23 Route-Integration-Tests mit mock_db/TestClient) Co-Authored-By: Claude Opus 4.6 --- .../v1/ucca/obligations/[[...path]]/route.ts | 11 +- .../sdk/v1/ucca/obligations/assess/route.ts | 10 +- .../ucca/obligations/export/direct/route.ts | 10 +- .../api/handlers/obligations_handlers.go | 14 + .../compliance/api/obligation_routes.py | 8 + .../tests/test_obligation_routes.py | 315 ++++++++++++++++++ 6 files changed, 355 insertions(+), 13 deletions(-) diff --git a/admin-compliance/app/api/sdk/v1/ucca/obligations/[[...path]]/route.ts b/admin-compliance/app/api/sdk/v1/ucca/obligations/[[...path]]/route.ts index 79ac447..60c0abb 100644 --- a/admin-compliance/app/api/sdk/v1/ucca/obligations/[[...path]]/route.ts +++ b/admin-compliance/app/api/sdk/v1/ucca/obligations/[[...path]]/route.ts @@ -2,14 +2,19 @@ import { NextRequest, NextResponse } from 'next/server' const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085' +const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' +const DEFAULT_USER_ID = 'admin' + async function proxyRequest(request: NextRequest, method: string) { try { const pathSegments = request.nextUrl.pathname.replace('/api/sdk/v1/ucca/obligations/', '') const targetUrl = `${SDK_BASE_URL}/sdk/v1/ucca/obligations/${pathSegments}${request.nextUrl.search}` - const headers: Record = { 'Content-Type': 'application/json' } - const tenantId = request.headers.get('X-Tenant-ID') - if (tenantId) headers['X-Tenant-ID'] = tenantId + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID, + 'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID, + } const fetchOptions: RequestInit = { method, headers } if (method === 'POST' || method === 'PUT' || method === 'PATCH') { diff --git a/admin-compliance/app/api/sdk/v1/ucca/obligations/assess/route.ts b/admin-compliance/app/api/sdk/v1/ucca/obligations/assess/route.ts index 5b4d684..540604a 100644 --- a/admin-compliance/app/api/sdk/v1/ucca/obligations/assess/route.ts +++ b/admin-compliance/app/api/sdk/v1/ucca/obligations/assess/route.ts @@ -2,19 +2,19 @@ import { NextRequest, NextResponse } from 'next/server' const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085' +const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' +const DEFAULT_USER_ID = 'admin' + export async function POST(request: NextRequest) { try { const body = await request.json() - // Forward the request to the SDK backend const response = await fetch(`${SDK_BASE_URL}/sdk/v1/ucca/obligations/assess`, { method: 'POST', headers: { 'Content-Type': 'application/json', - // Forward tenant ID if present - ...(request.headers.get('X-Tenant-ID') && { - 'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string, - }), + 'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID, + 'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID, }, body: JSON.stringify(body), }) diff --git a/admin-compliance/app/api/sdk/v1/ucca/obligations/export/direct/route.ts b/admin-compliance/app/api/sdk/v1/ucca/obligations/export/direct/route.ts index 59bb1fe..4500e61 100644 --- a/admin-compliance/app/api/sdk/v1/ucca/obligations/export/direct/route.ts +++ b/admin-compliance/app/api/sdk/v1/ucca/obligations/export/direct/route.ts @@ -2,19 +2,19 @@ import { NextRequest, NextResponse } from 'next/server' const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085' +const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' +const DEFAULT_USER_ID = 'admin' + export async function POST(request: NextRequest) { try { const body = await request.json() - // Forward the request to the SDK backend const response = await fetch(`${SDK_BASE_URL}/sdk/v1/ucca/obligations/export/direct`, { method: 'POST', headers: { 'Content-Type': 'application/json', - // Forward tenant ID if present - ...(request.headers.get('X-Tenant-ID') && { - 'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string, - }), + 'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID, + 'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID, }, body: JSON.stringify(body), }) diff --git a/ai-compliance-sdk/internal/api/handlers/obligations_handlers.go b/ai-compliance-sdk/internal/api/handlers/obligations_handlers.go index 568623c..ca6e20c 100644 --- a/ai-compliance-sdk/internal/api/handlers/obligations_handlers.go +++ b/ai-compliance-sdk/internal/api/handlers/obligations_handlers.go @@ -1,3 +1,17 @@ +// PARTIAL DEPRECATED — CRUD-Overlap mit Python backend-compliance +// +// Die folgenden CRUD-Endpoints sind deprecated, da Python (obligation_routes.py) +// nun als Primary fuer List/Create/Update/Delete dient: +// - ListObligations, CreateObligation, GetObligation, UpdateObligation, DeleteObligation +// +// AKTIV bleiben (einzigartige AI-Features ohne Python-Aequivalent): +// - AssessFromScope, RunGapAnalysis, ExportAssessment, ExportDirect +// - ListAssessments, GetAssessment, UpdateAssessment +// - TOM-Controls: ListTOMControls, MapTOMControls, GetTOMGapAnalysis +// - GetObligationsByFramework, GetFrameworks +// +// Go-Routen werden NICHT entfernt (Abwaertskompatibilitaet), aber Frontend +// nutzt Python-Backend fuer alle CRUD-Operationen. package handlers import ( diff --git a/backend-compliance/compliance/api/obligation_routes.py b/backend-compliance/compliance/api/obligation_routes.py index 74008e7..d94cb39 100644 --- a/backend-compliance/compliance/api/obligation_routes.py +++ b/backend-compliance/compliance/api/obligation_routes.py @@ -188,9 +188,11 @@ async def create_obligation( payload: ObligationCreate, db: Session = Depends(get_db), x_tenant_id: Optional[str] = Header(None), + x_user_id: Optional[str] = Header(None), ): """Create a new compliance obligation.""" tenant_id = _get_tenant_id(x_tenant_id) + logger.info("create_obligation user_id=%s tenant_id=%s title=%s", x_user_id, tenant_id, payload.title) import json linked_systems = json.dumps(payload.linked_systems or []) @@ -244,9 +246,11 @@ async def update_obligation( payload: ObligationUpdate, db: Session = Depends(get_db), x_tenant_id: Optional[str] = Header(None), + x_user_id: Optional[str] = Header(None), ): """Update an obligation's fields.""" tenant_id = _get_tenant_id(x_tenant_id) + logger.info("update_obligation user_id=%s tenant_id=%s id=%s", x_user_id, tenant_id, obligation_id) import json updates: Dict[str, Any] = {"id": obligation_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()} @@ -282,9 +286,11 @@ async def update_obligation_status( payload: ObligationStatusUpdate, db: Session = Depends(get_db), x_tenant_id: Optional[str] = Header(None), + x_user_id: Optional[str] = Header(None), ): """Quick status update for an obligation.""" tenant_id = _get_tenant_id(x_tenant_id) + logger.info("update_obligation_status user_id=%s tenant_id=%s id=%s status=%s", x_user_id, tenant_id, obligation_id, payload.status) valid_statuses = {"pending", "in-progress", "completed", "overdue"} if payload.status not in valid_statuses: raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {', '.join(valid_statuses)}") @@ -307,8 +313,10 @@ async def delete_obligation( obligation_id: str, db: Session = Depends(get_db), x_tenant_id: Optional[str] = Header(None), + x_user_id: Optional[str] = Header(None), ): tenant_id = _get_tenant_id(x_tenant_id) + logger.info("delete_obligation user_id=%s tenant_id=%s id=%s", x_user_id, tenant_id, obligation_id) result = db.execute(text(""" DELETE FROM compliance_obligations WHERE id = :id AND tenant_id = :tenant_id diff --git a/backend-compliance/tests/test_obligation_routes.py b/backend-compliance/tests/test_obligation_routes.py index c4e1247..5e377cd 100644 --- a/backend-compliance/tests/test_obligation_routes.py +++ b/backend-compliance/tests/test_obligation_routes.py @@ -4,7 +4,11 @@ import pytest from unittest.mock import MagicMock from datetime import datetime +from fastapi import FastAPI +from fastapi.testclient import TestClient + from compliance.api.obligation_routes import ( + router, ObligationCreate, ObligationUpdate, ObligationStatusUpdate, @@ -12,6 +16,71 @@ from compliance.api.obligation_routes import ( _get_tenant_id, DEFAULT_TENANT_ID, ) +from classroom_engine.database import get_db + +# --------------------------------------------------------------------------- +# Route-Test infrastructure +# --------------------------------------------------------------------------- + +OBLIGATION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + +app = FastAPI() +app.include_router(router) + + +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_obligation_row(overrides=None): + now = datetime(2026, 3, 6, 12, 0, 0) + data = { + "id": OBLIGATION_ID, + "tenant_id": DEFAULT_TENANT_ID, + "title": "Art. 30 VVT führen", + "description": "Pflicht nach DSGVO", + "source": "DSGVO", + "source_article": "Art. 30", + "deadline": None, + "status": "pending", + "priority": "medium", + "responsible": None, + "linked_systems": [], + "assessment_id": None, + "rule_code": None, + "notes": 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) # ============================================================================= @@ -323,3 +392,249 @@ class TestObligationBusinessLogic: for source in ["DSGVO", "AI Act", "NIS2", "BDSG", "ISO 27001"]: req = ObligationCreate(title="Test", source=source) assert req.source == source + + +# ============================================================================= +# Route Integration Tests — List +# ============================================================================= + +class TestListObligationsRoute: + def test_list_empty(self, client, mock_db): + mock_db.execute.side_effect = [ + MagicMock(fetchone=MagicMock(return_value=_count_row(0))), + MagicMock(fetchall=MagicMock(return_value=[])), + ] + resp = client.get("/obligations") + assert resp.status_code == 200 + data = resp.json() + assert data["obligations"] == [] + assert data["total"] == 0 + + def test_list_with_items(self, client, mock_db): + row = _make_obligation_row() + mock_db.execute.side_effect = [ + MagicMock(fetchone=MagicMock(return_value=_count_row(1))), + MagicMock(fetchall=MagicMock(return_value=[row])), + ] + resp = client.get("/obligations") + assert resp.status_code == 200 + data = resp.json() + assert len(data["obligations"]) == 1 + assert data["obligations"][0]["id"] == OBLIGATION_ID + assert data["total"] == 1 + + def test_list_filter_status(self, client, mock_db): + row = _make_obligation_row({"status": "overdue"}) + mock_db.execute.side_effect = [ + MagicMock(fetchone=MagicMock(return_value=_count_row(1))), + MagicMock(fetchall=MagicMock(return_value=[row])), + ] + resp = client.get("/obligations?status=overdue") + assert resp.status_code == 200 + assert resp.json()["obligations"][0]["status"] == "overdue" + + def test_list_filter_priority(self, client, mock_db): + row = _make_obligation_row({"priority": "critical"}) + mock_db.execute.side_effect = [ + MagicMock(fetchone=MagicMock(return_value=_count_row(1))), + MagicMock(fetchall=MagicMock(return_value=[row])), + ] + resp = client.get("/obligations?priority=critical") + assert resp.status_code == 200 + assert resp.json()["obligations"][0]["priority"] == "critical" + + +# ============================================================================= +# Route Integration Tests — Create +# ============================================================================= + +class TestCreateObligationRoute: + def test_create_basic(self, client, mock_db): + row = _make_obligation_row() + mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row)) + resp = client.post("/obligations", json={"title": "Art. 30 VVT führen"}) + assert resp.status_code == 201 + data = resp.json() + assert data["id"] == OBLIGATION_ID + assert data["title"] == "Art. 30 VVT führen" + mock_db.commit.assert_called_once() + + def test_create_full_fields(self, client, mock_db): + row = _make_obligation_row({ + "title": "DSFA durchführen", + "source": "DSGVO", + "source_article": "Art. 35", + "priority": "critical", + "responsible": "DSB", + }) + mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row)) + resp = client.post("/obligations", json={ + "title": "DSFA durchführen", + "source": "DSGVO", + "source_article": "Art. 35", + "priority": "critical", + "responsible": "DSB", + }) + assert resp.status_code == 201 + assert resp.json()["priority"] == "critical" + assert resp.json()["responsible"] == "DSB" + + def test_create_missing_title_422(self, client, mock_db): + resp = client.post("/obligations", json={"source": "DSGVO"}) + assert resp.status_code == 422 + + +# ============================================================================= +# Route Integration Tests — Get +# ============================================================================= + +class TestGetObligationRoute: + def test_get_existing(self, client, mock_db): + row = _make_obligation_row() + mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row)) + resp = client.get(f"/obligations/{OBLIGATION_ID}") + assert resp.status_code == 200 + assert resp.json()["id"] == OBLIGATION_ID + + def test_get_not_found(self, client, mock_db): + mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None)) + resp = client.get("/obligations/nonexistent-id") + assert resp.status_code == 404 + + +# ============================================================================= +# Route Integration Tests — Update +# ============================================================================= + +class TestUpdateObligationRoute: + def test_update_partial(self, client, mock_db): + updated = _make_obligation_row({"priority": "high"}) + mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=updated)) + resp = client.put(f"/obligations/{OBLIGATION_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"/obligations/{OBLIGATION_ID}", json={"title": "Updated"}) + assert resp.status_code == 404 + + def test_update_empty_body_400(self, client, mock_db): + resp = client.put(f"/obligations/{OBLIGATION_ID}", json={}) + assert resp.status_code == 400 + assert "No fields to update" in resp.json()["detail"] + + +# ============================================================================= +# Route Integration Tests — Status Update +# ============================================================================= + +class TestUpdateObligationStatusRoute: + def test_valid_status(self, client, mock_db): + row = _make_obligation_row({"status": "in-progress"}) + mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row)) + resp = client.put(f"/obligations/{OBLIGATION_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_invalid_status_400(self, client, mock_db): + resp = client.put(f"/obligations/{OBLIGATION_ID}/status", json={"status": "invalid"}) + assert resp.status_code == 400 + assert "Invalid status" in resp.json()["detail"] + + def test_status_not_found(self, client, mock_db): + mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None)) + resp = client.put(f"/obligations/{OBLIGATION_ID}/status", json={"status": "completed"}) + assert resp.status_code == 404 + + def test_status_to_completed(self, client, mock_db): + row = _make_obligation_row({"status": "completed"}) + mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row)) + resp = client.put(f"/obligations/{OBLIGATION_ID}/status", json={"status": "completed"}) + assert resp.status_code == 200 + assert resp.json()["status"] == "completed" + + +# ============================================================================= +# Route Integration Tests — Delete +# ============================================================================= + +class TestDeleteObligationRoute: + def test_delete_existing(self, client, mock_db): + mock_db.execute.return_value = MagicMock(rowcount=1) + resp = client.delete(f"/obligations/{OBLIGATION_ID}") + assert resp.status_code == 204 + mock_db.commit.assert_called_once() + + def test_delete_not_found(self, client, mock_db): + mock_db.execute.return_value = MagicMock(rowcount=0) + resp = client.delete(f"/obligations/{OBLIGATION_ID}") + assert resp.status_code == 404 + + +# ============================================================================= +# Route Integration Tests — Stats +# ============================================================================= + +class TestGetObligationStatsRoute: + def test_stats_empty(self, client, mock_db): + mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None)) + resp = client.get("/obligations/stats") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0 + assert data["pending"] == 0 + + def test_stats_with_data(self, client, mock_db): + row = MagicMock() + row._mapping = { + "pending": 3, "in_progress": 2, "overdue": 1, + "completed": 5, "critical": 2, "high": 3, "total": 11, + } + mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row)) + resp = client.get("/obligations/stats") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 11 + assert data["pending"] == 3 + assert data["critical"] == 2 + + def test_stats_structure(self, client, mock_db): + row = MagicMock() + row._mapping = { + "pending": 0, "in_progress": 0, "overdue": 0, + "completed": 0, "critical": 0, "high": 0, "total": 0, + } + mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row)) + resp = client.get("/obligations/stats") + data = resp.json() + expected_keys = {"pending", "in_progress", "overdue", "completed", "critical", "high", "total"} + assert set(data.keys()) == expected_keys + + +# ============================================================================= +# Route Integration Tests — Search +# ============================================================================= + +class TestObligationSearchRoute: + def test_search_param(self, client, mock_db): + row = _make_obligation_row({"title": "DSFA Pflicht"}) + mock_db.execute.side_effect = [ + MagicMock(fetchone=MagicMock(return_value=_count_row(1))), + MagicMock(fetchall=MagicMock(return_value=[row])), + ] + resp = client.get("/obligations?search=DSFA") + assert resp.status_code == 200 + assert len(resp.json()["obligations"]) == 1 + + def test_source_filter(self, client, mock_db): + row = _make_obligation_row({"source": "AI Act"}) + mock_db.execute.side_effect = [ + MagicMock(fetchone=MagicMock(return_value=_count_row(1))), + MagicMock(fetchall=MagicMock(return_value=[row])), + ] + resp = client.get("/obligations?source=AI Act") + assert resp.status_code == 200 + assert resp.json()["obligations"][0]["source"] == "AI Act"