Files
breakpilot-compliance/backend-compliance/tests/test_einwilligungen_routes.py
Sharang Parnerkar 3320ef94fc refactor: phase 0 guardrails + phase 1 step 2 (models.py split)
Squash of branch refactor/phase0-guardrails-and-models-split — 4 commits,
81 files, 173/173 pytest green, OpenAPI contract preserved (360 paths /
484 operations).

## Phase 0 — Architecture guardrails

Three defense-in-depth layers to keep the architecture rules enforced
regardless of who opens Claude Code in this repo:

  1. .claude/settings.json PreToolUse hook on Write/Edit blocks any file
     that would exceed the 500-line hard cap. Auto-loads in every Claude
     session in this repo.
  2. scripts/githooks/pre-commit (install via scripts/install-hooks.sh)
     enforces the LOC cap locally, freezes migrations/ without
     [migration-approved], and protects guardrail files without
     [guardrail-change].
  3. .gitea/workflows/ci.yaml gains loc-budget + guardrail-integrity +
     sbom-scan (syft+grype) jobs, adds mypy --strict for the new Python
     packages (compliance/{services,repositories,domain,schemas}), and
     tsc --noEmit for admin-compliance + developer-portal.

Per-language conventions documented in AGENTS.python.md, AGENTS.go.md,
AGENTS.typescript.md at the repo root — layering, tooling, and explicit
"what you may NOT do" lists. Root CLAUDE.md is prepended with the six
non-negotiable rules. Each of the 10 services gets a README.md.

scripts/check-loc.sh enforces soft 300 / hard 500 and surfaces the
current baseline of 205 hard + 161 soft violations so Phases 1-4 can
drain it incrementally. CI gates only CHANGED files in PRs so the
legacy baseline does not block unrelated work.

## Deprecation sweep

47 files. Pydantic V1 regex= -> pattern= (2 sites), class Config ->
ConfigDict in source_policy_router.py (schemas.py intentionally skipped;
it is the Phase 1 Step 3 split target). datetime.utcnow() ->
datetime.now(timezone.utc) everywhere including SQLAlchemy default=
callables. All DB columns already declare timezone=True, so this is a
latent-bug fix at the Python side, not a schema change.

DeprecationWarning count dropped from 158 to 35.

## Phase 1 Step 1 — Contract test harness

tests/contracts/test_openapi_baseline.py diffs the live FastAPI /openapi.json
against tests/contracts/openapi.baseline.json on every test run. Fails on
removed paths, removed status codes, or new required request body fields.
Regenerate only via tests/contracts/regenerate_baseline.py after a
consumer-updated contract change. This is the safety harness for all
subsequent refactor commits.

## Phase 1 Step 2 — models.py split (1466 -> 85 LOC shim)

compliance/db/models.py is decomposed into seven sibling aggregate modules
following the existing repo pattern (dsr_models.py, vvt_models.py, ...):

  regulation_models.py       (134) — Regulation, Requirement
  control_models.py          (279) — Control, Mapping, Evidence, Risk
  ai_system_models.py        (141) — AISystem, AuditExport
  service_module_models.py   (176) — ServiceModule, ModuleRegulation, ModuleRisk
  audit_session_models.py    (177) — AuditSession, AuditSignOff
  isms_governance_models.py  (323) — ISMSScope, Context, Policy, Objective, SoA
  isms_audit_models.py       (468) — Finding, CAPA, MgmtReview, InternalAudit,
                                     AuditTrail, Readiness

models.py becomes an 85-line re-export shim in dependency order so
existing imports continue to work unchanged. Schema is byte-identical:
__tablename__, column definitions, relationship strings, back_populates,
cascade directives all preserved.

All new sibling files are under the 500-line hard cap; largest is
isms_audit_models.py at 468. No file in compliance/db/ now exceeds
the hard cap.

## Phase 1 Step 3 — infrastructure only

backend-compliance/compliance/{schemas,domain,repositories}/ packages
are created as landing zones with docstrings. compliance/domain/
exports DomainError / NotFoundError / ConflictError / ValidationError /
PermissionError — the base classes services will use to raise
domain-level errors instead of HTTPException.

PHASE1_RUNBOOK.md at backend-compliance/PHASE1_RUNBOOK.md documents
the nine-step execution plan for Phase 1: snapshot baseline,
characterization tests, split models.py (this commit), split schemas.py
(next), extract services, extract repositories, mypy --strict, coverage.

## Verification

  backend-compliance/.venv-phase1: uv python install 3.12 + pip -r requirements.txt
  PYTHONPATH=. pytest compliance/tests/ tests/contracts/
  -> 173 passed, 0 failed, 35 warnings, OpenAPI 360/484 unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:18:29 +02:00

616 lines
23 KiB
Python

"""
Tests for Einwilligungen Routes — 008_einwilligungen migration.
Tests: Catalog Upsert, Company Info, Cookie-Config, Consent erfassen,
Consent widerrufen, Statistiken.
"""
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime, timezone
import uuid
# ============================================================================
# Shared Fixtures
# ============================================================================
def make_uuid():
return str(uuid.uuid4())
def make_catalog(tenant_id='test-tenant'):
rec = MagicMock()
rec.id = uuid.uuid4()
rec.tenant_id = tenant_id
rec.selected_data_point_ids = ['dp-001', 'dp-002']
rec.custom_data_points = []
rec.updated_at = datetime.now(timezone.utc)
return rec
def make_company(tenant_id='test-tenant'):
rec = MagicMock()
rec.id = uuid.uuid4()
rec.tenant_id = tenant_id
rec.data = {'company_name': 'Test GmbH', 'email': 'datenschutz@test.de'}
rec.updated_at = datetime.now(timezone.utc)
return rec
def make_cookies(tenant_id='test-tenant'):
rec = MagicMock()
rec.id = uuid.uuid4()
rec.tenant_id = tenant_id
rec.categories = [
{'id': 'necessary', 'name': 'Notwendig', 'isRequired': True, 'defaultEnabled': True},
{'id': 'analytics', 'name': 'Analyse', 'isRequired': False, 'defaultEnabled': False},
]
rec.config = {'position': 'bottom', 'style': 'bar'}
rec.updated_at = datetime.now(timezone.utc)
return rec
def make_consent(tenant_id='test-tenant', user_id='user-001', data_point_id='dp-001', granted=True):
rec = MagicMock()
rec.id = uuid.uuid4()
rec.tenant_id = tenant_id
rec.user_id = user_id
rec.data_point_id = data_point_id
rec.granted = granted
rec.granted_at = datetime.now(timezone.utc)
rec.revoked_at = None
rec.consent_version = '1.0'
rec.source = 'website'
rec.ip_address = None
rec.user_agent = None
rec.created_at = datetime.now(timezone.utc)
return rec
# ============================================================================
# Pydantic Schema Tests
# ============================================================================
class TestCatalogUpsert:
def test_catalog_upsert_defaults(self):
from compliance.api.einwilligungen_routes import CatalogUpsert
data = CatalogUpsert()
assert data.selected_data_point_ids == []
assert data.custom_data_points == []
def test_catalog_upsert_with_data(self):
from compliance.api.einwilligungen_routes import CatalogUpsert
data = CatalogUpsert(
selected_data_point_ids=['dp-001', 'dp-002', 'dp-003'],
custom_data_points=[{'id': 'custom-1', 'name': 'Eigener Datenpunkt'}],
)
assert len(data.selected_data_point_ids) == 3
assert len(data.custom_data_points) == 1
class TestCompanyUpsert:
def test_company_upsert_empty(self):
from compliance.api.einwilligungen_routes import CompanyUpsert
data = CompanyUpsert()
assert data.data == {}
def test_company_upsert_with_data(self):
from compliance.api.einwilligungen_routes import CompanyUpsert
data = CompanyUpsert(data={
'company_name': 'Test GmbH',
'address': 'Musterstraße 1, 12345 Berlin',
'email': 'datenschutz@test.de',
'dpo_name': 'Max Mustermann',
})
assert data.data['company_name'] == 'Test GmbH'
assert data.data['dpo_name'] == 'Max Mustermann'
class TestCookiesUpsert:
def test_cookies_upsert_defaults(self):
from compliance.api.einwilligungen_routes import CookiesUpsert
data = CookiesUpsert()
assert data.categories == []
assert data.config == {}
def test_cookies_upsert_with_categories(self):
from compliance.api.einwilligungen_routes import CookiesUpsert
data = CookiesUpsert(
categories=[
{'id': 'necessary', 'name': 'Notwendig', 'isRequired': True},
{'id': 'analytics', 'name': 'Analyse', 'isRequired': False},
],
config={'position': 'bottom'},
)
assert len(data.categories) == 2
assert data.config['position'] == 'bottom'
class TestConsentCreate:
def test_consent_create_valid(self):
from compliance.api.einwilligungen_routes import ConsentCreate
data = ConsentCreate(
user_id='user-001',
data_point_id='dp-marketing',
granted=True,
)
assert data.user_id == 'user-001'
assert data.granted is True
assert data.consent_version == '1.0'
assert data.source is None
def test_consent_create_revoke(self):
from compliance.api.einwilligungen_routes import ConsentCreate
data = ConsentCreate(
user_id='user-001',
data_point_id='dp-analytics',
granted=False,
consent_version='2.0',
source='cookie-banner',
)
assert data.granted is False
assert data.consent_version == '2.0'
# ============================================================================
# Catalog Upsert Tests
# ============================================================================
class TestCatalogDB:
def test_catalog_returns_empty_when_not_found(self):
"""GET catalog should return empty defaults when no record exists."""
from compliance.db.einwilligungen_models import EinwilligungenCatalogDB
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
result = mock_db.query(EinwilligungenCatalogDB).filter().first()
assert result is None
def test_catalog_upsert_creates_new(self):
"""PUT catalog should create a new record if none exists."""
from compliance.db.einwilligungen_models import EinwilligungenCatalogDB
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
new_record = EinwilligungenCatalogDB(
tenant_id='test-tenant',
selected_data_point_ids=['dp-001'],
custom_data_points=[],
)
assert new_record.tenant_id == 'test-tenant'
assert new_record.selected_data_point_ids == ['dp-001']
def test_catalog_upsert_updates_existing(self):
"""PUT catalog should update existing record."""
existing = make_catalog()
existing.selected_data_point_ids = ['dp-001', 'dp-002', 'dp-003']
existing.custom_data_points = [{'id': 'custom-1'}]
assert len(existing.selected_data_point_ids) == 3
assert len(existing.custom_data_points) == 1
# ============================================================================
# Cookie Config Tests
# ============================================================================
class TestCookieConfig:
def test_cookie_config_returns_empty_when_not_found(self):
"""GET cookies should return empty defaults for new tenant."""
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
result = mock_db.query().filter().first()
assert result is None
def test_cookie_config_upsert_with_categories(self):
"""PUT cookies should store categories and config."""
from compliance.db.einwilligungen_models import EinwilligungenCookiesDB
categories = [
{'id': 'necessary', 'name': 'Notwendig', 'isRequired': True, 'defaultEnabled': True},
]
config = {'position': 'bottom', 'primaryColor': '#6366f1'}
rec = EinwilligungenCookiesDB(
tenant_id='test-tenant',
categories=categories,
config=config,
)
assert rec.categories[0]['id'] == 'necessary'
assert rec.config['position'] == 'bottom'
def test_essential_cookies_cannot_be_disabled(self):
"""Category with isRequired=True should not allow enabled=False."""
categories = [
{'id': 'necessary', 'name': 'Notwendig', 'isRequired': True, 'defaultEnabled': True},
{'id': 'analytics', 'name': 'Analyse', 'isRequired': False, 'defaultEnabled': True},
]
# Simulate the toggle logic
category_id = 'necessary'
enabled = False
updated = []
for cat in categories:
if cat['id'] == category_id:
if cat.get('isRequired') and not enabled:
updated.append(cat) # Not changed
else:
updated.append({**cat, 'defaultEnabled': enabled})
else:
updated.append(cat)
necessary_cat = next(c for c in updated if c['id'] == 'necessary')
assert necessary_cat['defaultEnabled'] is True # Not changed
# ============================================================================
# Consent Tests
# ============================================================================
class TestConsentDB:
def test_consent_record_creation(self):
"""Consent record should store all required fields."""
from compliance.db.einwilligungen_models import EinwilligungenConsentDB
consent = EinwilligungenConsentDB(
tenant_id='test-tenant',
user_id='user-001',
data_point_id='dp-marketing',
granted=True,
granted_at=datetime.now(timezone.utc),
consent_version='1.0',
source='website',
)
assert consent.tenant_id == 'test-tenant'
assert consent.granted is True
assert consent.revoked_at is None
def test_consent_revoke_sets_revoked_at(self):
"""Revoking a consent should set revoked_at timestamp."""
consent = make_consent()
assert consent.revoked_at is None
consent.revoked_at = datetime.now(timezone.utc)
assert consent.revoked_at is not None
def test_cannot_revoke_already_revoked(self):
"""Should not be possible to revoke an already revoked consent."""
consent = make_consent()
consent.revoked_at = datetime.now(timezone.utc)
# Simulate the guard logic from the route
already_revoked = consent.revoked_at is not None
assert already_revoked is True # Route would raise HTTPException 400
# ============================================================================
# Statistics Tests
# ============================================================================
class TestConsentStats:
def test_stats_empty_tenant(self):
"""Stats for tenant with no consents should return zeros."""
consents = []
total = len(consents)
active = sum(1 for c in consents if c.granted and not c.revoked_at)
revoked = sum(1 for c in consents if c.revoked_at)
unique_users = len(set(c.user_id for c in consents))
assert total == 0
assert active == 0
assert revoked == 0
assert unique_users == 0
def test_stats_with_mixed_consents(self):
"""Stats should correctly count active and revoked consents."""
consents = [
make_consent(user_id='user-1', data_point_id='dp-1', granted=True),
make_consent(user_id='user-1', data_point_id='dp-2', granted=True),
make_consent(user_id='user-2', data_point_id='dp-1', granted=True),
]
# Revoke one
consents[1].revoked_at = datetime.now(timezone.utc)
total = len(consents)
active = sum(1 for c in consents if c.granted and not c.revoked_at)
revoked = sum(1 for c in consents if c.revoked_at)
unique_users = len(set(c.user_id for c in consents))
assert total == 3
assert active == 2
assert revoked == 1
assert unique_users == 2
def test_stats_conversion_rate(self):
"""Conversion rate = users with active consent / total unique users."""
consents = [
make_consent(user_id='user-1', granted=True),
make_consent(user_id='user-2', granted=True),
make_consent(user_id='user-3', granted=True),
]
consents[2].revoked_at = datetime.now(timezone.utc) # user-3 revoked
unique_users = len(set(c.user_id for c in consents))
users_with_active = len(set(c.user_id for c in consents if c.granted and not c.revoked_at))
rate = round((users_with_active / unique_users * 100), 1) if unique_users > 0 else 0.0
assert unique_users == 3
assert users_with_active == 2
assert rate == pytest.approx(66.7, 0.1)
def test_stats_by_data_point(self):
"""Stats should group consents by data_point_id."""
consents = [
make_consent(data_point_id='dp-marketing', granted=True),
make_consent(data_point_id='dp-marketing', granted=True),
make_consent(data_point_id='dp-analytics', granted=True),
]
by_dp: dict = {}
for c in consents:
dp = c.data_point_id
if dp not in by_dp:
by_dp[dp] = {'total': 0, 'active': 0}
by_dp[dp]['total'] += 1
if c.granted and not c.revoked_at:
by_dp[dp]['active'] += 1
assert by_dp['dp-marketing']['total'] == 2
assert by_dp['dp-analytics']['total'] == 1
# ============================================================================
# Model Repr Tests
# ============================================================================
class TestModelReprs:
def test_catalog_repr(self):
from compliance.db.einwilligungen_models import EinwilligungenCatalogDB
rec = EinwilligungenCatalogDB(tenant_id='my-tenant')
assert 'my-tenant' in repr(rec)
def test_cookies_repr(self):
from compliance.db.einwilligungen_models import EinwilligungenCookiesDB
rec = EinwilligungenCookiesDB(tenant_id='my-tenant')
assert 'my-tenant' in repr(rec)
def test_consent_repr(self):
from compliance.db.einwilligungen_models import EinwilligungenConsentDB
rec = EinwilligungenConsentDB(
tenant_id='t1', user_id='u1', data_point_id='dp1', granted=True
)
assert 'u1' in repr(rec)
assert 'dp1' in repr(rec)
# ============================================================================
# Consent Response Field Tests (Regression: Phase 3 — ip_address + user_agent)
# ============================================================================
class TestConsentResponseFields:
def test_consent_response_includes_ip_address(self):
"""GET /consents Serialisierung muss ip_address enthalten."""
c = make_consent()
c.ip_address = '10.0.0.1'
row = {
"id": str(c.id),
"tenant_id": c.tenant_id,
"user_id": c.user_id,
"data_point_id": c.data_point_id,
"granted": c.granted,
"granted_at": c.granted_at,
"revoked_at": c.revoked_at,
"consent_version": c.consent_version,
"source": c.source,
"ip_address": c.ip_address,
"user_agent": c.user_agent,
"created_at": c.created_at,
}
assert "ip_address" in row
assert row["ip_address"] == '10.0.0.1'
def test_consent_response_includes_user_agent(self):
"""GET /consents Serialisierung muss user_agent enthalten."""
c = make_consent()
c.user_agent = 'Mozilla/5.0 (Test)'
row = {
"id": str(c.id),
"tenant_id": c.tenant_id,
"user_id": c.user_id,
"data_point_id": c.data_point_id,
"granted": c.granted,
"granted_at": c.granted_at,
"revoked_at": c.revoked_at,
"consent_version": c.consent_version,
"source": c.source,
"ip_address": c.ip_address,
"user_agent": c.user_agent,
"created_at": c.created_at,
}
assert "user_agent" in row
assert row["user_agent"] == 'Mozilla/5.0 (Test)'
def test_consent_response_ip_and_ua_none_by_default(self):
"""ip_address und user_agent sind None wenn nicht gesetzt (make_consent Default)."""
c = make_consent()
row = {"ip_address": c.ip_address, "user_agent": c.user_agent}
assert row["ip_address"] is None
assert row["user_agent"] is None
# ============================================================================
# History-Tracking Tests (Migration 009)
# ============================================================================
class TestConsentHistoryTracking:
def test_record_history_helper_builds_entry(self):
"""_record_history() erstellt korrekt befuelltes EinwilligungenConsentHistoryDB-Objekt."""
from compliance.db.einwilligungen_models import EinwilligungenConsentHistoryDB
consent = make_consent()
consent.ip_address = '10.0.0.1'
consent.user_agent = 'TestAgent/1.0'
consent.source = 'test-source'
mock_db = MagicMock()
# Simulate _record_history inline (mirrors implementation)
entry = EinwilligungenConsentHistoryDB(
consent_id=consent.id,
tenant_id=consent.tenant_id,
action='granted',
consent_version=consent.consent_version,
ip_address=consent.ip_address,
user_agent=consent.user_agent,
source=consent.source,
)
mock_db.add(entry)
assert entry.tenant_id == consent.tenant_id
assert entry.consent_id == consent.id
assert entry.ip_address == '10.0.0.1'
assert entry.user_agent == 'TestAgent/1.0'
assert entry.source == 'test-source'
mock_db.add.assert_called_once_with(entry)
def test_history_entry_has_correct_action_granted(self):
"""History-Eintrag bei Einwilligung hat action='granted'."""
from compliance.db.einwilligungen_models import EinwilligungenConsentHistoryDB
consent = make_consent()
entry = EinwilligungenConsentHistoryDB(
consent_id=consent.id,
tenant_id=consent.tenant_id,
action='granted',
consent_version=consent.consent_version,
)
assert entry.action == 'granted'
def test_history_entry_has_correct_action_revoked(self):
"""History-Eintrag bei Widerruf hat action='revoked'."""
from compliance.db.einwilligungen_models import EinwilligungenConsentHistoryDB
consent = make_consent()
consent.revoked_at = datetime.now(timezone.utc)
entry = EinwilligungenConsentHistoryDB(
consent_id=consent.id,
tenant_id=consent.tenant_id,
action='revoked',
consent_version=consent.consent_version,
)
assert entry.action == 'revoked'
def test_history_serialization_format(self):
"""Response-Dict fuer einen History-Eintrag enthaelt alle 8 Pflichtfelder."""
import uuid as _uuid
entry_id = _uuid.uuid4()
consent_id = _uuid.uuid4()
now = datetime.now(timezone.utc)
row = {
"id": str(entry_id),
"consent_id": str(consent_id),
"action": "granted",
"consent_version": "1.0",
"ip_address": "192.168.1.1",
"user_agent": "Mozilla/5.0",
"source": "web_banner",
"created_at": now,
}
assert len(row) == 8
assert "id" in row
assert "consent_id" in row
assert "action" in row
assert "consent_version" in row
assert "ip_address" in row
assert "user_agent" in row
assert "source" in row
assert "created_at" in row
def test_history_empty_list_for_no_entries(self):
"""GET /consents/{id}/history gibt leere Liste zurueck wenn keine Eintraege vorhanden."""
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
entries = mock_db.query().filter().order_by().all()
result = [{"id": str(e.id), "action": e.action} for e in entries]
assert result == []
# =============================================================================
# Cookie Banner Embed-Code + Banner-Text Persistenz
# =============================================================================
class TestCookieBannerEmbedCode:
"""Tests fuer Cookie-Banner Embed-Code und Banner-Text Persistenz."""
def test_embed_code_generation_returns_script_content(self):
"""GET /cookies gibt config zurueck aus der embed-code generiert werden kann."""
from compliance.db.einwilligungen_models import EinwilligungenCookiesDB
config = EinwilligungenCookiesDB(
tenant_id='t1',
categories=[{'id': 'necessary', 'name': 'Notwendig'}],
config={'position': 'bottom', 'style': 'banner'},
)
assert config.tenant_id == 't1'
assert len(config.categories) == 1
assert config.config['position'] == 'bottom'
def test_banner_texts_saved_in_config(self):
"""PUT /cookies mit banner_texts in config persistiert die Texte."""
from compliance.db.einwilligungen_models import EinwilligungenCookiesDB
banner_texts = {
'title': 'Cookie Hinweis',
'description': 'Wir nutzen Cookies',
'accept_all': 'Alle akzeptieren',
}
config = EinwilligungenCookiesDB(
tenant_id='t2',
categories=[],
config={'banner_texts': banner_texts},
)
assert config.config['banner_texts']['title'] == 'Cookie Hinweis'
assert config.config['banner_texts']['accept_all'] == 'Alle akzeptieren'
def test_config_roundtrip_preserves_all_fields(self):
"""Config-Felder (styling, texts, position) bleiben bei Upsert erhalten."""
from compliance.db.einwilligungen_models import EinwilligungenCookiesDB
full_config = {
'position': 'bottom-right',
'style': 'card',
'primaryColor': '#2563eb',
'banner_texts': {'title': 'Test', 'description': 'Desc'},
}
config = EinwilligungenCookiesDB(tenant_id='t3', categories=[], config=full_config)
assert config.config['primaryColor'] == '#2563eb'
assert config.config['banner_texts']['description'] == 'Desc'
def test_required_categories_flag_preserved(self):
"""isRequired-Flag fuer notwendige Kategorien bleibt nach Upsert erhalten."""
from compliance.db.einwilligungen_models import EinwilligungenCookiesDB
categories = [
{'id': 'necessary', 'name': 'Notwendig', 'isRequired': True, 'defaultEnabled': True},
{'id': 'analytics', 'name': 'Analyse', 'isRequired': False, 'defaultEnabled': False},
]
config = EinwilligungenCookiesDB(tenant_id='t4', categories=categories, config={})
required = [c for c in config.categories if c.get('isRequired')]
assert len(required) == 1
assert required[0]['id'] == 'necessary'