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>
1100 lines
38 KiB
Python
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
|