"""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 import uuid import os import sys from datetime import datetime, timezone 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.now(timezone.utc).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 # ============================================================================= class TestDSFACreate: def test_minimal_valid(self): req = DSFACreate(title="DSFA - Mitarbeiter-Monitoring") assert req.title == "DSFA - Mitarbeiter-Monitoring" assert req.status == "draft" assert req.risk_level == "low" assert req.description == "" assert req.processing_activity == "" assert req.data_categories == [] assert req.recipients == [] assert req.measures == [] assert req.created_by == "system" def test_full_values(self): req = DSFACreate( title="DSFA - Video-Ueberwachung", description="Videoueberwachung im Buero", status="in-review", risk_level="high", processing_activity="Videoueberwachung zu Sicherheitszwecken", data_categories=["Bilddaten", "Bewegungsdaten"], recipients=["Sicherheitsdienst"], measures=["Loeschfristen", "Hinweisschilder"], created_by="admin", ) assert req.title == "DSFA - Video-Ueberwachung" assert req.status == "in-review" assert req.risk_level == "high" assert req.data_categories == ["Bilddaten", "Bewegungsdaten"] assert req.recipients == ["Sicherheitsdienst"] assert req.measures == ["Loeschfristen", "Hinweisschilder"] assert req.created_by == "admin" def test_draft_is_default_status(self): req = DSFACreate(title="Test") assert req.status == "draft" def test_low_is_default_risk_level(self): req = DSFACreate(title="Test") assert req.risk_level == "low" def test_empty_arrays_default(self): req = DSFACreate(title="Test") assert isinstance(req.data_categories, list) assert isinstance(req.recipients, list) assert isinstance(req.measures, list) assert len(req.data_categories) == 0 def test_serialization_model_dump(self): req = DSFACreate(title="Test", risk_level="critical") data = req.model_dump() assert data["title"] == "Test" assert data["risk_level"] == "critical" assert "status" in data assert "data_categories" in data # ============================================================================= # Schema Tests — DSFAUpdate # ============================================================================= class TestDSFAUpdate: def test_all_optional(self): req = DSFAUpdate() assert req.title is None assert req.description is None assert req.status is None assert req.risk_level is None assert req.processing_activity is None assert req.data_categories is None assert req.recipients is None assert req.measures is None assert req.approved_by is None def test_partial_update_title_only(self): req = DSFAUpdate(title="Neuer Titel") data = req.model_dump(exclude_none=True) assert data == {"title": "Neuer Titel"} def test_partial_update_status_and_risk(self): req = DSFAUpdate(status="approved", risk_level="medium") data = req.model_dump(exclude_none=True) assert data["status"] == "approved" assert data["risk_level"] == "medium" assert "title" not in data def test_update_arrays(self): req = DSFAUpdate(data_categories=["Kontaktdaten"], measures=["Verschluesselung"]) assert req.data_categories == ["Kontaktdaten"] assert req.measures == ["Verschluesselung"] def test_exclude_none_removes_unset(self): req = DSFAUpdate(approved_by="DSB Mueller") data = req.model_dump(exclude_none=True) assert data == {"approved_by": "DSB Mueller"} # ============================================================================= # Schema Tests — DSFAStatusUpdate # ============================================================================= class TestDSFAStatusUpdate: def test_status_only(self): req = DSFAStatusUpdate(status="approved") assert req.status == "approved" assert req.approved_by is None def test_status_with_approved_by(self): req = DSFAStatusUpdate(status="approved", approved_by="DSB Mueller") assert req.status == "approved" assert req.approved_by == "DSB Mueller" def test_in_review_status(self): req = DSFAStatusUpdate(status="in-review") assert req.status == "in-review" def test_needs_update_status(self): req = DSFAStatusUpdate(status="needs-update") 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 # ============================================================================= class TestGetTenantId: def test_none_returns_default(self): assert _get_tenant_id(None) == DEFAULT_TENANT_ID def test_empty_string_returns_empty(self): # Empty string is falsy → returns default assert _get_tenant_id("") == DEFAULT_TENANT_ID def test_custom_tenant_id(self): assert _get_tenant_id("my-tenant") == "my-tenant" def test_default_constant_value(self): assert DEFAULT_TENANT_ID == "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" # ============================================================================= # Helper Tests — _dsfa_to_response # ============================================================================= class TestDsfaToResponse: def _make_row(self, **overrides): defaults = { # Core fields "id": "abc123", "tenant_id": "default", "title": "Test DSFA", "description": "Testbeschreibung", "status": "draft", "risk_level": "low", "processing_activity": "Test-Verarbeitung", "data_categories": ["Kontaktdaten"], "recipients": ["HR"], "measures": ["Verschluesselung"], "approved_by": None, "approved_at": None, "created_by": "system", "created_at": datetime(2026, 1, 1, 12, 0, 0), "updated_at": datetime(2026, 1, 2, 12, 0, 0), # Section 1 (Migration 030) "processing_description": None, "processing_purpose": None, "legal_basis": None, "legal_basis_details": None, # Section 2 "necessity_assessment": None, "proportionality_assessment": None, "data_minimization": None, "alternatives_considered": None, "retention_justification": None, # Section 3 "involves_ai": False, "overall_risk_level": None, "risk_score": 0, # Section 6 "dpo_consulted": False, "dpo_consulted_at": None, "dpo_name": None, "dpo_opinion": None, "dpo_approved": None, "authority_consulted": False, "authority_consulted_at": None, "authority_reference": None, "authority_decision": None, # Metadata "version": 1, "previous_version_id": None, "conclusion": None, "federal_state": None, "authority_resource_id": None, "submitted_for_review_at": None, "submitted_by": None, # JSONB Arrays "data_subjects": [], "affected_rights": [], "triggered_rule_codes": [], "ai_trigger_ids": [], "wp248_criteria_met": [], "art35_abs3_triggered": [], "tom_references": [], "risks": [], "mitigations": [], "stakeholder_consultations": [], "review_triggers": [], "review_comments": [], # Section 8 (Migration 028) "ai_use_case_modules": [], "section_8_complete": False, # JSONB Objects "threshold_analysis": None, "consultation_requirement": None, "review_schedule": None, "section_progress": {}, "metadata": {}, } defaults.update(overrides) row = MagicMock() row.__getitem__ = lambda self, key: defaults[key] return row def test_basic_fields(self): row = self._make_row() result = _dsfa_to_response(row) assert result["id"] == "abc123" assert result["title"] == "Test DSFA" assert result["status"] == "draft" assert result["risk_level"] == "low" def test_dates_as_iso_strings(self): row = self._make_row() result = _dsfa_to_response(row) assert result["created_at"] == "2026-01-01T12:00:00" assert result["updated_at"] == "2026-01-02T12:00:00" def test_approved_at_none_when_not_set(self): row = self._make_row(approved_at=None) result = _dsfa_to_response(row) assert result["approved_at"] is None def test_approved_at_iso_when_set(self): row = self._make_row(approved_at=datetime(2026, 3, 1, 10, 0, 0)) result = _dsfa_to_response(row) assert result["approved_at"] == "2026-03-01T10:00:00" def test_null_description_becomes_empty_string(self): row = self._make_row(description=None) result = _dsfa_to_response(row) assert result["description"] == "" def test_json_string_data_categories_parsed(self): import json row = self._make_row(data_categories=json.dumps(["Kontaktdaten", "Finanzdaten"])) result = _dsfa_to_response(row) assert result["data_categories"] == ["Kontaktdaten", "Finanzdaten"] def test_null_arrays_become_empty_lists(self): row = self._make_row(data_categories=None, recipients=None, measures=None) result = _dsfa_to_response(row) assert result["data_categories"] == [] assert result["recipients"] == [] assert result["measures"] == [] def test_null_status_defaults_to_draft(self): row = self._make_row(status=None) result = _dsfa_to_response(row) assert result["status"] == "draft" def test_null_risk_level_defaults_to_low(self): row = self._make_row(risk_level=None) result = _dsfa_to_response(row) assert result["risk_level"] == "low" # ============================================================================= # Valid Status Values # ============================================================================= class TestValidStatusValues: def test_draft_is_valid(self): assert "draft" in VALID_STATUSES def test_in_review_is_valid(self): assert "in-review" in VALID_STATUSES def test_approved_is_valid(self): assert "approved" in VALID_STATUSES def test_needs_update_is_valid(self): assert "needs-update" in VALID_STATUSES def test_invalid_status_not_in_set(self): assert "invalid_status" not in VALID_STATUSES def test_all_four_statuses_covered(self): assert len(VALID_STATUSES) == 4 # ============================================================================= # Valid Risk Levels # ============================================================================= class TestValidRiskLevels: def test_low_is_valid(self): assert "low" in VALID_RISK_LEVELS def test_medium_is_valid(self): assert "medium" in VALID_RISK_LEVELS def test_high_is_valid(self): assert "high" in VALID_RISK_LEVELS def test_critical_is_valid(self): assert "critical" in VALID_RISK_LEVELS def test_invalid_risk_not_in_set(self): assert "extreme" not in VALID_RISK_LEVELS def test_all_four_levels_covered(self): assert len(VALID_RISK_LEVELS) == 4 # ============================================================================= # Router Config # ============================================================================= class TestDSFARouterConfig: def test_router_prefix(self): assert dsfa_router.prefix == "/dsfa" def test_router_has_tags(self): assert "compliance-dsfa" in dsfa_router.tags def test_router_registered_in_init(self): from compliance.api import dsfa_router as imported_router assert imported_router is not None # ============================================================================= # Route Integration Tests — CRUD # ============================================================================= class TestDSFARouteCRUD: """Integration tests using TestClient + SQLite.""" def test_list_dsfas_empty(self): resp = client.get("/api/compliance/dsfa") assert resp.status_code == 200 assert resp.json() == [] 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_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_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 == 400 # ValidationError -> 400 def test_create_invalid_risk_level(self): resp = client.post("/api/compliance/dsfa", json={"title": "Bad", "risk_level": "extreme"}) assert resp.status_code == 400 # ValidationError -> 400 # ============================================================================= # Route Integration Tests — Stats # ============================================================================= 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_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 # ============================================================================= # 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 == 400 # ValidationError -> 400 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 == 400 # ValidationError -> 400 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 == 400 # ValidationError -> 400 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 == 400 # ValidationError -> 400 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() == [] # ============================================================================= # TestAIUseCaseModules — Section 8 KI-Anwendungsfälle (Migration 028) # ============================================================================= class TestAIUseCaseModules: """Tests for ai_use_case_modules field (DSFACreate/DSFAUpdate Pydantic schemas).""" def test_ai_use_case_modules_field_accepted_in_create(self): req = DSFACreate(title="Test", ai_use_case_modules=[{"type": "generative_ai"}]) assert req.ai_use_case_modules == [{"type": "generative_ai"}] def test_ai_use_case_modules_default_none_in_create(self): req = DSFACreate(title="Test") assert req.ai_use_case_modules is None def test_ai_use_case_modules_field_accepted_in_update(self): req = DSFAUpdate(ai_use_case_modules=[{"type": "computer_vision", "name": "Bilderkennung"}]) assert req.ai_use_case_modules == [{"type": "computer_vision", "name": "Bilderkennung"}] def test_ai_use_case_modules_empty_list_accepted(self): req = DSFAUpdate(ai_use_case_modules=[]) assert req.ai_use_case_modules == [] def test_ai_use_case_modules_multiple_modules(self): modules = [ {"type": "generative_ai", "name": "LLM-Assistent"}, {"type": "predictive_analytics", "name": "Risikobewertung"}, ] req = DSFAUpdate(ai_use_case_modules=modules) assert len(req.ai_use_case_modules) == 2 def test_module_generative_ai_type(self): module = {"type": "generative_ai", "name": "Text-Generator"} req = DSFAUpdate(ai_use_case_modules=[module]) assert req.ai_use_case_modules[0]["type"] == "generative_ai" def test_module_art22_assessment_structure(self): module = { "type": "decision_support", "art22_relevant": True, "art22_assessment": {"automated_decision": True, "human_oversight": True}, } req = DSFAUpdate(ai_use_case_modules=[module]) assert req.ai_use_case_modules[0]["art22_relevant"] is True def test_module_ai_act_risk_class_values(self): for risk_class in ["minimal", "limited", "high", "unacceptable"]: module = {"type": "nlp", "ai_act_risk_class": risk_class} req = DSFAUpdate(ai_use_case_modules=[module]) assert req.ai_use_case_modules[0]["ai_act_risk_class"] == risk_class def test_module_risk_criteria_structure(self): module = { "type": "computer_vision", "risk_criteria": [ {"criterion": "K1", "met": True, "justification": "Scoring vorhanden"}, {"criterion": "K3", "met": True, "justification": "Systematische Überwachung"}, ], } req = DSFAUpdate(ai_use_case_modules=[module]) assert len(req.ai_use_case_modules[0]["risk_criteria"]) == 2 def test_module_privacy_by_design_measures(self): module = { "type": "recommendation", "privacy_by_design": ["data_minimization", "pseudonymization"], } req = DSFAUpdate(ai_use_case_modules=[module]) assert "data_minimization" in req.ai_use_case_modules[0]["privacy_by_design"] def test_module_review_triggers(self): req = DSFAUpdate(review_triggers=[{"trigger": "model_update", "date": "2026-06-01"}]) assert req.review_triggers[0]["trigger"] == "model_update" def test_section_8_complete_flag_in_create(self): req = DSFACreate(title="Test", section_8_complete=True) assert req.section_8_complete is True def test_section_8_complete_flag_in_update(self): req = DSFAUpdate(section_8_complete=True) data = req.model_dump(exclude_none=True) assert data["section_8_complete"] is True def test_section_8_complete_default_none(self): req = DSFAUpdate() assert req.section_8_complete is None def test_ai_use_case_modules_excluded_when_none(self): req = DSFAUpdate(title="Test") data = req.model_dump(exclude_none=True) assert "ai_use_case_modules" not in data def test_ai_use_case_modules_included_when_set(self): req = DSFAUpdate(ai_use_case_modules=[{"type": "nlp"}]) data = req.model_dump(exclude_none=True) assert "ai_use_case_modules" in data def test_module_with_all_common_fields(self): module = { "type": "predictive_analytics", "name": "Fraud Detection", "description": "Erkennung betrügerischer Aktivitäten", "data_inputs": ["Transaktionsdaten", "Verhaltensdaten"], "ai_act_risk_class": "high", "art22_relevant": True, } req = DSFAUpdate(ai_use_case_modules=[module]) m = req.ai_use_case_modules[0] assert m["name"] == "Fraud Detection" assert m["ai_act_risk_class"] == "high" def test_response_ai_use_case_modules_list_from_list(self): """_dsfa_to_response: ai_use_case_modules list passthrough.""" helper = TestDsfaToResponse() modules = [{"type": "nlp", "name": "Test"}] row = helper._make_row(ai_use_case_modules=modules) result = _dsfa_to_response(row) assert result["ai_use_case_modules"] == modules def test_response_ai_use_case_modules_from_json_string(self): """_dsfa_to_response: parses JSON string for ai_use_case_modules.""" helper = TestDsfaToResponse() modules = [{"type": "computer_vision"}] row = helper._make_row(ai_use_case_modules=_json.dumps(modules)) result = _dsfa_to_response(row) assert result["ai_use_case_modules"] == modules def test_response_ai_use_case_modules_null_becomes_empty_list(self): """_dsfa_to_response: None → empty list.""" helper = TestDsfaToResponse() row = helper._make_row(ai_use_case_modules=None) result = _dsfa_to_response(row) assert result["ai_use_case_modules"] == [] def test_response_section_8_complete_flag(self): """_dsfa_to_response: section_8_complete bool preserved.""" helper = TestDsfaToResponse() row = helper._make_row(section_8_complete=True) result = _dsfa_to_response(row) assert result["section_8_complete"] is True # ============================================================================= # TestDSFAFullSchema — Migration 030 neue Felder # ============================================================================= class TestDSFAFullSchema: """Tests for all new fields added in Migration 030.""" def _make_row(self, **overrides): """Reuse the shared helper from TestDsfaToResponse.""" helper = TestDsfaToResponse() return helper._make_row(**overrides) # --- Pydantic Schema Tests --- def test_processing_description_accepted(self): req = DSFAUpdate(processing_description="Verarbeitung von Kundendaten zur Risikoanalyse") assert req.processing_description == "Verarbeitung von Kundendaten zur Risikoanalyse" def test_legal_basis_accepted(self): req = DSFAUpdate(legal_basis="Art. 6 Abs. 1f DSGVO") data = req.model_dump(exclude_none=True) assert data["legal_basis"] == "Art. 6 Abs. 1f DSGVO" def test_dpo_consulted_bool(self): req = DSFAUpdate(dpo_consulted=True, dpo_name="Dr. Müller") assert req.dpo_consulted is True assert req.dpo_name == "Dr. Müller" def test_dpo_approved_bool(self): req = DSFAUpdate(dpo_approved=True) data = req.model_dump(exclude_none=True) assert data["dpo_approved"] is True def test_authority_consulted_bool(self): req = DSFAUpdate(authority_consulted=True, authority_reference="AZ-2026-001") assert req.authority_consulted is True assert req.authority_reference == "AZ-2026-001" def test_risks_jsonb_structure(self): risks = [ {"id": "R1", "title": "Datenpanne", "likelihood": "medium", "impact": "high"}, {"id": "R2", "title": "Unbefugter Zugriff", "likelihood": "low", "impact": "critical"}, ] req = DSFAUpdate(risks=risks) assert len(req.risks) == 2 assert req.risks[0]["title"] == "Datenpanne" def test_mitigations_jsonb_structure(self): mitigations = [ {"id": "M1", "measure": "Verschlüsselung", "risk_ref": "R1"}, ] req = DSFAUpdate(mitigations=mitigations) assert req.mitigations[0]["measure"] == "Verschlüsselung" def test_review_schedule_jsonb(self): schedule = {"next_review": "2027-01-01", "frequency": "annual", "responsible": "DSB"} req = DSFAUpdate(review_schedule=schedule) assert req.review_schedule["frequency"] == "annual" def test_section_progress_jsonb(self): progress = {"section_1": True, "section_2": False, "section_3": True} req = DSFAUpdate(section_progress=progress) assert req.section_progress["section_1"] is True def test_threshold_analysis_jsonb(self): analysis = {"wp248_criteria_count": 3, "dsfa_required": True} req = DSFAUpdate(threshold_analysis=analysis) assert req.threshold_analysis["dsfa_required"] is True def test_involves_ai_bool(self): req = DSFAUpdate(involves_ai=True) data = req.model_dump(exclude_none=True) assert data["involves_ai"] is True def test_federal_state_accepted(self): req = DSFAUpdate(federal_state="Bayern") data = req.model_dump(exclude_none=True) assert data["federal_state"] == "Bayern" def test_data_subjects_list(self): req = DSFAUpdate(data_subjects=["Kunden", "Mitarbeiter", "Minderjährige"]) assert len(req.data_subjects) == 3 def test_wp248_criteria_met_list(self): req = DSFAUpdate(wp248_criteria_met=["K1", "K3", "K5"]) assert "K3" in req.wp248_criteria_met def test_conclusion_text(self): req = DSFAUpdate(conclusion="DSFA erforderlich — hohe Risiken verbleiben nach Maßnahmen.") assert "DSFA erforderlich" in req.conclusion def test_all_new_fields_optional_in_update(self): req = DSFAUpdate() for field in [ "processing_description", "processing_purpose", "legal_basis", "necessity_assessment", "proportionality_assessment", "involves_ai", "dpo_consulted", "dpo_opinion", "dpo_approved", "authority_consulted", "risks", "mitigations", "section_progress", "threshold_analysis", "federal_state", "conclusion", ]: assert getattr(req, field) is None, f"{field} should default to None" # --- _dsfa_to_response Tests --- def test_response_processing_description(self): row = self._make_row(processing_description="Test-Beschreibung") result = _dsfa_to_response(row) assert result["processing_description"] == "Test-Beschreibung" def test_response_risks_parsed_from_json_string(self): risks = [{"id": "R1", "title": "Datenpanne"}] row = self._make_row(risks=_json.dumps(risks)) result = _dsfa_to_response(row) assert result["risks"] == risks def test_response_section_progress_object(self): progress = {"section_1": True, "section_3": False} row = self._make_row(section_progress=progress) result = _dsfa_to_response(row) assert result["section_progress"]["section_1"] is True def test_response_section_progress_from_json_string(self): progress = {"section_2": True} row = self._make_row(section_progress=_json.dumps(progress)) result = _dsfa_to_response(row) assert result["section_progress"] == progress def test_response_involves_ai_bool(self): row = self._make_row(involves_ai=True) result = _dsfa_to_response(row) assert result["involves_ai"] is True def test_response_dpo_consulted_bool(self): row = self._make_row(dpo_consulted=True, dpo_name="Dr. Müller") result = _dsfa_to_response(row) assert result["dpo_consulted"] is True assert result["dpo_name"] == "Dr. Müller" def test_response_version_defaults_to_1(self): row = self._make_row(version=None) result = _dsfa_to_response(row) assert result["version"] == 1 def test_response_null_risks_becomes_empty_list(self): row = self._make_row(risks=None) result = _dsfa_to_response(row) assert result["risks"] == [] def test_response_null_section_progress_becomes_empty_dict(self): row = self._make_row(section_progress=None) result = _dsfa_to_response(row) assert result["section_progress"] == {} def test_response_threshold_analysis_null_becomes_empty_dict(self): row = self._make_row(threshold_analysis=None) result = _dsfa_to_response(row) assert result["threshold_analysis"] == {} def test_response_federal_state(self): row = self._make_row(federal_state="NRW") result = _dsfa_to_response(row) assert result["federal_state"] == "NRW" def test_response_all_new_keys_present(self): """All new fields must be present in response even with defaults.""" row = self._make_row() result = _dsfa_to_response(row) new_keys = [ "processing_description", "legal_basis", "necessity_assessment", "involves_ai", "dpo_consulted", "authority_consulted", "risks", "mitigations", "section_progress", "threshold_analysis", "ai_use_case_modules", "section_8_complete", "federal_state", "version", "conclusion", ] 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