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>
616 lines
23 KiB
Python
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'
|