feat(dsfa): Go DSFA deprecated, URL-Fix, fehlende Endpoints + 145 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 34s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 27s
CI / test-python-dsms-gateway (push) Successful in 18s
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 34s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 27s
CI / test-python-dsms-gateway (push) Successful in 18s
- Go: DEPRECATED-Kommentare an allen 6 DSFA-Handlern + Route-Block - api.ts: URL-Fix /dsgvo/dsfas → /dsfa (Detail-Seite war komplett kaputt) - Python: Section-Update, Workflow (submit/approve), Export (JSON+CSV), UCCA-Stubs - Tests: 145/145 bestanden (Schema + Route-Integration mit TestClient+SQLite) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,261 @@
|
||||
"""Tests for DSFA routes and schemas (dsfa_routes.py)."""
|
||||
"""Tests for DSFA routes and schemas (dsfa_routes.py).
|
||||
|
||||
Includes:
|
||||
- Schema/Pydantic tests (DSFACreate, DSFAUpdate, DSFAStatusUpdate)
|
||||
- Helper tests (_dsfa_to_response, _get_tenant_id)
|
||||
- Route integration tests (TestClient + SQLite)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
import uuid
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text, event # noqa: F401
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Ensure backend dir is on path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
from compliance.api.dsfa_routes import (
|
||||
DSFACreate,
|
||||
DSFAUpdate,
|
||||
DSFAStatusUpdate,
|
||||
DSFASectionUpdate,
|
||||
DSFAApproveRequest,
|
||||
_dsfa_to_response,
|
||||
_get_tenant_id,
|
||||
DEFAULT_TENANT_ID,
|
||||
VALID_STATUSES,
|
||||
VALID_RISK_LEVELS,
|
||||
router as dsfa_router,
|
||||
)
|
||||
|
||||
import json as _json
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test App + SQLite Setup
|
||||
# =============================================================================
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./test_dsfa.db"
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
_RawSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _register_sqlite_functions(dbapi_conn, connection_record):
|
||||
"""Register PostgreSQL-compatible functions for SQLite."""
|
||||
dbapi_conn.create_function("NOW", 0, lambda: datetime.utcnow().isoformat())
|
||||
|
||||
TENANT_ID = "default"
|
||||
|
||||
|
||||
class _DictRow(dict):
|
||||
"""Dict wrapper that mimics PostgreSQL's dict-like row access for SQLite."""
|
||||
pass
|
||||
|
||||
|
||||
class _DictSession:
|
||||
"""Wrapper around SQLAlchemy Session that returns dict-like rows.
|
||||
|
||||
Production code uses row["column_name"] which works with PostgreSQL/psycopg2
|
||||
but not with SQLAlchemy 2.0's Row objects on SQLite. This wrapper converts
|
||||
all result rows to dicts so the raw-SQL routes work in tests.
|
||||
|
||||
Also rewrites CAST(:param AS jsonb) → :param for SQLite compatibility.
|
||||
PostgreSQL CAST AS jsonb works, but SQLite CAST to unknown type yields 0.
|
||||
"""
|
||||
def __init__(self, session):
|
||||
self._session = session
|
||||
|
||||
def execute(self, stmt, params=None):
|
||||
import re
|
||||
# Rewrite CAST(:param AS jsonb) → :param for SQLite
|
||||
if hasattr(stmt, 'text'):
|
||||
rewritten = re.sub(r'CAST\((:[\w]+)\s+AS\s+jsonb\)', r'\1', stmt.text)
|
||||
if rewritten != stmt.text:
|
||||
stmt = text(rewritten)
|
||||
result = self._session.execute(stmt, params)
|
||||
return _DictResult(result)
|
||||
|
||||
def flush(self):
|
||||
self._session.flush()
|
||||
|
||||
def commit(self):
|
||||
self._session.commit()
|
||||
|
||||
def rollback(self):
|
||||
self._session.rollback()
|
||||
|
||||
def close(self):
|
||||
self._session.close()
|
||||
|
||||
|
||||
class _DictResult:
|
||||
"""Wraps SQLAlchemy Result to return dict rows."""
|
||||
def __init__(self, result):
|
||||
self._result = result
|
||||
try:
|
||||
self._keys = list(result.keys())
|
||||
self._returns_rows = True
|
||||
except Exception:
|
||||
self._keys = []
|
||||
self._returns_rows = False
|
||||
|
||||
def fetchone(self):
|
||||
if not self._returns_rows:
|
||||
return None
|
||||
row = self._result.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _DictRow(zip(self._keys, row))
|
||||
|
||||
def fetchall(self):
|
||||
if not self._returns_rows:
|
||||
return []
|
||||
rows = self._result.fetchall()
|
||||
return [_DictRow(zip(self._keys, r)) for r in rows]
|
||||
|
||||
@property
|
||||
def rowcount(self):
|
||||
return self._result.rowcount
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(dsfa_router, prefix="/api/compliance")
|
||||
|
||||
|
||||
def override_get_db():
|
||||
session = _RawSessionLocal()
|
||||
db = _DictSession(session)
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
# SQL to create the DSFA tables in SQLite (simplified from PostgreSQL)
|
||||
CREATE_DSFAS_TABLE = """
|
||||
CREATE TABLE IF NOT EXISTS compliance_dsfas (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
tenant_id TEXT NOT NULL DEFAULT 'default',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'draft',
|
||||
risk_level TEXT DEFAULT 'low',
|
||||
processing_activity TEXT DEFAULT '',
|
||||
data_categories TEXT DEFAULT '[]',
|
||||
recipients TEXT DEFAULT '[]',
|
||||
measures TEXT DEFAULT '[]',
|
||||
approved_by TEXT,
|
||||
approved_at TIMESTAMP,
|
||||
created_by TEXT DEFAULT 'system',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
-- Section 1
|
||||
processing_description TEXT,
|
||||
processing_purpose TEXT,
|
||||
legal_basis TEXT,
|
||||
legal_basis_details TEXT,
|
||||
-- Section 2
|
||||
necessity_assessment TEXT,
|
||||
proportionality_assessment TEXT,
|
||||
data_minimization TEXT,
|
||||
alternatives_considered TEXT,
|
||||
retention_justification TEXT,
|
||||
-- Section 3
|
||||
involves_ai INTEGER DEFAULT 0,
|
||||
overall_risk_level TEXT,
|
||||
risk_score INTEGER DEFAULT 0,
|
||||
risk_assessment TEXT,
|
||||
-- Section 6
|
||||
dpo_consulted INTEGER DEFAULT 0,
|
||||
dpo_consulted_at TIMESTAMP,
|
||||
dpo_name TEXT,
|
||||
dpo_opinion TEXT,
|
||||
dpo_approved INTEGER,
|
||||
authority_consulted INTEGER DEFAULT 0,
|
||||
authority_consulted_at TIMESTAMP,
|
||||
authority_reference TEXT,
|
||||
authority_decision TEXT,
|
||||
-- Metadata
|
||||
version INTEGER DEFAULT 1,
|
||||
previous_version_id TEXT,
|
||||
conclusion TEXT,
|
||||
federal_state TEXT,
|
||||
authority_resource_id TEXT,
|
||||
submitted_for_review_at TIMESTAMP,
|
||||
submitted_by TEXT,
|
||||
-- JSONB arrays (stored as TEXT in SQLite)
|
||||
data_subjects TEXT DEFAULT '[]',
|
||||
affected_rights TEXT DEFAULT '[]',
|
||||
triggered_rule_codes TEXT DEFAULT '[]',
|
||||
ai_trigger_ids TEXT DEFAULT '[]',
|
||||
wp248_criteria_met TEXT DEFAULT '[]',
|
||||
art35_abs3_triggered TEXT DEFAULT '[]',
|
||||
tom_references TEXT DEFAULT '[]',
|
||||
risks TEXT DEFAULT '[]',
|
||||
mitigations TEXT DEFAULT '[]',
|
||||
stakeholder_consultations TEXT DEFAULT '[]',
|
||||
review_triggers TEXT DEFAULT '[]',
|
||||
review_comments TEXT DEFAULT '[]',
|
||||
ai_use_case_modules TEXT DEFAULT '[]',
|
||||
section_8_complete INTEGER DEFAULT 0,
|
||||
-- JSONB objects (stored as TEXT in SQLite)
|
||||
threshold_analysis TEXT DEFAULT '{}',
|
||||
consultation_requirement TEXT DEFAULT '{}',
|
||||
review_schedule TEXT DEFAULT '{}',
|
||||
section_progress TEXT DEFAULT '{}',
|
||||
metadata TEXT DEFAULT '{}'
|
||||
)
|
||||
"""
|
||||
|
||||
CREATE_AUDIT_TABLE = """
|
||||
CREATE TABLE IF NOT EXISTS compliance_dsfa_audit_log (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
tenant_id TEXT NOT NULL,
|
||||
dsfa_id TEXT,
|
||||
action TEXT NOT NULL,
|
||||
changed_by TEXT DEFAULT 'system',
|
||||
old_values TEXT,
|
||||
new_values TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Create tables before each test, drop after."""
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(CREATE_DSFAS_TABLE))
|
||||
conn.execute(text(CREATE_AUDIT_TABLE))
|
||||
conn.commit()
|
||||
yield
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("DROP TABLE IF EXISTS compliance_dsfa_audit_log"))
|
||||
conn.execute(text("DROP TABLE IF EXISTS compliance_dsfas"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _create_dsfa_via_api(**kwargs):
|
||||
"""Helper: create a DSFA via POST and return response JSON."""
|
||||
payload = {"title": "Test DSFA", **kwargs}
|
||||
resp = client.post("/api/compliance/dsfa", json=payload)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — DSFACreate
|
||||
# =============================================================================
|
||||
@@ -143,6 +381,38 @@ class TestDSFAStatusUpdate:
|
||||
assert req.status == "needs-update"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — New Schemas
|
||||
# =============================================================================
|
||||
|
||||
class TestDSFASectionUpdate:
|
||||
def test_content_only(self):
|
||||
req = DSFASectionUpdate(content="Beschreibung der Verarbeitung")
|
||||
assert req.content == "Beschreibung der Verarbeitung"
|
||||
assert req.extra is None
|
||||
|
||||
def test_extra_dict(self):
|
||||
req = DSFASectionUpdate(extra={"key": "value"})
|
||||
assert req.extra == {"key": "value"}
|
||||
|
||||
def test_all_optional(self):
|
||||
req = DSFASectionUpdate()
|
||||
assert req.content is None
|
||||
assert req.extra is None
|
||||
|
||||
|
||||
class TestDSFAApproveRequest:
|
||||
def test_approved_true(self):
|
||||
req = DSFAApproveRequest(approved=True, approved_by="DSB Mueller")
|
||||
assert req.approved is True
|
||||
assert req.approved_by == "DSB Mueller"
|
||||
|
||||
def test_rejected(self):
|
||||
req = DSFAApproveRequest(approved=False, comments="Massnahmen unzureichend")
|
||||
assert req.approved is False
|
||||
assert req.comments == "Massnahmen unzureichend"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Tests — _get_tenant_id
|
||||
# =============================================================================
|
||||
@@ -353,94 +623,369 @@ class TestValidRiskLevels:
|
||||
|
||||
class TestDSFARouterConfig:
|
||||
def test_router_prefix(self):
|
||||
from compliance.api.dsfa_routes import router
|
||||
# /v1 prefix is added when router is included in the main app
|
||||
assert router.prefix == "/dsfa"
|
||||
assert dsfa_router.prefix == "/dsfa"
|
||||
|
||||
def test_router_has_tags(self):
|
||||
from compliance.api.dsfa_routes import router
|
||||
assert "compliance-dsfa" in router.tags
|
||||
assert "compliance-dsfa" in dsfa_router.tags
|
||||
|
||||
def test_router_registered_in_init(self):
|
||||
from compliance.api import dsfa_router
|
||||
assert dsfa_router is not None
|
||||
from compliance.api import dsfa_router as imported_router
|
||||
assert imported_router is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Stats Response Structure
|
||||
# Route Integration Tests — CRUD
|
||||
# =============================================================================
|
||||
|
||||
class TestDSFAStatsResponse:
|
||||
def test_stats_keys_present(self):
|
||||
"""Stats endpoint must return these keys."""
|
||||
expected_keys = {
|
||||
"total", "by_status", "by_risk_level",
|
||||
"draft_count", "in_review_count", "approved_count", "needs_update_count"
|
||||
}
|
||||
# Verify by constructing the expected dict shape
|
||||
stats = {
|
||||
"total": 0,
|
||||
"by_status": {},
|
||||
"by_risk_level": {},
|
||||
"draft_count": 0,
|
||||
"in_review_count": 0,
|
||||
"approved_count": 0,
|
||||
"needs_update_count": 0,
|
||||
}
|
||||
assert set(stats.keys()) == expected_keys
|
||||
class TestDSFARouteCRUD:
|
||||
"""Integration tests using TestClient + SQLite."""
|
||||
|
||||
def test_stats_total_is_int(self):
|
||||
stats = {"total": 5}
|
||||
assert isinstance(stats["total"], int)
|
||||
def test_list_dsfas_empty(self):
|
||||
resp = client.get("/api/compliance/dsfa")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
def test_stats_by_status_is_dict(self):
|
||||
by_status = {"draft": 2, "approved": 1}
|
||||
assert isinstance(by_status, dict)
|
||||
def test_create_dsfa(self):
|
||||
data = _create_dsfa_via_api(title="DSFA Videoüberwachung", risk_level="high")
|
||||
assert data["title"] == "DSFA Videoüberwachung"
|
||||
assert data["status"] == "draft"
|
||||
assert data["risk_level"] == "high"
|
||||
assert "id" in data
|
||||
|
||||
def test_stats_counts_are_integers(self):
|
||||
counts = {"draft_count": 2, "in_review_count": 1, "approved_count": 0}
|
||||
assert all(isinstance(v, int) for v in counts.values())
|
||||
def test_list_dsfas_with_data(self):
|
||||
_create_dsfa_via_api(title="DSFA 1")
|
||||
_create_dsfa_via_api(title="DSFA 2")
|
||||
resp = client.get("/api/compliance/dsfa")
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()
|
||||
assert len(items) == 2
|
||||
|
||||
def test_stats_zero_total_when_no_dsfas(self):
|
||||
stats = {"total": 0, "draft_count": 0, "in_review_count": 0, "approved_count": 0}
|
||||
assert stats["total"] == 0
|
||||
def test_get_dsfa(self):
|
||||
created = _create_dsfa_via_api(title="Detail-Test")
|
||||
dsfa_id = created["id"]
|
||||
resp = client.get(f"/api/compliance/dsfa/{dsfa_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "Detail-Test"
|
||||
|
||||
def test_get_dsfa_not_found(self):
|
||||
resp = client.get(f"/api/compliance/dsfa/{uuid.uuid4()}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_dsfa(self):
|
||||
created = _create_dsfa_via_api(title="Original")
|
||||
dsfa_id = created["id"]
|
||||
resp = client.put(f"/api/compliance/dsfa/{dsfa_id}", json={"title": "Updated"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "Updated"
|
||||
|
||||
def test_update_dsfa_not_found(self):
|
||||
resp = client.put(f"/api/compliance/dsfa/{uuid.uuid4()}", json={"title": "X"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_dsfa(self):
|
||||
created = _create_dsfa_via_api(title="To Delete")
|
||||
dsfa_id = created["id"]
|
||||
resp = client.delete(f"/api/compliance/dsfa/{dsfa_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
# Verify gone
|
||||
resp2 = client.get(f"/api/compliance/dsfa/{dsfa_id}")
|
||||
assert resp2.status_code == 404
|
||||
|
||||
def test_delete_dsfa_not_found(self):
|
||||
resp = client.delete(f"/api/compliance/dsfa/{uuid.uuid4()}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_list_with_status_filter(self):
|
||||
_create_dsfa_via_api(title="Draft One")
|
||||
created2 = _create_dsfa_via_api(title="Approved One")
|
||||
# Change status to approved
|
||||
client.patch(
|
||||
f"/api/compliance/dsfa/{created2['id']}/status",
|
||||
json={"status": "approved", "approved_by": "DSB"},
|
||||
)
|
||||
resp = client.get("/api/compliance/dsfa?status=approved")
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()
|
||||
assert len(items) == 1
|
||||
assert items[0]["status"] == "approved"
|
||||
|
||||
def test_create_invalid_status(self):
|
||||
resp = client.post("/api/compliance/dsfa", json={"title": "Bad", "status": "invalid"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_invalid_risk_level(self):
|
||||
resp = client.post("/api/compliance/dsfa", json={"title": "Bad", "risk_level": "extreme"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Audit Log Entry Structure
|
||||
# Route Integration Tests — Stats
|
||||
# =============================================================================
|
||||
|
||||
class TestAuditLogEntry:
|
||||
def test_audit_log_entry_keys(self):
|
||||
entry = {
|
||||
"id": "uuid-1",
|
||||
"tenant_id": "default",
|
||||
"dsfa_id": "uuid-2",
|
||||
"action": "CREATE",
|
||||
"changed_by": "system",
|
||||
"old_values": None,
|
||||
"new_values": {"title": "Test"},
|
||||
"created_at": "2026-01-01T12:00:00",
|
||||
}
|
||||
assert "id" in entry
|
||||
assert "action" in entry
|
||||
assert "dsfa_id" in entry
|
||||
assert "created_at" in entry
|
||||
class TestDSFARouteStats:
|
||||
def test_stats_empty(self):
|
||||
resp = client.get("/api/compliance/dsfa/stats")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
assert data["draft_count"] == 0
|
||||
|
||||
def test_audit_action_values(self):
|
||||
valid_actions = {"CREATE", "UPDATE", "DELETE", "STATUS_CHANGE"}
|
||||
assert "CREATE" in valid_actions
|
||||
assert "DELETE" in valid_actions
|
||||
assert "STATUS_CHANGE" in valid_actions
|
||||
def test_stats_with_data(self):
|
||||
_create_dsfa_via_api(title="DSFA A")
|
||||
_create_dsfa_via_api(title="DSFA B")
|
||||
resp = client.get("/api/compliance/dsfa/stats")
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert data["draft_count"] == 2
|
||||
|
||||
def test_audit_dsfa_id_can_be_none(self):
|
||||
entry = {"dsfa_id": None}
|
||||
assert entry["dsfa_id"] is None
|
||||
|
||||
def test_audit_old_values_can_be_none(self):
|
||||
entry = {"old_values": None, "new_values": {"title": "Test"}}
|
||||
assert entry["old_values"] is None
|
||||
assert entry["new_values"] is not None
|
||||
# =============================================================================
|
||||
# Route Integration Tests — Status Patch
|
||||
# =============================================================================
|
||||
|
||||
class TestDSFARouteStatusPatch:
|
||||
def test_patch_status(self):
|
||||
created = _create_dsfa_via_api(title="Status Test")
|
||||
resp = client.patch(
|
||||
f"/api/compliance/dsfa/{created['id']}/status",
|
||||
json={"status": "in-review"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "in-review"
|
||||
|
||||
def test_patch_status_invalid(self):
|
||||
created = _create_dsfa_via_api(title="Bad Status")
|
||||
resp = client.patch(
|
||||
f"/api/compliance/dsfa/{created['id']}/status",
|
||||
json={"status": "bogus"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_patch_status_not_found(self):
|
||||
resp = client.patch(
|
||||
f"/api/compliance/dsfa/{uuid.uuid4()}/status",
|
||||
json={"status": "draft"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — Section Update
|
||||
# =============================================================================
|
||||
|
||||
class TestDSFARouteSectionUpdate:
|
||||
def test_update_section_1(self):
|
||||
created = _create_dsfa_via_api(title="Section Test")
|
||||
resp = client.put(
|
||||
f"/api/compliance/dsfa/{created['id']}/sections/1",
|
||||
json={"content": "Verarbeitung personenbezogener Daten"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["processing_description"] == "Verarbeitung personenbezogener Daten"
|
||||
|
||||
def test_update_section_7_conclusion(self):
|
||||
created = _create_dsfa_via_api(title="Conclusion Test")
|
||||
resp = client.put(
|
||||
f"/api/compliance/dsfa/{created['id']}/sections/7",
|
||||
json={"content": "DSFA abgeschlossen — Restrisiko akzeptabel"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["conclusion"] == "DSFA abgeschlossen — Restrisiko akzeptabel"
|
||||
|
||||
def test_update_section_progress_tracked(self):
|
||||
created = _create_dsfa_via_api(title="Progress Test")
|
||||
client.put(
|
||||
f"/api/compliance/dsfa/{created['id']}/sections/1",
|
||||
json={"content": "Test"},
|
||||
)
|
||||
resp = client.get(f"/api/compliance/dsfa/{created['id']}")
|
||||
progress = resp.json()["section_progress"]
|
||||
assert progress.get("section_1") is True
|
||||
|
||||
def test_update_section_invalid_number(self):
|
||||
created = _create_dsfa_via_api(title="Invalid Section")
|
||||
resp = client.put(
|
||||
f"/api/compliance/dsfa/{created['id']}/sections/9",
|
||||
json={"content": "X"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_update_section_not_found(self):
|
||||
resp = client.put(
|
||||
f"/api/compliance/dsfa/{uuid.uuid4()}/sections/1",
|
||||
json={"content": "X"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — Workflow (Submit + Approve)
|
||||
# =============================================================================
|
||||
|
||||
class TestDSFARouteWorkflow:
|
||||
def test_submit_for_review(self):
|
||||
created = _create_dsfa_via_api(title="Workflow Test")
|
||||
resp = client.post(f"/api/compliance/dsfa/{created['id']}/submit-for-review")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "in-review"
|
||||
assert data["message"] == "DSFA zur Prüfung eingereicht"
|
||||
|
||||
def test_submit_for_review_wrong_status(self):
|
||||
created = _create_dsfa_via_api(title="Wrong Status")
|
||||
# First submit
|
||||
client.post(f"/api/compliance/dsfa/{created['id']}/submit-for-review")
|
||||
# Try to submit again (already in-review)
|
||||
resp = client.post(f"/api/compliance/dsfa/{created['id']}/submit-for-review")
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_not_found(self):
|
||||
resp = client.post(f"/api/compliance/dsfa/{uuid.uuid4()}/submit-for-review")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_approve_dsfa(self):
|
||||
created = _create_dsfa_via_api(title="Approve Test")
|
||||
client.post(f"/api/compliance/dsfa/{created['id']}/submit-for-review")
|
||||
resp = client.post(
|
||||
f"/api/compliance/dsfa/{created['id']}/approve",
|
||||
json={"approved": True, "approved_by": "DSB Mueller"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "approved"
|
||||
|
||||
def test_reject_dsfa(self):
|
||||
created = _create_dsfa_via_api(title="Reject Test")
|
||||
client.post(f"/api/compliance/dsfa/{created['id']}/submit-for-review")
|
||||
resp = client.post(
|
||||
f"/api/compliance/dsfa/{created['id']}/approve",
|
||||
json={"approved": False, "comments": "Massnahmen fehlen"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "needs-update"
|
||||
|
||||
def test_approve_wrong_status(self):
|
||||
created = _create_dsfa_via_api(title="Not In Review")
|
||||
resp = client.post(
|
||||
f"/api/compliance/dsfa/{created['id']}/approve",
|
||||
json={"approved": True},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_approve_not_found(self):
|
||||
resp = client.post(
|
||||
f"/api/compliance/dsfa/{uuid.uuid4()}/approve",
|
||||
json={"approved": True},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_full_workflow_draft_to_approved(self):
|
||||
"""Full lifecycle: create → submit → approve."""
|
||||
created = _create_dsfa_via_api(title="Full Lifecycle")
|
||||
dsfa_id = created["id"]
|
||||
assert created["status"] == "draft"
|
||||
|
||||
# Submit for review
|
||||
resp1 = client.post(f"/api/compliance/dsfa/{dsfa_id}/submit-for-review")
|
||||
assert resp1.json()["status"] == "in-review"
|
||||
|
||||
# Approve
|
||||
resp2 = client.post(
|
||||
f"/api/compliance/dsfa/{dsfa_id}/approve",
|
||||
json={"approved": True, "approved_by": "CISO"},
|
||||
)
|
||||
assert resp2.json()["status"] == "approved"
|
||||
|
||||
# Verify final state
|
||||
resp3 = client.get(f"/api/compliance/dsfa/{dsfa_id}")
|
||||
final = resp3.json()
|
||||
assert final["status"] == "approved"
|
||||
assert final["approved_by"] == "CISO"
|
||||
|
||||
def test_reject_then_resubmit(self):
|
||||
"""Lifecycle: create → submit → reject → resubmit → approve."""
|
||||
created = _create_dsfa_via_api(title="Reject Resubmit")
|
||||
dsfa_id = created["id"]
|
||||
|
||||
client.post(f"/api/compliance/dsfa/{dsfa_id}/submit-for-review")
|
||||
client.post(
|
||||
f"/api/compliance/dsfa/{dsfa_id}/approve",
|
||||
json={"approved": False, "comments": "Incomplete"},
|
||||
)
|
||||
|
||||
# Status should be needs-update → can resubmit
|
||||
resp = client.post(f"/api/compliance/dsfa/{dsfa_id}/submit-for-review")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "in-review"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — Export
|
||||
# =============================================================================
|
||||
|
||||
class TestDSFARouteExport:
|
||||
def test_export_json(self):
|
||||
created = _create_dsfa_via_api(title="Export Test")
|
||||
resp = client.get(f"/api/compliance/dsfa/{created['id']}/export?format=json")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "exported_at" in data
|
||||
assert data["dsfa"]["title"] == "Export Test"
|
||||
|
||||
def test_export_json_not_found(self):
|
||||
resp = client.get(f"/api/compliance/dsfa/{uuid.uuid4()}/export?format=json")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_export_csv(self):
|
||||
_create_dsfa_via_api(title="CSV DSFA 1")
|
||||
_create_dsfa_via_api(title="CSV DSFA 2")
|
||||
resp = client.get("/api/compliance/dsfa/export/csv")
|
||||
assert resp.status_code == 200
|
||||
assert "text/csv" in resp.headers.get("content-type", "")
|
||||
lines = resp.text.strip().split("\n")
|
||||
assert len(lines) == 3 # header + 2 rows
|
||||
assert "ID" in lines[0]
|
||||
assert "CSV DSFA" in lines[1] or "CSV DSFA" in lines[2]
|
||||
|
||||
def test_export_csv_empty(self):
|
||||
resp = client.get("/api/compliance/dsfa/export/csv")
|
||||
assert resp.status_code == 200
|
||||
lines = resp.text.strip().split("\n")
|
||||
assert len(lines) == 1 # header only
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — UCCA Stubs
|
||||
# =============================================================================
|
||||
|
||||
class TestDSFARouteUCCAStubs:
|
||||
def test_from_assessment_returns_501(self):
|
||||
resp = client.post(f"/api/compliance/dsfa/from-assessment/{uuid.uuid4()}")
|
||||
assert resp.status_code == 501
|
||||
|
||||
def test_by_assessment_returns_501(self):
|
||||
resp = client.get(f"/api/compliance/dsfa/by-assessment/{uuid.uuid4()}")
|
||||
assert resp.status_code == 501
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — Audit Log
|
||||
# =============================================================================
|
||||
|
||||
class TestDSFARouteAuditLog:
|
||||
def test_audit_log_after_create(self):
|
||||
_create_dsfa_via_api(title="Audit Test")
|
||||
resp = client.get("/api/compliance/dsfa/audit-log")
|
||||
assert resp.status_code == 200
|
||||
entries = resp.json()
|
||||
assert len(entries) >= 1
|
||||
assert entries[0]["action"] == "CREATE"
|
||||
|
||||
def test_audit_log_empty(self):
|
||||
resp = client.get("/api/compliance/dsfa/audit-log")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -556,7 +1101,6 @@ class TestAIUseCaseModules:
|
||||
|
||||
def test_response_ai_use_case_modules_list_from_list(self):
|
||||
"""_dsfa_to_response: ai_use_case_modules list passthrough."""
|
||||
from tests.test_dsfa_routes import TestDsfaToResponse
|
||||
helper = TestDsfaToResponse()
|
||||
modules = [{"type": "nlp", "name": "Test"}]
|
||||
row = helper._make_row(ai_use_case_modules=modules)
|
||||
@@ -565,7 +1109,6 @@ class TestAIUseCaseModules:
|
||||
|
||||
def test_response_ai_use_case_modules_from_json_string(self):
|
||||
"""_dsfa_to_response: parses JSON string for ai_use_case_modules."""
|
||||
from tests.test_dsfa_routes import TestDsfaToResponse
|
||||
helper = TestDsfaToResponse()
|
||||
modules = [{"type": "computer_vision"}]
|
||||
row = helper._make_row(ai_use_case_modules=_json.dumps(modules))
|
||||
@@ -574,7 +1117,6 @@ class TestAIUseCaseModules:
|
||||
|
||||
def test_response_ai_use_case_modules_null_becomes_empty_list(self):
|
||||
"""_dsfa_to_response: None → empty list."""
|
||||
from tests.test_dsfa_routes import TestDsfaToResponse
|
||||
helper = TestDsfaToResponse()
|
||||
row = helper._make_row(ai_use_case_modules=None)
|
||||
result = _dsfa_to_response(row)
|
||||
@@ -582,7 +1124,6 @@ class TestAIUseCaseModules:
|
||||
|
||||
def test_response_section_8_complete_flag(self):
|
||||
"""_dsfa_to_response: section_8_complete bool preserved."""
|
||||
from tests.test_dsfa_routes import TestDsfaToResponse
|
||||
helper = TestDsfaToResponse()
|
||||
row = helper._make_row(section_8_complete=True)
|
||||
result = _dsfa_to_response(row)
|
||||
@@ -598,7 +1139,6 @@ class TestDSFAFullSchema:
|
||||
|
||||
def _make_row(self, **overrides):
|
||||
"""Reuse the shared helper from TestDsfaToResponse."""
|
||||
from tests.test_dsfa_routes import TestDsfaToResponse
|
||||
helper = TestDsfaToResponse()
|
||||
return helper._make_row(**overrides)
|
||||
|
||||
@@ -766,3 +1306,78 @@ class TestDSFAFullSchema:
|
||||
]
|
||||
for key in new_keys:
|
||||
assert key in result, f"Missing key in response: {key}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Stats Response Structure
|
||||
# =============================================================================
|
||||
|
||||
class TestDSFAStatsResponse:
|
||||
def test_stats_keys_present(self):
|
||||
expected_keys = {
|
||||
"total", "by_status", "by_risk_level",
|
||||
"draft_count", "in_review_count", "approved_count", "needs_update_count"
|
||||
}
|
||||
stats = {
|
||||
"total": 0,
|
||||
"by_status": {},
|
||||
"by_risk_level": {},
|
||||
"draft_count": 0,
|
||||
"in_review_count": 0,
|
||||
"approved_count": 0,
|
||||
"needs_update_count": 0,
|
||||
}
|
||||
assert set(stats.keys()) == expected_keys
|
||||
|
||||
def test_stats_total_is_int(self):
|
||||
stats = {"total": 5}
|
||||
assert isinstance(stats["total"], int)
|
||||
|
||||
def test_stats_by_status_is_dict(self):
|
||||
by_status = {"draft": 2, "approved": 1}
|
||||
assert isinstance(by_status, dict)
|
||||
|
||||
def test_stats_counts_are_integers(self):
|
||||
counts = {"draft_count": 2, "in_review_count": 1, "approved_count": 0}
|
||||
assert all(isinstance(v, int) for v in counts.values())
|
||||
|
||||
def test_stats_zero_total_when_no_dsfas(self):
|
||||
stats = {"total": 0, "draft_count": 0, "in_review_count": 0, "approved_count": 0}
|
||||
assert stats["total"] == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Audit Log Entry Structure
|
||||
# =============================================================================
|
||||
|
||||
class TestAuditLogEntry:
|
||||
def test_audit_log_entry_keys(self):
|
||||
entry = {
|
||||
"id": "uuid-1",
|
||||
"tenant_id": "default",
|
||||
"dsfa_id": "uuid-2",
|
||||
"action": "CREATE",
|
||||
"changed_by": "system",
|
||||
"old_values": None,
|
||||
"new_values": {"title": "Test"},
|
||||
"created_at": "2026-01-01T12:00:00",
|
||||
}
|
||||
assert "id" in entry
|
||||
assert "action" in entry
|
||||
assert "dsfa_id" in entry
|
||||
assert "created_at" in entry
|
||||
|
||||
def test_audit_action_values(self):
|
||||
valid_actions = {"CREATE", "UPDATE", "DELETE", "STATUS_CHANGE"}
|
||||
assert "CREATE" in valid_actions
|
||||
assert "DELETE" in valid_actions
|
||||
assert "STATUS_CHANGE" in valid_actions
|
||||
|
||||
def test_audit_dsfa_id_can_be_none(self):
|
||||
entry = {"dsfa_id": None}
|
||||
assert entry["dsfa_id"] is None
|
||||
|
||||
def test_audit_old_values_can_be_none(self):
|
||||
entry = {"old_values": None, "new_values": {"title": "Test"}}
|
||||
assert entry["old_values"] is None
|
||||
assert entry["new_values"] is not None
|
||||
|
||||
Reference in New Issue
Block a user