feat(obligations): Go PARTIAL DEPRECATED, Python x-user-id, UCCA Proxy Headers, 62 Tests
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 31s
CI / test-python-backend-compliance (push) Successful in 29s
CI / test-python-document-crawler (push) Successful in 19s
CI / test-python-dsms-gateway (push) Successful in 26s

- 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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-06 21:31:14 +01:00
parent a5e4801b09
commit 3467bce222
6 changed files with 355 additions and 13 deletions

View File

@@ -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<string, string> = { 'Content-Type': 'application/json' }
const tenantId = request.headers.get('X-Tenant-ID')
if (tenantId) headers['X-Tenant-ID'] = tenantId
const headers: Record<string, string> = {
'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') {

View File

@@ -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),
})

View File

@@ -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),
})

View File

@@ -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 (

View File

@@ -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

View File

@@ -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"