All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 47s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 18s
Migration 019: 5 neue Herkunftsspalten (source_url, source_repo,
source_file_path, source_retrieved_at, attribution_text, inspiration_sources)
ermöglichen lückenlosen Nachweis jeder Template-Quelle.
Neue Templates:
DE: AVV (Art. 28 DSGVO), Widerrufsbelehrung (EGBGB Anlage 1, §5 UrhG),
Cookie-Richtlinie (TTDSG §25)
EN: Privacy Policy (GDPR), Terms of Service (EU Directive 2011/83),
Data Processing Agreement (GDPR Art. 28)
Gesamt: 9 Templates — 5 DE, 4 EN | 6 document_type-Werte
- VALID_DOCUMENT_TYPES um 3 neue Typen erweitert
- Create/Update-Schemas: attribution fields ergänzt
- Status-Endpoint: alle 6 Typen in by_type
- Tests: 34/34 — alle grün
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
465 lines
19 KiB
Python
465 lines
19 KiB
Python
"""Tests for Legal Template routes and schemas (legal_template_routes.py)."""
|
|
|
|
import json
|
|
import pytest
|
|
from datetime import datetime
|
|
from unittest.mock import MagicMock, patch
|
|
from fastapi.testclient import TestClient
|
|
from fastapi import FastAPI
|
|
|
|
from compliance.api.legal_template_routes import (
|
|
LegalTemplateCreate,
|
|
LegalTemplateUpdate,
|
|
_row_to_dict,
|
|
_get_tenant_id,
|
|
DEFAULT_TENANT_ID,
|
|
VALID_DOCUMENT_TYPES,
|
|
VALID_STATUSES,
|
|
router,
|
|
)
|
|
from classroom_engine.database import get_db
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
|
|
DEFAULT_TENANT = DEFAULT_TENANT_ID
|
|
TEMPLATE_ID = "ffffffff-0001-0001-0001-000000000001"
|
|
UNKNOWN_ID = "aaaaaaaa-9999-9999-9999-999999999999"
|
|
|
|
DSE_PLACEHOLDERS = [
|
|
"{{COMPANY_NAME}}", "{{COMPANY_ADDRESS}}", "{{CONTACT_EMAIL}}", "{{VERSION_DATE}}"
|
|
]
|
|
|
|
|
|
# =============================================================================
|
|
# Helpers
|
|
# =============================================================================
|
|
|
|
def make_template_row(overrides=None):
|
|
data = {
|
|
"id": TEMPLATE_ID,
|
|
"tenant_id": DEFAULT_TENANT,
|
|
"document_type": "privacy_policy",
|
|
"title": "Datenschutzerklärung (DSGVO-konform)",
|
|
"description": "Vollständige DSE gemäß DSGVO Art. 13/14",
|
|
"content": "# Datenschutzerklärung\n\n## 1. Verantwortlicher\n\n{{COMPANY_NAME}}",
|
|
"placeholders": DSE_PLACEHOLDERS,
|
|
"language": "de",
|
|
"jurisdiction": "DE",
|
|
"license_id": "mit",
|
|
"license_name": "MIT License",
|
|
"source_name": "BreakPilot Compliance",
|
|
"attribution_required": False,
|
|
"is_complete_document": True,
|
|
"version": "1.0.0",
|
|
"status": "published",
|
|
"created_at": datetime(2024, 1, 1),
|
|
"updated_at": datetime(2024, 1, 1),
|
|
# Attribution columns (Migration 019)
|
|
"source_url": None,
|
|
"source_repo": None,
|
|
"source_file_path": None,
|
|
"source_retrieved_at": None,
|
|
"attribution_text": "Self-authored by BreakPilot Compliance (MIT License, 2026-03-04).",
|
|
"inspiration_sources": [{"source": "Self-authored", "license": "MIT"}],
|
|
}
|
|
if overrides:
|
|
data.update(overrides)
|
|
row = MagicMock()
|
|
row._mapping = data
|
|
return row
|
|
|
|
|
|
def make_db_mock():
|
|
db = MagicMock()
|
|
return db
|
|
|
|
|
|
# =============================================================================
|
|
# TestLegalTemplateSchemas
|
|
# =============================================================================
|
|
|
|
class TestLegalTemplateSchemas:
|
|
def test_create_schema_defaults(self):
|
|
"""LegalTemplateCreate sets sensible defaults."""
|
|
payload = LegalTemplateCreate(
|
|
document_type="privacy_policy",
|
|
title="Test DSE",
|
|
content="# Test"
|
|
)
|
|
assert payload.language == "de"
|
|
assert payload.jurisdiction == "DE"
|
|
assert payload.license_id == "mit"
|
|
assert payload.license_name == "MIT License"
|
|
assert payload.source_name == "BreakPilot Compliance"
|
|
assert payload.attribution_required is False
|
|
assert payload.is_complete_document is True
|
|
assert payload.version == "1.0.0"
|
|
assert payload.status == "published"
|
|
|
|
def test_create_schema_with_placeholders(self):
|
|
"""LegalTemplateCreate accepts placeholder list."""
|
|
payload = LegalTemplateCreate(
|
|
document_type="impressum",
|
|
title="Impressum",
|
|
content="# Impressum\n{{COMPANY_NAME}}",
|
|
placeholders=["{{COMPANY_NAME}}", "{{CEO_NAME}}"]
|
|
)
|
|
assert len(payload.placeholders) == 2
|
|
assert "{{COMPANY_NAME}}" in payload.placeholders
|
|
|
|
def test_update_schema_all_optional(self):
|
|
"""LegalTemplateUpdate: all fields optional."""
|
|
payload = LegalTemplateUpdate()
|
|
d = payload.model_dump(exclude_unset=True)
|
|
assert d == {}
|
|
|
|
def test_update_schema_partial(self):
|
|
"""LegalTemplateUpdate partial: only set fields serialized."""
|
|
payload = LegalTemplateUpdate(status="archived", title="Neue DSE")
|
|
d = payload.model_dump(exclude_unset=True)
|
|
assert d == {"status": "archived", "title": "Neue DSE"}
|
|
|
|
def test_valid_document_types_constant(self):
|
|
"""VALID_DOCUMENT_TYPES contains all 6 expected types."""
|
|
assert "privacy_policy" in VALID_DOCUMENT_TYPES
|
|
assert "terms_of_service" in VALID_DOCUMENT_TYPES
|
|
assert "impressum" in VALID_DOCUMENT_TYPES
|
|
assert "data_processing_agreement" in VALID_DOCUMENT_TYPES
|
|
assert "withdrawal_policy" in VALID_DOCUMENT_TYPES
|
|
assert "cookie_policy" in VALID_DOCUMENT_TYPES
|
|
assert len(VALID_DOCUMENT_TYPES) == 6
|
|
|
|
def test_create_schema_attribution_fields(self):
|
|
"""LegalTemplateCreate accepts attribution fields."""
|
|
payload = LegalTemplateCreate(
|
|
document_type="data_processing_agreement",
|
|
title="AVV Test",
|
|
content="# AVV\n{{CONTROLLER_NAME}}",
|
|
source_url="https://github.com/example/legal",
|
|
source_repo="https://github.com/example/legal",
|
|
source_file_path="dpa-de.md",
|
|
attribution_text="Self-authored MIT.",
|
|
inspiration_sources=[{"source": "test", "license": "MIT"}],
|
|
)
|
|
assert payload.source_url == "https://github.com/example/legal"
|
|
assert payload.attribution_text == "Self-authored MIT."
|
|
assert len(payload.inspiration_sources) == 1
|
|
|
|
def test_update_schema_inspiration_sources(self):
|
|
"""LegalTemplateUpdate accepts inspiration_sources list."""
|
|
payload = LegalTemplateUpdate(
|
|
inspiration_sources=[{"source": "Self-authored", "license": "MIT"}]
|
|
)
|
|
d = payload.model_dump(exclude_unset=True)
|
|
assert "inspiration_sources" in d
|
|
assert d["inspiration_sources"][0]["license"] == "MIT"
|
|
|
|
def test_valid_statuses_constant(self):
|
|
"""VALID_STATUSES contains expected values."""
|
|
assert "published" in VALID_STATUSES
|
|
assert "draft" in VALID_STATUSES
|
|
assert "archived" in VALID_STATUSES
|
|
|
|
|
|
# =============================================================================
|
|
# TestLegalTemplateDB
|
|
# =============================================================================
|
|
|
|
class TestLegalTemplateDB:
|
|
def test_row_to_dict_converts_datetime(self):
|
|
"""_row_to_dict converts datetime to ISO string."""
|
|
row = make_template_row()
|
|
result = _row_to_dict(row)
|
|
assert result["created_at"] == "2024-01-01T00:00:00"
|
|
assert result["updated_at"] == "2024-01-01T00:00:00"
|
|
|
|
def test_row_to_dict_preserves_strings(self):
|
|
"""_row_to_dict preserves string fields unchanged."""
|
|
row = make_template_row()
|
|
result = _row_to_dict(row)
|
|
assert result["title"] == "Datenschutzerklärung (DSGVO-konform)"
|
|
assert result["license_id"] == "mit"
|
|
assert result["document_type"] == "privacy_policy"
|
|
|
|
def test_row_to_dict_preserves_list(self):
|
|
"""_row_to_dict preserves list fields (placeholders)."""
|
|
row = make_template_row()
|
|
result = _row_to_dict(row)
|
|
assert isinstance(result["placeholders"], list)
|
|
assert "{{COMPANY_NAME}}" in result["placeholders"]
|
|
|
|
def test_get_tenant_id_default(self):
|
|
"""_get_tenant_id returns default when no header provided."""
|
|
result = _get_tenant_id(None)
|
|
assert result == DEFAULT_TENANT_ID
|
|
|
|
def test_get_tenant_id_valid_uuid(self):
|
|
"""_get_tenant_id returns provided UUID when valid."""
|
|
custom_uuid = "12345678-1234-1234-1234-123456789abc"
|
|
result = _get_tenant_id(custom_uuid)
|
|
assert result == custom_uuid
|
|
|
|
def test_get_tenant_id_invalid_uuid(self):
|
|
"""_get_tenant_id falls back to default for invalid UUID."""
|
|
result = _get_tenant_id("not-a-uuid")
|
|
assert result == DEFAULT_TENANT_ID
|
|
|
|
|
|
# =============================================================================
|
|
# TestLegalTemplateSearch
|
|
# =============================================================================
|
|
|
|
class TestLegalTemplateSearch:
|
|
def setup_method(self):
|
|
self.db = make_db_mock()
|
|
app.dependency_overrides[get_db] = lambda: self.db
|
|
self.client = TestClient(app)
|
|
|
|
def teardown_method(self):
|
|
app.dependency_overrides.clear()
|
|
|
|
def _setup_list(self, rows, total=None):
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: total if total is not None else len(rows)
|
|
self.db.execute.return_value.fetchone.side_effect = [count_row]
|
|
self.db.execute.return_value.fetchall.return_value = rows
|
|
|
|
def test_list_returns_200(self):
|
|
"""GET /legal-templates returns 200 with templates list."""
|
|
rows = [make_template_row()]
|
|
count_mock = MagicMock()
|
|
count_mock.__getitem__ = lambda self, i: 1
|
|
|
|
execute_results = []
|
|
first_call = MagicMock()
|
|
first_call.fetchone.return_value = count_mock
|
|
second_call = MagicMock()
|
|
second_call.fetchall.return_value = rows
|
|
self.db.execute.side_effect = [first_call, second_call]
|
|
|
|
res = self.client.get("/legal-templates")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert "templates" in data
|
|
assert "total" in data
|
|
|
|
def test_list_filter_by_document_type(self):
|
|
"""GET /legal-templates?document_type=impressum passes filter."""
|
|
count_mock = MagicMock()
|
|
count_mock.__getitem__ = lambda self, i: 0
|
|
first_call = MagicMock()
|
|
first_call.fetchone.return_value = count_mock
|
|
second_call = MagicMock()
|
|
second_call.fetchall.return_value = []
|
|
self.db.execute.side_effect = [first_call, second_call]
|
|
|
|
res = self.client.get("/legal-templates?document_type=impressum")
|
|
assert res.status_code == 200
|
|
# Verify the SQL call used document_type filter
|
|
call_args = self.db.execute.call_args_list[0]
|
|
sql_str = str(call_args[0][0])
|
|
assert "document_type" in sql_str
|
|
|
|
def test_create_invalid_document_type_returns_400(self):
|
|
"""POST /legal-templates with invalid document_type returns 400."""
|
|
res = self.client.post("/legal-templates", json={
|
|
"document_type": "unknown_type",
|
|
"title": "Test",
|
|
"content": "# Test"
|
|
})
|
|
assert res.status_code == 400
|
|
assert "document_type" in res.json()["detail"]
|
|
|
|
def test_create_invalid_status_returns_400(self):
|
|
"""POST /legal-templates with invalid status returns 400."""
|
|
res = self.client.post("/legal-templates", json={
|
|
"document_type": "privacy_policy",
|
|
"title": "Test",
|
|
"content": "# Test",
|
|
"status": "invalid_status"
|
|
})
|
|
assert res.status_code == 400
|
|
|
|
def test_get_nonexistent_returns_404(self):
|
|
"""GET /legal-templates/{id} for unknown ID returns 404."""
|
|
self.db.execute.return_value.fetchone.return_value = None
|
|
res = self.client.get(f"/legal-templates/{UNKNOWN_ID}")
|
|
assert res.status_code == 404
|
|
|
|
def test_update_no_fields_returns_400(self):
|
|
"""PUT /legal-templates/{id} with empty body returns 400."""
|
|
res = self.client.put(f"/legal-templates/{TEMPLATE_ID}", json={})
|
|
assert res.status_code == 400
|
|
assert "No fields" in res.json()["detail"]
|
|
|
|
def test_delete_nonexistent_returns_404(self):
|
|
"""DELETE /legal-templates/{id} for unknown ID returns 404."""
|
|
result_mock = MagicMock()
|
|
result_mock.rowcount = 0
|
|
self.db.execute.return_value = result_mock
|
|
res = self.client.delete(f"/legal-templates/{UNKNOWN_ID}")
|
|
assert res.status_code == 404
|
|
|
|
def test_status_endpoint_returns_200(self):
|
|
"""GET /legal-templates/status returns count structure with all 6 types."""
|
|
row = MagicMock()
|
|
row._mapping = {
|
|
"total": 9, "published": 9, "draft": 0, "archived": 0,
|
|
"privacy_policy": 2, "terms_of_service": 2, "impressum": 1,
|
|
"data_processing_agreement": 2, "withdrawal_policy": 1, "cookie_policy": 1,
|
|
}
|
|
self.db.execute.return_value.fetchone.return_value = row
|
|
res = self.client.get("/legal-templates/status")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["total"] == 9
|
|
assert "by_type" in data
|
|
assert "by_status" in data
|
|
assert "data_processing_agreement" in data["by_type"]
|
|
assert "withdrawal_policy" in data["by_type"]
|
|
assert "cookie_policy" in data["by_type"]
|
|
|
|
def test_sources_endpoint_returns_list(self):
|
|
"""GET /legal-templates/sources returns sources list."""
|
|
self.db.execute.return_value.fetchall.return_value = [
|
|
("BreakPilot Compliance",),
|
|
]
|
|
res = self.client.get("/legal-templates/sources")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert "sources" in data
|
|
assert "BreakPilot Compliance" in data["sources"]
|
|
|
|
|
|
# =============================================================================
|
|
# TestLegalTemplateSeed
|
|
# =============================================================================
|
|
|
|
class TestLegalTemplateSeed:
|
|
"""Validate that seed template structures are correct."""
|
|
|
|
def test_dse_placeholders_present(self):
|
|
"""DSE template row has expected placeholder tokens."""
|
|
row = make_template_row({
|
|
"document_type": "privacy_policy",
|
|
"placeholders": [
|
|
"{{COMPANY_NAME}}", "{{COMPANY_ADDRESS}}", "{{COMPANY_CITY}}",
|
|
"{{CONTACT_EMAIL}}", "{{DPO_NAME}}", "{{DPO_EMAIL}}",
|
|
"{{SUPERVISORY_AUTHORITY}}", "{{VERSION_DATE}}"
|
|
]
|
|
})
|
|
result = _row_to_dict(row)
|
|
assert "{{COMPANY_NAME}}" in result["placeholders"]
|
|
assert "{{CONTACT_EMAIL}}" in result["placeholders"]
|
|
assert "{{VERSION_DATE}}" in result["placeholders"]
|
|
assert len(result["placeholders"]) == 8
|
|
|
|
def test_impressum_has_ceo_placeholder(self):
|
|
"""Impressum template row has CEO_NAME placeholder."""
|
|
row = make_template_row({
|
|
"document_type": "impressum",
|
|
"placeholders": [
|
|
"{{COMPANY_NAME}}", "{{COMPANY_LEGAL_FORM}}", "{{COMPANY_ADDRESS}}",
|
|
"{{COMPANY_CITY}}", "{{CEO_NAME}}", "{{COMPANY_PHONE}}",
|
|
"{{CONTACT_EMAIL}}", "{{WEBSITE_URL}}", "{{REGISTER_SECTION}}", "{{VAT_SECTION}}"
|
|
]
|
|
})
|
|
result = _row_to_dict(row)
|
|
assert "{{CEO_NAME}}" in result["placeholders"]
|
|
assert "{{WEBSITE_URL}}" in result["placeholders"]
|
|
|
|
def test_agb_has_pricing_placeholder(self):
|
|
"""AGB template row has PRICING_SECTION placeholder."""
|
|
row = make_template_row({
|
|
"document_type": "terms_of_service",
|
|
"placeholders": [
|
|
"{{COMPANY_NAME}}", "{{SERVICE_DESCRIPTION}}", "{{PRICING_SECTION}}",
|
|
"{{PAYMENT_TERMS}}", "{{COMPANY_CITY}}", "{{PRIVACY_POLICY_URL}}", "{{VERSION_DATE}}"
|
|
]
|
|
})
|
|
result = _row_to_dict(row)
|
|
assert "{{PRICING_SECTION}}" in result["placeholders"]
|
|
assert "{{PAYMENT_TERMS}}" in result["placeholders"]
|
|
assert "{{PRIVACY_POLICY_URL}}" in result["placeholders"]
|
|
|
|
def test_all_seeds_use_mit_license(self):
|
|
"""All seed templates declare MIT license and no attribution required."""
|
|
for doc_type in ["privacy_policy", "terms_of_service", "impressum"]:
|
|
row = make_template_row({"document_type": doc_type})
|
|
result = _row_to_dict(row)
|
|
assert result["license_id"] == "mit"
|
|
assert result["license_name"] == "MIT License"
|
|
assert result["attribution_required"] is False
|
|
assert result["is_complete_document"] is True
|
|
|
|
def test_dpa_template_has_controller_processor_placeholders(self):
|
|
"""AVV/DPA template has CONTROLLER_NAME and PROCESSOR_NAME placeholders."""
|
|
row = make_template_row({
|
|
"document_type": "data_processing_agreement",
|
|
"placeholders": [
|
|
"{{CONTROLLER_NAME}}", "{{CONTROLLER_ADDRESS}}", "{{CONTROLLER_CITY}}",
|
|
"{{PROCESSOR_NAME}}", "{{PROCESSOR_ADDRESS}}", "{{PROCESSOR_CITY}}",
|
|
"{{SERVICE_DESCRIPTION}}", "{{DATA_CATEGORIES}}", "{{DATA_SUBJECTS}}",
|
|
"{{CONTRACT_DATE}}", "{{SUPERVISORY_AUTHORITY}}"
|
|
]
|
|
})
|
|
result = _row_to_dict(row)
|
|
assert "{{CONTROLLER_NAME}}" in result["placeholders"]
|
|
assert "{{PROCESSOR_NAME}}" in result["placeholders"]
|
|
assert "{{DATA_CATEGORIES}}" in result["placeholders"]
|
|
|
|
def test_withdrawal_policy_has_public_domain_license(self):
|
|
"""Widerrufsbelehrung uses Public Domain license (based on §5 UrhG source)."""
|
|
row = make_template_row({
|
|
"document_type": "withdrawal_policy",
|
|
"license_id": "public_domain",
|
|
"license_name": "Public Domain / Gemeinfrei",
|
|
"source_url": "https://www.gesetze-im-internet.de/egbgb/anlage_1.html",
|
|
"attribution_text": "Basiert auf der amtlichen Musterwiderrufsbelehrung.",
|
|
"inspiration_sources": [{"source": "EGBGB Anlage 1", "license": "Public Domain gemäß §5 UrhG"}],
|
|
})
|
|
result = _row_to_dict(row)
|
|
assert result["document_type"] == "withdrawal_policy"
|
|
assert result["license_id"] == "public_domain"
|
|
assert result["source_url"] == "https://www.gesetze-im-internet.de/egbgb/anlage_1.html"
|
|
assert result["attribution_text"] is not None
|
|
|
|
def test_attribution_text_stored_in_row(self):
|
|
"""attribution_text and inspiration_sources are returned in _row_to_dict."""
|
|
row = make_template_row({
|
|
"attribution_text": "Self-authored by BreakPilot Compliance (MIT License, 2026-03-04).",
|
|
"inspiration_sources": [{"source": "Self-authored", "license": "MIT"}],
|
|
"source_url": None,
|
|
"source_repo": None,
|
|
"source_file_path": None,
|
|
"source_retrieved_at": None,
|
|
})
|
|
result = _row_to_dict(row)
|
|
assert "Self-authored" in result["attribution_text"]
|
|
assert result["inspiration_sources"][0]["license"] == "MIT"
|
|
|
|
def test_english_privacy_policy_eu_jurisdiction(self):
|
|
"""EN Privacy Policy template uses EU jurisdiction."""
|
|
row = make_template_row({
|
|
"document_type": "privacy_policy",
|
|
"language": "en",
|
|
"jurisdiction": "EU",
|
|
"title": "Privacy Policy (GDPR-compliant, EU)",
|
|
})
|
|
result = _row_to_dict(row)
|
|
assert result["language"] == "en"
|
|
assert result["jurisdiction"] == "EU"
|
|
|
|
def test_cookie_policy_type_valid(self):
|
|
"""cookie_policy is accepted as valid document_type."""
|
|
assert "cookie_policy" in VALID_DOCUMENT_TYPES
|
|
|
|
def test_withdrawal_policy_type_valid(self):
|
|
"""withdrawal_policy is accepted as valid document_type."""
|
|
assert "withdrawal_policy" in VALID_DOCUMENT_TYPES
|
|
|
|
def test_dpa_type_valid(self):
|
|
"""data_processing_agreement is accepted as valid document_type."""
|
|
assert "data_processing_agreement" in VALID_DOCUMENT_TYPES
|