A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
288 lines
10 KiB
Python
288 lines
10 KiB
Python
"""
|
|
Tests für die GDPR API Endpoints
|
|
"""
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from unittest.mock import patch, AsyncMock, MagicMock
|
|
import sys
|
|
import os
|
|
|
|
# Add parent directory to path
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
class TestDataCategories:
|
|
"""Tests für Datenkategorien-Endpoint"""
|
|
|
|
def test_data_categories_structure(self):
|
|
"""Test that data categories have correct structure"""
|
|
# Import the data categories from the GDPR API
|
|
try:
|
|
from gdpr_api import DATA_CATEGORIES
|
|
except ImportError:
|
|
pytest.skip("gdpr_api module not available")
|
|
|
|
assert "essential" in DATA_CATEGORIES
|
|
assert "optional" in DATA_CATEGORIES
|
|
|
|
# Check essential categories
|
|
for category in DATA_CATEGORIES["essential"]:
|
|
assert "name" in category
|
|
assert "description" in category
|
|
assert "retention_days" in category
|
|
assert "legal_basis" in category
|
|
|
|
# Check optional categories
|
|
for category in DATA_CATEGORIES["optional"]:
|
|
assert "name" in category
|
|
assert "description" in category
|
|
assert "retention_days" in category
|
|
assert "cookie_category" in category
|
|
|
|
def test_retention_days_values(self):
|
|
"""Test that retention days are reasonable"""
|
|
try:
|
|
from gdpr_api import DATA_CATEGORIES
|
|
except ImportError:
|
|
pytest.skip("gdpr_api module not available")
|
|
|
|
all_categories = DATA_CATEGORIES["essential"] + DATA_CATEGORIES["optional"]
|
|
|
|
for category in all_categories:
|
|
retention = category.get("retention_days")
|
|
if retention is not None and isinstance(retention, int):
|
|
assert retention > 0, f"Retention for {category['name']} should be positive"
|
|
assert retention <= 3650, f"Retention for {category['name']} shouldn't exceed 10 years"
|
|
|
|
|
|
class TestGDPRCompliance:
|
|
"""Tests für GDPR Compliance"""
|
|
|
|
def test_gdpr_rights_covered(self):
|
|
"""Test that all GDPR rights are addressable"""
|
|
gdpr_rights = {
|
|
"art_15": "Right of access", # Auskunftsrecht
|
|
"art_16": "Right to rectification", # Berichtigungsrecht
|
|
"art_17": "Right to erasure", # Löschungsrecht
|
|
"art_18": "Right to restriction", # Einschränkungsrecht
|
|
"art_20": "Right to portability", # Datenübertragbarkeit
|
|
"art_21": "Right to object", # Widerspruchsrecht
|
|
}
|
|
|
|
# These should all be implementable via the consent service
|
|
for article, right in gdpr_rights.items():
|
|
assert right is not None, f"GDPR {article} ({right}) should be covered"
|
|
|
|
def test_mandatory_documents(self):
|
|
"""Test that mandatory legal documents are defined"""
|
|
mandatory_docs = ["terms", "privacy"]
|
|
|
|
for doc in mandatory_docs:
|
|
assert doc in mandatory_docs, f"Document {doc} should be mandatory"
|
|
|
|
def test_cookie_categories_defined(self):
|
|
"""Test that cookie categories follow GDPR requirements"""
|
|
expected_categories = ["necessary", "functional", "analytics", "marketing"]
|
|
|
|
# Necessary cookies must be allowed without consent
|
|
assert "necessary" in expected_categories
|
|
|
|
# Optional categories require consent
|
|
optional = [c for c in expected_categories if c != "necessary"]
|
|
assert len(optional) > 0
|
|
|
|
|
|
class TestRetentionPolicies:
|
|
"""Tests für Löschfristen"""
|
|
|
|
def test_session_data_retention(self):
|
|
"""Test that session data has short retention"""
|
|
session_retention_days = 1 # Expected: 24 hours max
|
|
assert session_retention_days <= 7, "Session data should be retained for max 7 days"
|
|
|
|
def test_audit_log_retention(self):
|
|
"""Test audit log retention complies with legal requirements"""
|
|
# Audit logs must be kept for compliance but not indefinitely
|
|
audit_retention_days = 1095 # 3 years
|
|
assert audit_retention_days >= 365, "Audit logs should be kept for at least 1 year"
|
|
assert audit_retention_days <= 3650, "Audit logs shouldn't be kept more than 10 years"
|
|
|
|
def test_consent_record_retention(self):
|
|
"""Test that consent records are kept long enough for proof"""
|
|
# § 7a UWG requires proof of consent for 3 years
|
|
consent_retention_days = 1095
|
|
assert consent_retention_days >= 1095, "Consent records must be kept for at least 3 years"
|
|
|
|
def test_ip_address_retention(self):
|
|
"""Test IP address retention is minimized"""
|
|
ip_retention_days = 28 # 4 weeks
|
|
assert ip_retention_days <= 90, "IP addresses should not be stored longer than 90 days"
|
|
|
|
|
|
class TestDataMinimization:
|
|
"""Tests für Datensparsamkeit"""
|
|
|
|
def test_password_not_stored_plain(self):
|
|
"""Test that passwords are never stored in plain text"""
|
|
# This is a design requirement test
|
|
assert True, "Passwords must be hashed with bcrypt"
|
|
|
|
def test_unnecessary_data_not_collected(self):
|
|
"""Test that only necessary data is collected"""
|
|
# User model should only contain necessary fields
|
|
required_fields = ["id", "email", "password_hash", "created_at"]
|
|
optional_fields = ["name", "role"]
|
|
|
|
# No excessive personal data
|
|
forbidden_fields = ["ssn", "credit_card", "date_of_birth", "address"]
|
|
|
|
for field in forbidden_fields:
|
|
assert field not in required_fields, f"Field {field} should not be required"
|
|
|
|
|
|
class TestAnonymization:
|
|
"""Tests für Anonymisierung"""
|
|
|
|
def test_ip_anonymization(self):
|
|
"""Test IP address anonymization logic"""
|
|
def anonymize_ip(ip: str) -> str:
|
|
"""Anonymize IPv4 by zeroing last octet"""
|
|
parts = ip.split(".")
|
|
if len(parts) == 4:
|
|
parts[3] = "0"
|
|
return ".".join(parts)
|
|
return ip
|
|
|
|
test_cases = [
|
|
("192.168.1.100", "192.168.1.0"),
|
|
("10.0.0.1", "10.0.0.0"),
|
|
("172.16.255.255", "172.16.255.0"),
|
|
]
|
|
|
|
for original, expected in test_cases:
|
|
assert anonymize_ip(original) == expected
|
|
|
|
def test_user_data_anonymization(self):
|
|
"""Test user data anonymization for deleted accounts"""
|
|
def anonymize_user_data(user_data: dict) -> dict:
|
|
"""Anonymize user data while keeping audit trail"""
|
|
anonymized = user_data.copy()
|
|
anonymized["email"] = f"deleted-{user_data['id']}@anonymized.local"
|
|
anonymized["name"] = None
|
|
anonymized["password_hash"] = None
|
|
return anonymized
|
|
|
|
original = {
|
|
"id": "123",
|
|
"email": "real@example.com",
|
|
"name": "John Doe",
|
|
"password_hash": "bcrypt_hash"
|
|
}
|
|
|
|
anonymized = anonymize_user_data(original)
|
|
|
|
assert "@anonymized.local" in anonymized["email"]
|
|
assert anonymized["name"] is None
|
|
assert anonymized["password_hash"] is None
|
|
assert anonymized["id"] == original["id"] # ID preserved for audit
|
|
|
|
|
|
class TestExportFormat:
|
|
"""Tests für Datenexport-Format"""
|
|
|
|
def test_export_includes_all_user_data(self):
|
|
"""Test that export includes all required data sections"""
|
|
required_sections = [
|
|
"user", # Personal data
|
|
"consents", # Consent history
|
|
"cookie_consents", # Cookie preferences
|
|
"audit_log", # Activity log
|
|
"exported_at", # Export timestamp
|
|
]
|
|
|
|
# Mock export response
|
|
mock_export = {
|
|
"user": {},
|
|
"consents": [],
|
|
"cookie_consents": [],
|
|
"audit_log": [],
|
|
"exported_at": "2024-01-01T00:00:00Z"
|
|
}
|
|
|
|
for section in required_sections:
|
|
assert section in mock_export, f"Export must include {section}"
|
|
|
|
def test_export_is_machine_readable(self):
|
|
"""Test that export can be provided in machine-readable format"""
|
|
import json
|
|
|
|
mock_data = {
|
|
"user": {"email": "test@example.com"},
|
|
"exported_at": "2024-01-01T00:00:00Z"
|
|
}
|
|
|
|
# Should be valid JSON
|
|
json_str = json.dumps(mock_data)
|
|
parsed = json.loads(json_str)
|
|
|
|
assert parsed == mock_data
|
|
|
|
|
|
class TestConsentValidation:
|
|
"""Tests für Consent-Validierung"""
|
|
|
|
def test_consent_requires_version_id(self):
|
|
"""Test that consent requires a specific document version"""
|
|
consent_request = {
|
|
"document_type": "terms",
|
|
"version_id": "version-123",
|
|
"consented": True
|
|
}
|
|
|
|
assert "version_id" in consent_request
|
|
assert consent_request["version_id"] is not None
|
|
|
|
def test_consent_tracks_ip_and_timestamp(self):
|
|
"""Test that consent tracks IP and timestamp for proof"""
|
|
consent_record = {
|
|
"user_id": "user-123",
|
|
"document_version_id": "version-123",
|
|
"consented": True,
|
|
"ip_address": "192.168.1.1",
|
|
"consented_at": "2024-01-01T00:00:00Z"
|
|
}
|
|
|
|
assert "ip_address" in consent_record
|
|
assert "consented_at" in consent_record
|
|
|
|
def test_withdrawal_is_possible(self):
|
|
"""Test that consent can be withdrawn (Art. 7(3) GDPR)"""
|
|
# Withdrawal should be as easy as giving consent
|
|
withdraw_request = {
|
|
"consent_id": "consent-123"
|
|
}
|
|
|
|
assert "consent_id" in withdraw_request
|
|
|
|
|
|
class TestSecurityHeaders:
|
|
"""Tests für Sicherheits-Header"""
|
|
|
|
def test_required_security_headers(self):
|
|
"""Test that API responses include security headers"""
|
|
required_headers = [
|
|
"X-Content-Type-Options", # nosniff
|
|
"X-Frame-Options", # DENY or SAMEORIGIN
|
|
"Content-Security-Policy", # CSP
|
|
"X-XSS-Protection", # Legacy but useful
|
|
]
|
|
|
|
# These should be set by the application
|
|
for header in required_headers:
|
|
assert header is not None, f"Header {header} should be set"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|