Files
breakpilot-compliance/backend-compliance/tests/test_vvt_library_routes.py
Benjamin Admin 2a70441eaa feat(sdk): VVT master libraries, process templates, Loeschfristen profiling + document
VVT: Master library tables (7 catalogs), 500+ seed entries, process templates
with instantiation, library API endpoints + 18 tests.
Loeschfristen: Baseline catalog, compliance checks, profiling engine, HTML document
generator, MkDocs documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:56:25 +01:00

1100 lines
38 KiB
Python

"""Tests for VVT Library + Template routes (vvt_library_routes.py, vvt_library_models.py)."""
import pytest
import uuid
from unittest.mock import MagicMock, patch
from datetime import datetime
from compliance.db.vvt_library_models import (
VVTLibDataSubjectDB,
VVTLibDataCategoryDB,
VVTLibRecipientDB,
VVTLibLegalBasisDB,
VVTLibRetentionRuleDB,
VVTLibTransferMechanismDB,
VVTLibPurposeDB,
VVTLibTomDB,
VVTProcessTemplateDB,
)
from compliance.db.vvt_models import VVTActivityDB
from compliance.api.vvt_library_routes import (
_row_to_dict,
_resolve_ids,
_template_to_dict,
)
# =============================================================================
# Model Tests
# =============================================================================
class TestLibraryModels:
def test_data_subject_model(self):
ds = VVTLibDataSubjectDB(
id="EMPLOYEES",
label_de="Beschaeftigte",
description_de="Aktuelle Mitarbeiter",
art9_relevant=False,
typical_for=["hr"],
sort_order=1,
)
assert ds.id == "EMPLOYEES"
assert ds.label_de == "Beschaeftigte"
assert ds.art9_relevant is False
def test_data_category_hierarchical(self):
parent = VVTLibDataCategoryDB(
id="IDENTIFICATION",
label_de="Identifikationsdaten",
is_art9=False,
is_art10=False,
risk_weight=2,
)
child = VVTLibDataCategoryDB(
id="NAME",
parent_id="IDENTIFICATION",
label_de="Name",
is_art9=False,
is_art10=False,
risk_weight=1,
)
assert parent.parent_id is None
assert child.parent_id == "IDENTIFICATION"
def test_data_category_art9(self):
cat = VVTLibDataCategoryDB(
id="HEALTH_DATA",
parent_id="ART9_SPECIAL",
label_de="Gesundheitsdaten",
is_art9=True,
risk_weight=5,
)
assert cat.is_art9 is True
assert cat.risk_weight == 5
def test_recipient_model(self):
rec = VVTLibRecipientDB(
id="PROCESSOR_PAYROLL",
type="PROCESSOR",
label_de="Lohnabrechnungsdienstleister",
is_third_country=False,
country="DE",
)
assert rec.type == "PROCESSOR"
assert rec.is_third_country is False
def test_legal_basis_model(self):
lb = VVTLibLegalBasisDB(
id="ART6_1A",
article="Art. 6 Abs. 1 lit. a",
type="CONSENT",
label_de="Einwilligung",
is_art9=False,
)
assert lb.type == "CONSENT"
assert lb.article == "Art. 6 Abs. 1 lit. a"
def test_retention_rule_model(self):
rr = VVTLibRetentionRuleDB(
id="HGB_257_10Y",
label_de="10 Jahre (HGB)",
duration=10,
duration_unit="YEARS",
start_event="Ende des Kalenderjahres",
)
assert rr.duration == 10
assert rr.duration_unit == "YEARS"
def test_transfer_mechanism_model(self):
tm = VVTLibTransferMechanismDB(
id="SCC_PROCESSOR",
label_de="Standardvertragsklauseln",
article="Art. 46",
requires_tia=True,
)
assert tm.requires_tia is True
def test_purpose_model(self):
p = VVTLibPurposeDB(
id="PAYROLL",
label_de="Gehaltsabrechnung",
typical_legal_basis="BDSG_26",
typical_for=["hr"],
)
assert p.typical_legal_basis == "BDSG_26"
def test_tom_model(self):
t = VVTLibTomDB(
id="AC_RBAC",
category="accessControl",
label_de="Rollenbasierte Zugriffskontrolle",
art32_reference="Art. 32 Abs. 1 lit. b",
)
assert t.category == "accessControl"
def test_process_template_model(self):
tpl = VVTProcessTemplateDB(
id="hr-mitarbeiterverwaltung",
name="Mitarbeiterverwaltung",
business_function="hr",
purpose_refs=["EMPLOYMENT_ADMIN", "PAYROLL"],
data_subject_refs=["EMPLOYEES"],
retention_rule_ref="HGB_257_10Y",
is_system=True,
)
assert tpl.is_system is True
assert len(tpl.purpose_refs) == 2
# =============================================================================
# Helper Function Tests
# =============================================================================
class TestRowToDict:
def test_basic_fields(self):
row = MagicMock()
row.id = "TEST"
row.label_de = "Test Label"
row.description_de = "Test Description"
row.sort_order = 1
result = _row_to_dict(row)
assert result["id"] == "TEST"
assert result["label_de"] == "Test Label"
assert result["description_de"] == "Test Description"
def test_extra_fields(self):
row = MagicMock()
row.id = "ART6_1A"
row.label_de = "Einwilligung"
row.description_de = None
row.sort_order = 1
row.article = "Art. 6"
row.type = "CONSENT"
result = _row_to_dict(row, ["article", "type"])
assert result["article"] == "Art. 6"
assert result["type"] == "CONSENT"
assert "description_de" not in result # None is excluded
def test_none_extra_fields_excluded(self):
row = MagicMock()
row.id = "X"
row.label_de = "X"
row.description_de = None
row.sort_order = 0
row.country = None
result = _row_to_dict(row, ["country"])
assert "country" not in result
class TestTemplateToDict:
def test_template_to_dict(self):
tpl = MagicMock()
tpl.id = "hr-test"
tpl.name = "HR Test"
tpl.description = "Test desc"
tpl.business_function = "hr"
tpl.purpose_refs = ["PAYROLL"]
tpl.legal_basis_refs = ["BDSG_26"]
tpl.data_subject_refs = ["EMPLOYEES"]
tpl.data_category_refs = ["NAME"]
tpl.recipient_refs = ["INTERNAL_HR"]
tpl.tom_refs = ["AC_RBAC"]
tpl.transfer_mechanism_refs = []
tpl.retention_rule_ref = "HGB_257_10Y"
tpl.typical_systems = ["SAP"]
tpl.protection_level = "HIGH"
tpl.dpia_required = False
tpl.risk_score = 3
tpl.tags = ["hr"]
tpl.is_system = True
tpl.sort_order = 1
result = _template_to_dict(tpl)
assert result["id"] == "hr-test"
assert result["name"] == "HR Test"
assert result["purpose_refs"] == ["PAYROLL"]
assert result["retention_rule_ref"] == "HGB_257_10Y"
def test_template_empty_refs(self):
tpl = MagicMock()
tpl.id = "empty"
tpl.name = "Empty"
tpl.description = None
tpl.business_function = "other"
tpl.purpose_refs = None
tpl.legal_basis_refs = None
tpl.data_subject_refs = None
tpl.data_category_refs = None
tpl.recipient_refs = None
tpl.tom_refs = None
tpl.transfer_mechanism_refs = None
tpl.retention_rule_ref = None
tpl.typical_systems = None
tpl.protection_level = None
tpl.dpia_required = None
tpl.risk_score = None
tpl.tags = None
tpl.is_system = True
tpl.sort_order = 0
result = _template_to_dict(tpl)
assert result["purpose_refs"] == []
assert result["retention_rule_ref"] is None
assert result["protection_level"] == "MEDIUM"
class TestResolveIds:
def test_resolve_ids_basic(self):
mock_db = MagicMock()
row1 = MagicMock()
row1.id = "PAYROLL"
row1.label_de = "Gehaltsabrechnung"
row2 = MagicMock()
row2.id = "CRM"
row2.label_de = "Kundenbeziehungsmanagement"
mock_db.query.return_value.filter.return_value.all.return_value = [row1, row2]
result = _resolve_ids(mock_db, VVTLibPurposeDB, ["PAYROLL", "CRM"])
assert result == ["Gehaltsabrechnung", "Kundenbeziehungsmanagement"]
def test_resolve_ids_empty(self):
mock_db = MagicMock()
result = _resolve_ids(mock_db, VVTLibPurposeDB, [])
assert result == []
def test_resolve_ids_missing_fallback(self):
mock_db = MagicMock()
row1 = MagicMock()
row1.id = "PAYROLL"
row1.label_de = "Gehaltsabrechnung"
mock_db.query.return_value.filter.return_value.all.return_value = [row1]
result = _resolve_ids(mock_db, VVTLibPurposeDB, ["PAYROLL", "UNKNOWN"])
assert result == ["Gehaltsabrechnung", "UNKNOWN"]
# =============================================================================
# FastAPI Endpoint Tests (via TestClient)
# =============================================================================
class TestLibraryEndpoints:
"""Tests using FastAPI app.dependency_overrides for DB mocking."""
@pytest.fixture
def client(self):
from fastapi.testclient import TestClient
from fastapi import FastAPI
from classroom_engine.database import get_db
from compliance.api.tenant_utils import get_tenant_id
from compliance.api.vvt_library_routes import router
app = FastAPI()
app.include_router(router, prefix="/api/compliance")
mock_db = MagicMock()
app.dependency_overrides[get_db] = lambda: mock_db
app.dependency_overrides[get_tenant_id] = lambda: "test-tenant"
client = TestClient(app)
client.mock_db = mock_db
return client
# Libraries overview
def test_libraries_overview(self, client):
client.mock_db.query.return_value.count.return_value = 5
res = client.get("/api/compliance/vvt/libraries")
assert res.status_code == 200
data = res.json()
assert "libraries" in data
assert len(data["libraries"]) == 8
# Data Subjects
def test_list_data_subjects(self, client):
row = MagicMock()
row.id = "EMPLOYEES"
row.label_de = "Beschaeftigte"
row.description_de = "Aktuelle Mitarbeiter"
row.art9_relevant = False
row.typical_for = ["hr"]
row.sort_order = 1
client.mock_db.query.return_value.order_by.return_value.all.return_value = [row]
res = client.get("/api/compliance/vvt/libraries/data-subjects")
assert res.status_code == 200
data = res.json()
assert len(data) == 1
assert data[0]["id"] == "EMPLOYEES"
def test_list_data_subjects_filter(self, client):
row1 = MagicMock()
row1.id = "EMPLOYEES"
row1.label_de = "Beschaeftigte"
row1.description_de = None
row1.art9_relevant = False
row1.typical_for = ["hr"]
row1.sort_order = 1
row2 = MagicMock()
row2.id = "CUSTOMERS"
row2.label_de = "Kunden"
row2.description_de = None
row2.art9_relevant = False
row2.typical_for = ["sales_crm"]
row2.sort_order = 3
client.mock_db.query.return_value.order_by.return_value.all.return_value = [row1, row2]
res = client.get("/api/compliance/vvt/libraries/data-subjects?typical_for=hr")
assert res.status_code == 200
data = res.json()
assert len(data) == 1
assert data[0]["id"] == "EMPLOYEES"
# Data Categories
def test_list_data_categories_flat(self, client):
row = MagicMock()
row.id = "NAME"
row.label_de = "Name"
row.description_de = None
row.parent_id = "IDENTIFICATION"
row.is_art9 = False
row.is_art10 = False
row.risk_weight = 1
row.default_retention_rule = None
row.default_legal_basis = None
row.sort_order = 10
client.mock_db.query.return_value.order_by.return_value.all.return_value = [row]
res = client.get("/api/compliance/vvt/libraries/data-categories?flat=true")
assert res.status_code == 200
data = res.json()
assert len(data) == 1
assert data[0]["parent_id"] == "IDENTIFICATION"
def test_list_data_categories_tree(self, client):
parent = MagicMock()
parent.id = "IDENTIFICATION"
parent.label_de = "Identifikationsdaten"
parent.description_de = None
parent.parent_id = None
parent.is_art9 = False
parent.is_art10 = False
parent.risk_weight = 2
parent.default_retention_rule = None
parent.default_legal_basis = None
parent.sort_order = 1
child = MagicMock()
child.id = "NAME"
child.label_de = "Name"
child.description_de = None
child.parent_id = "IDENTIFICATION"
child.is_art9 = False
child.is_art10 = False
child.risk_weight = 1
child.default_retention_rule = None
child.default_legal_basis = None
child.sort_order = 10
client.mock_db.query.return_value.order_by.return_value.all.return_value = [parent, child]
res = client.get("/api/compliance/vvt/libraries/data-categories")
assert res.status_code == 200
data = res.json()
assert len(data) == 1 # only parent at top level
assert data[0]["id"] == "IDENTIFICATION"
assert len(data[0]["children"]) == 1
assert data[0]["children"][0]["id"] == "NAME"
def test_list_data_categories_filter_art9(self, client):
row = MagicMock()
row.id = "HEALTH_DATA"
row.label_de = "Gesundheitsdaten"
row.description_de = None
row.parent_id = "ART9_SPECIAL"
row.is_art9 = True
row.is_art10 = False
row.risk_weight = 5
row.default_retention_rule = None
row.default_legal_basis = None
row.sort_order = 80
client.mock_db.query.return_value.order_by.return_value.filter.return_value.all.return_value = [row]
# Adjusting for chained filter calls
client.mock_db.query.return_value.order_by.return_value.all.return_value = [row]
res = client.get("/api/compliance/vvt/libraries/data-categories?is_art9=true&flat=true")
assert res.status_code == 200
# Recipients
def test_list_recipients(self, client):
row = MagicMock()
row.id = "INTERNAL_HR"
row.label_de = "Personalabteilung"
row.description_de = None
row.type = "INTERNAL"
row.is_third_country = False
row.country = "DE"
row.sort_order = 1
client.mock_db.query.return_value.order_by.return_value.all.return_value = [row]
res = client.get("/api/compliance/vvt/libraries/recipients")
assert res.status_code == 200
data = res.json()
assert data[0]["type"] == "INTERNAL"
def test_list_recipients_filter_type(self, client):
row = MagicMock()
row.id = "PROCESSOR_PAYROLL"
row.label_de = "Lohnabrechnung"
row.description_de = None
row.type = "PROCESSOR"
row.is_third_country = False
row.country = "DE"
row.sort_order = 7
client.mock_db.query.return_value.order_by.return_value.filter.return_value.all.return_value = [row]
res = client.get("/api/compliance/vvt/libraries/recipients?type=PROCESSOR")
assert res.status_code == 200
# Legal Bases
def test_list_legal_bases(self, client):
row = MagicMock()
row.id = "ART6_1A"
row.label_de = "Einwilligung"
row.description_de = None
row.article = "Art. 6"
row.type = "CONSENT"
row.is_art9 = False
row.typical_national_law = None
row.sort_order = 1
client.mock_db.query.return_value.order_by.return_value.all.return_value = [row]
res = client.get("/api/compliance/vvt/libraries/legal-bases")
assert res.status_code == 200
data = res.json()
assert data[0]["article"] == "Art. 6"
# Retention Rules
def test_list_retention_rules(self, client):
row = MagicMock()
row.id = "HGB_257_10Y"
row.label_de = "10 Jahre (HGB)"
row.description_de = None
row.legal_basis = "HGB § 257"
row.duration = 10
row.duration_unit = "YEARS"
row.start_event = "Ende des Kalenderjahres"
row.deletion_procedure = "Vernichtung"
row.sort_order = 1
client.mock_db.query.return_value.order_by.return_value.all.return_value = [row]
res = client.get("/api/compliance/vvt/libraries/retention-rules")
assert res.status_code == 200
data = res.json()
assert data[0]["duration"] == 10
assert data[0]["duration_unit"] == "YEARS"
# Transfer Mechanisms
def test_list_transfer_mechanisms(self, client):
row = MagicMock()
row.id = "SCC_PROCESSOR"
row.label_de = "SCC"
row.description_de = None
row.article = "Art. 46"
row.requires_tia = True
row.sort_order = 3
client.mock_db.query.return_value.order_by.return_value.all.return_value = [row]
res = client.get("/api/compliance/vvt/libraries/transfer-mechanisms")
assert res.status_code == 200
data = res.json()
assert data[0]["requires_tia"] is True
# Purposes
def test_list_purposes(self, client):
row = MagicMock()
row.id = "PAYROLL"
row.label_de = "Gehaltsabrechnung"
row.description_de = None
row.typical_legal_basis = "BDSG_26"
row.typical_for = ["hr"]
row.sort_order = 2
client.mock_db.query.return_value.order_by.return_value.all.return_value = [row]
res = client.get("/api/compliance/vvt/libraries/purposes")
assert res.status_code == 200
data = res.json()
assert data[0]["typical_legal_basis"] == "BDSG_26"
# TOMs
def test_list_toms(self, client):
row = MagicMock()
row.id = "AC_RBAC"
row.label_de = "RBAC"
row.description_de = None
row.category = "accessControl"
row.art32_reference = "Art. 32"
row.sort_order = 1
client.mock_db.query.return_value.order_by.return_value.all.return_value = [row]
res = client.get("/api/compliance/vvt/libraries/toms")
assert res.status_code == 200
data = res.json()
assert data[0]["category"] == "accessControl"
def test_list_toms_filter(self, client):
row = MagicMock()
row.id = "CONF_ENCRYPTION_REST"
row.label_de = "Verschluesselung"
row.description_de = None
row.category = "confidentiality"
row.art32_reference = "Art. 32"
row.sort_order = 5
client.mock_db.query.return_value.order_by.return_value.filter.return_value.all.return_value = [row]
res = client.get("/api/compliance/vvt/libraries/toms?category=confidentiality")
assert res.status_code == 200
# Templates
def test_list_templates(self, client):
tpl = MagicMock()
tpl.id = "hr-test"
tpl.name = "HR Test"
tpl.description = "Test"
tpl.business_function = "hr"
tpl.purpose_refs = ["PAYROLL"]
tpl.legal_basis_refs = ["BDSG_26"]
tpl.data_subject_refs = ["EMPLOYEES"]
tpl.data_category_refs = ["NAME"]
tpl.recipient_refs = []
tpl.tom_refs = ["AC_RBAC"]
tpl.transfer_mechanism_refs = []
tpl.retention_rule_ref = "HGB_257_10Y"
tpl.typical_systems = ["SAP"]
tpl.protection_level = "HIGH"
tpl.dpia_required = False
tpl.risk_score = 3
tpl.tags = ["hr"]
tpl.is_system = True
tpl.sort_order = 1
client.mock_db.query.return_value.order_by.return_value.all.return_value = [tpl]
res = client.get("/api/compliance/vvt/templates")
assert res.status_code == 200
data = res.json()
assert len(data) == 1
assert data[0]["id"] == "hr-test"
def test_list_templates_filter_bf(self, client):
client.mock_db.query.return_value.order_by.return_value.filter.return_value.all.return_value = []
res = client.get("/api/compliance/vvt/templates?business_function=finance")
assert res.status_code == 200
def test_list_templates_search(self, client):
client.mock_db.query.return_value.order_by.return_value.filter.return_value.all.return_value = []
res = client.get("/api/compliance/vvt/templates?search=gehalts")
assert res.status_code == 200
def test_get_template(self, client):
tpl = MagicMock()
tpl.id = "hr-test"
tpl.name = "HR Test"
tpl.description = None
tpl.business_function = "hr"
tpl.purpose_refs = ["PAYROLL"]
tpl.legal_basis_refs = []
tpl.data_subject_refs = []
tpl.data_category_refs = []
tpl.recipient_refs = []
tpl.tom_refs = []
tpl.transfer_mechanism_refs = []
tpl.retention_rule_ref = None
tpl.typical_systems = []
tpl.protection_level = "MEDIUM"
tpl.dpia_required = False
tpl.risk_score = None
tpl.tags = []
tpl.is_system = True
tpl.sort_order = 0
client.mock_db.query.return_value.filter.return_value.first.return_value = tpl
# For label resolution queries
client.mock_db.query.return_value.filter.return_value.all.return_value = []
res = client.get("/api/compliance/vvt/templates/hr-test")
assert res.status_code == 200
data = res.json()
assert data["id"] == "hr-test"
def test_get_template_not_found(self, client):
client.mock_db.query.return_value.filter.return_value.first.return_value = None
res = client.get("/api/compliance/vvt/templates/nonexistent")
assert res.status_code == 404
# Template instantiation
def test_instantiate_template(self, client):
tpl = MagicMock()
tpl.id = "hr-test"
tpl.name = "HR Test"
tpl.description = "Test"
tpl.business_function = "hr"
tpl.purpose_refs = ["PAYROLL"]
tpl.legal_basis_refs = ["BDSG_26"]
tpl.data_subject_refs = ["EMPLOYEES"]
tpl.data_category_refs = ["NAME"]
tpl.recipient_refs = ["INTERNAL_HR"]
tpl.tom_refs = ["AC_RBAC"]
tpl.transfer_mechanism_refs = []
tpl.retention_rule_ref = "HGB_257_10Y"
tpl.typical_systems = ["SAP"]
tpl.protection_level = "HIGH"
tpl.dpia_required = False
tpl.risk_score = 3
purpose_row = MagicMock()
purpose_row.id = "PAYROLL"
purpose_row.label_de = "Gehaltsabrechnung"
legal_row = MagicMock()
legal_row.id = "BDSG_26"
legal_row.label_de = "Beschaeftigtenverhaeltnis"
subject_row = MagicMock()
subject_row.id = "EMPLOYEES"
subject_row.label_de = "Beschaeftigte"
cat_row = MagicMock()
cat_row.id = "NAME"
cat_row.label_de = "Name"
rec_row = MagicMock()
rec_row.id = "INTERNAL_HR"
rec_row.label_de = "Personalabteilung"
rr_row = MagicMock()
rr_row.id = "HGB_257_10Y"
rr_row.label_de = "10 Jahre (HGB)"
rr_row.legal_basis = "HGB § 257"
rr_row.deletion_procedure = "Vernichtung"
rr_row.duration = 10
rr_row.duration_unit = "YEARS"
tom_row = MagicMock()
tom_row.id = "AC_RBAC"
tom_row.label_de = "RBAC"
tom_row.category = "accessControl"
def mock_query(model):
mock_q = MagicMock()
if model == VVTProcessTemplateDB:
mock_q.filter.return_value.first.return_value = tpl
elif model == VVTActivityDB:
mock_q.filter.return_value.count.return_value = 5
elif model == VVTLibPurposeDB:
mock_q.filter.return_value.all.return_value = [purpose_row]
elif model == VVTLibLegalBasisDB:
mock_q.filter.return_value.all.return_value = [legal_row]
elif model == VVTLibDataSubjectDB:
mock_q.filter.return_value.all.return_value = [subject_row]
elif model == VVTLibDataCategoryDB:
mock_q.filter.return_value.all.return_value = [cat_row]
elif model == VVTLibRecipientDB:
mock_q.filter.return_value.all.return_value = [rec_row]
elif model == VVTLibRetentionRuleDB:
mock_q.filter.return_value.first.return_value = rr_row
elif model == VVTLibTomDB:
mock_q.filter.return_value.all.return_value = [tom_row]
return mock_q
client.mock_db.query.side_effect = mock_query
created_id = uuid.uuid4()
created_at = datetime.utcnow()
def mock_add(obj):
if hasattr(obj, 'vvt_id'):
obj.id = created_id
obj.created_at = created_at
obj.updated_at = created_at
client.mock_db.add.side_effect = mock_add
client.mock_db.flush.return_value = None
client.mock_db.commit.return_value = None
client.mock_db.refresh.return_value = None
res = client.post(
"/api/compliance/vvt/templates/hr-test/instantiate",
headers={"X-Tenant-ID": "test-tenant", "X-User-ID": "test-user"}
)
# The response contains the created activity — verify it was created
assert res.status_code == 201
data = res.json()
assert data["name"] == "HR Test"
assert data["purpose_refs"] == ["PAYROLL"]
assert data["source_template_id"] == "hr-test"
def test_instantiate_template_not_found(self, client):
def mock_query(model):
mock_q = MagicMock()
mock_q.filter.return_value.first.return_value = None
return mock_q
client.mock_db.query.side_effect = mock_query
res = client.post(
"/api/compliance/vvt/templates/nonexistent/instantiate",
headers={"X-Tenant-ID": "test-tenant"}
)
assert res.status_code == 404
# =============================================================================
# Completeness Calculation Tests
# =============================================================================
class TestCompletenessCalculation:
def test_completeness_full(self):
from compliance.api.vvt_routes import _calculate_completeness
act = MagicMock()
act.name = "Test Activity"
act.purposes = ["Zweck 1"]
act.purpose_refs = None
act.legal_bases = [{"type": "ART6_1B"}]
act.legal_basis_refs = None
act.data_subject_categories = ["Mitarbeiter"]
act.data_subject_refs = None
act.personal_data_categories = ["Name"]
act.data_category_refs = None
act.recipient_categories = [{"type": "INTERNAL", "name": "HR"}]
act.recipient_refs = None
act.third_country_transfers = []
act.transfer_mechanism_refs = None
act.retention_period = {"description": "10 Jahre"}
act.retention_rule_ref = None
act.tom_description = "Verschluesselung"
act.tom_refs = None
act.structured_toms = {}
act.responsible = "Max Mustermann"
act.dpia_required = False
act.dsfa_id = None
result = _calculate_completeness(act)
assert result["score"] == 100
assert result["missing"] == []
def test_completeness_empty(self):
from compliance.api.vvt_routes import _calculate_completeness
act = MagicMock()
act.name = ""
act.purposes = []
act.purpose_refs = None
act.legal_bases = []
act.legal_basis_refs = None
act.data_subject_categories = []
act.data_subject_refs = None
act.personal_data_categories = []
act.data_category_refs = None
act.recipient_categories = []
act.recipient_refs = None
act.third_country_transfers = []
act.transfer_mechanism_refs = None
act.retention_period = {}
act.retention_rule_ref = None
act.tom_description = ""
act.tom_refs = None
act.structured_toms = {}
act.responsible = ""
act.dpia_required = False
act.dsfa_id = None
result = _calculate_completeness(act)
assert result["score"] < 50
assert "name" in result["missing"]
assert "purposes" in result["missing"]
def test_completeness_with_refs_only(self):
from compliance.api.vvt_routes import _calculate_completeness
act = MagicMock()
act.name = "Test"
act.purposes = []
act.purpose_refs = ["PAYROLL"] # refs fill the gap
act.legal_bases = []
act.legal_basis_refs = ["BDSG_26"]
act.data_subject_categories = []
act.data_subject_refs = ["EMPLOYEES"]
act.personal_data_categories = []
act.data_category_refs = ["NAME"]
act.recipient_categories = []
act.recipient_refs = ["INTERNAL_HR"]
act.third_country_transfers = []
act.transfer_mechanism_refs = None
act.retention_period = {}
act.retention_rule_ref = "HGB_257_10Y"
act.tom_description = ""
act.tom_refs = ["AC_RBAC"]
act.structured_toms = {}
act.responsible = "Test Person"
act.dpia_required = False
act.dsfa_id = None
result = _calculate_completeness(act)
assert result["score"] == 100
assert result["missing"] == []
def test_completeness_dpia_warning(self):
from compliance.api.vvt_routes import _calculate_completeness
act = MagicMock()
act.name = "Test"
act.purposes = ["Zweck"]
act.purpose_refs = None
act.legal_bases = [{"type": "ART6_1B"}]
act.legal_basis_refs = None
act.data_subject_categories = ["Mitarbeiter"]
act.data_subject_refs = None
act.personal_data_categories = ["Name"]
act.data_category_refs = None
act.recipient_categories = [{"type": "INTERNAL", "name": "HR"}]
act.recipient_refs = None
act.third_country_transfers = [{"country": "US"}]
act.transfer_mechanism_refs = None
act.retention_period = {"description": "10 Jahre"}
act.retention_rule_ref = None
act.tom_description = "TOM"
act.tom_refs = None
act.structured_toms = {}
act.responsible = "Max"
act.dpia_required = True
act.dsfa_id = None
result = _calculate_completeness(act)
assert "dpia_required_but_no_dsfa_linked" in result["warnings"]
assert "third_country_transfer_without_mechanism" in result["warnings"]
def test_completeness_partial(self):
from compliance.api.vvt_routes import _calculate_completeness
act = MagicMock()
act.name = "Test"
act.purposes = ["Zweck"]
act.purpose_refs = None
act.legal_bases = []
act.legal_basis_refs = None
act.data_subject_categories = ["Mitarbeiter"]
act.data_subject_refs = None
act.personal_data_categories = []
act.data_category_refs = None
act.recipient_categories = []
act.recipient_refs = None
act.third_country_transfers = []
act.transfer_mechanism_refs = None
act.retention_period = {}
act.retention_rule_ref = None
act.tom_description = ""
act.tom_refs = None
act.structured_toms = {}
act.responsible = ""
act.dpia_required = False
act.dsfa_id = None
result = _calculate_completeness(act)
assert 30 <= result["score"] <= 50
assert "legal_bases" in result["missing"]
assert "data_categories" in result["missing"]
assert "recipients" in result["missing"]
# =============================================================================
# VVT Activity Schema Tests — new ref fields
# =============================================================================
class TestVVTActivityCreateWithRefs:
def test_create_with_refs(self):
from compliance.api.schemas import VVTActivityCreate
req = VVTActivityCreate(
vvt_id="VVT-010",
name="Test mit Refs",
purpose_refs=["PAYROLL", "CRM"],
legal_basis_refs=["BDSG_26"],
data_subject_refs=["EMPLOYEES"],
data_category_refs=["NAME", "SALARY_DATA"],
recipient_refs=["INTERNAL_HR"],
retention_rule_ref="HGB_257_10Y",
tom_refs=["AC_RBAC", "CONF_ENCRYPTION_REST"],
source_template_id="hr-mitarbeiterverwaltung",
risk_score=3,
)
assert req.purpose_refs == ["PAYROLL", "CRM"]
assert req.retention_rule_ref == "HGB_257_10Y"
assert req.source_template_id == "hr-mitarbeiterverwaltung"
assert req.risk_score == 3
def test_create_without_refs_backward_compat(self):
from compliance.api.schemas import VVTActivityCreate
req = VVTActivityCreate(
vvt_id="VVT-011",
name="Test ohne Refs",
purposes=["Vertragserfuellung"],
legal_bases=["Art. 6 Abs. 1b"],
)
assert req.purpose_refs is None
assert req.retention_rule_ref is None
assert req.source_template_id is None
def test_serialization_with_refs(self):
from compliance.api.schemas import VVTActivityCreate
req = VVTActivityCreate(
vvt_id="VVT-012",
name="Test",
purpose_refs=["PAYROLL"],
linked_loeschfristen_ids=["uuid-1"],
)
data = req.model_dump()
assert data["purpose_refs"] == ["PAYROLL"]
assert data["linked_loeschfristen_ids"] == ["uuid-1"]
class TestVVTActivityUpdateWithRefs:
def test_partial_update_refs(self):
from compliance.api.schemas import VVTActivityUpdate
req = VVTActivityUpdate(
purpose_refs=["RECRUITING"],
retention_rule_ref="AGG_15_6M",
)
data = req.model_dump(exclude_none=True)
assert data["purpose_refs"] == ["RECRUITING"]
assert data["retention_rule_ref"] == "AGG_15_6M"
assert "name" not in data
def test_update_without_refs_backward_compat(self):
from compliance.api.schemas import VVTActivityUpdate
req = VVTActivityUpdate(status="APPROVED")
data = req.model_dump(exclude_none=True)
assert "purpose_refs" not in data
assert "status" in data
class TestVVTActivityResponseWithRefs:
def test_response_with_refs(self):
from compliance.api.schemas import VVTActivityResponse
resp = VVTActivityResponse(
id="test-id",
vvt_id="VVT-010",
name="Test",
purpose_refs=["PAYROLL"],
legal_basis_refs=["BDSG_26"],
source_template_id="hr-test",
risk_score=3,
art30_completeness={"score": 85, "missing": ["recipients"]},
created_at=datetime.utcnow(),
)
assert resp.purpose_refs == ["PAYROLL"]
assert resp.art30_completeness["score"] == 85
def test_response_without_refs_backward_compat(self):
from compliance.api.schemas import VVTActivityResponse
resp = VVTActivityResponse(
id="test-id",
vvt_id="VVT-011",
name="Test",
created_at=datetime.utcnow(),
)
assert resp.purpose_refs is None
assert resp.retention_rule_ref is None
assert resp.art30_completeness is None
# =============================================================================
# VVT Activity DB Model — new columns
# =============================================================================
class TestVVTActivityDBNewColumns:
def test_new_columns_exist(self):
act = VVTActivityDB(
tenant_id="test",
vvt_id="VVT-001",
name="Test",
purpose_refs=["PAYROLL"],
legal_basis_refs=["BDSG_26"],
data_subject_refs=["EMPLOYEES"],
data_category_refs=["NAME"],
recipient_refs=["INTERNAL_HR"],
retention_rule_ref="HGB_257_10Y",
transfer_mechanism_refs=None,
tom_refs=["AC_RBAC"],
linked_loeschfristen_ids=["uuid-1"],
linked_tom_measure_ids=None,
source_template_id="hr-test",
risk_score=3,
art30_completeness={"score": 80, "missing": ["recipients"]},
)
assert act.purpose_refs == ["PAYROLL"]
assert act.retention_rule_ref == "HGB_257_10Y"
assert act.source_template_id == "hr-test"
def test_new_columns_null_defaults(self):
act = VVTActivityDB(
tenant_id="test",
vvt_id="VVT-002",
name="Test ohne Refs",
)
assert act.purpose_refs is None
assert act.retention_rule_ref is None
assert act.source_template_id is None
assert act.art30_completeness is None
# =============================================================================
# _activity_to_response with refs
# =============================================================================
class TestActivityToResponseWithRefs:
def test_includes_ref_fields(self):
from compliance.api.vvt_routes import _activity_to_response
act = MagicMock()
act.id = uuid.uuid4()
act.vvt_id = "VVT-001"
act.name = "Test"
act.description = "Desc"
act.purposes = ["Zweck"]
act.legal_bases = []
act.data_subject_categories = []
act.personal_data_categories = []
act.recipient_categories = []
act.third_country_transfers = []
act.retention_period = {}
act.tom_description = None
act.business_function = "hr"
act.systems = []
act.deployment_model = "cloud"
act.data_sources = []
act.data_flows = []
act.protection_level = "HIGH"
act.dpia_required = False
act.structured_toms = {}
act.status = "DRAFT"
act.responsible = "Max"
act.owner = "Max"
act.last_reviewed_at = None
act.next_review_at = None
act.created_by = "system"
act.dsfa_id = None
act.purpose_refs = ["PAYROLL"]
act.legal_basis_refs = ["BDSG_26"]
act.data_subject_refs = ["EMPLOYEES"]
act.data_category_refs = ["NAME"]
act.recipient_refs = ["INTERNAL_HR"]
act.retention_rule_ref = "HGB_257_10Y"
act.transfer_mechanism_refs = None
act.tom_refs = ["AC_RBAC"]
act.source_template_id = "hr-test"
act.risk_score = 3
act.linked_loeschfristen_ids = None
act.linked_tom_measure_ids = None
act.art30_completeness = {"score": 90, "missing": ["responsible"]}
act.created_at = datetime.utcnow()
act.updated_at = None
resp = _activity_to_response(act)
assert resp.purpose_refs == ["PAYROLL"]
assert resp.retention_rule_ref == "HGB_257_10Y"
assert resp.source_template_id == "hr-test"
assert resp.art30_completeness["score"] == 90