Initial monorepo structure

This commit is contained in:
2026-06-30 20:37:40 +02:00
commit 2f2b295531
121 changed files with 39171 additions and 0 deletions
@@ -0,0 +1,626 @@
"""Unit-Tests für SecretEncryptionManager (Task 4.6).
Testet die folgenden Aspekte, die NICHT bereits in test_encryption.py abgedeckt sind:
1. encrypt_file() Erfolg, Autorisierung, fehlende Datei
2. decrypt_file() Erfolg, Autorisierung, fehlende Datei, Kontextauflösung
3. is_authorized() autorisierte und nicht-autorisierte Kontexte
4. Fehlermeldungen enthalten KEINEN Dateiinhalt (keine Offenlegung)
5. setup_gitcrypt_filters() .gitattributes-Generierung pro Kontext
6. Edge Cases: shared-Kontext, Dateien außerhalb von Kontextordnern
_Requirements: 9.1, 9.3, 9.4, 9.8, 9.10, 9.11_
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from monorepo.encryption import (
DecryptionResult,
EncryptionResult,
GitCryptNotAvailableError,
SecretEncryptionManager,
)
from monorepo.models import MachineContext
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_monorepo(tmp_path: Path) -> Path:
"""Erstellt eine minimale Monorepo-Struktur."""
for ctx in ("privat", "dhive", "bahn", "shared"):
(tmp_path / ctx).mkdir()
return tmp_path
@pytest.fixture
def full_context() -> MachineContext:
"""MachineContext mit Zugriff auf alle Kontexte."""
return MachineContext(
name="andre-hauptrechner",
description="Voller Zugriff",
authorized_contexts=["privat", "dhive", "bahn"],
key_source="keyring",
)
@pytest.fixture
def dhive_only_context() -> MachineContext:
"""MachineContext nur für dhive autorisiert."""
return MachineContext(
name="dhive-laptop",
description="Nur dhive",
authorized_contexts=["dhive"],
key_source="keyring",
)
@pytest.fixture
def no_context() -> MachineContext:
"""MachineContext ohne jede Autorisierung."""
return MachineContext(
name="fremder-rechner",
description="Kein Zugriff",
authorized_contexts=[],
key_source="keyring",
)
@pytest.fixture
def manager(tmp_monorepo: Path, full_context: MachineContext) -> SecretEncryptionManager:
"""SecretEncryptionManager mit vollem Zugriff."""
return SecretEncryptionManager(tmp_monorepo, full_context)
@pytest.fixture
def limited_manager(
tmp_monorepo: Path, dhive_only_context: MachineContext
) -> SecretEncryptionManager:
"""SecretEncryptionManager nur für dhive."""
return SecretEncryptionManager(tmp_monorepo, dhive_only_context)
@pytest.fixture
def unauthorized_manager(
tmp_monorepo: Path, no_context: MachineContext
) -> SecretEncryptionManager:
"""SecretEncryptionManager ohne Autorisierung."""
return SecretEncryptionManager(tmp_monorepo, no_context)
# ---------------------------------------------------------------------------
# Tests: encrypt_file()
# ---------------------------------------------------------------------------
class TestEncryptFile:
"""Tests für SecretEncryptionManager.encrypt_file()."""
def test_encrypt_success_with_authorized_context(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Verschlüsselung gelingt bei autorisiertem Kontext und existierender Datei."""
secret_file = tmp_monorepo / "privat" / ".env"
secret_file.write_text("SECRET_KEY=super-geheim-123")
# Mock git-crypt Aufruf, da git-crypt nicht installiert ist
with patch.object(manager, "_run_gitcrypt") as mock_gc:
mock_gc.return_value = None
result = manager.encrypt_file(secret_file, "privat")
assert isinstance(result, EncryptionResult)
assert result.success is True
assert result.file_path == secret_file
assert result.context == "privat"
assert result.error is None
def test_encrypt_fails_with_unauthorized_context(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Verschlüsselung schlägt fehl bei nicht-autorisiertem Kontext."""
secret_file = tmp_monorepo / "privat" / ".env"
secret_file.write_text("SECRET=value")
result = limited_manager.encrypt_file(secret_file, "privat")
assert result.success is False
assert "nicht" in result.error.lower() or "autorisiert" in result.error.lower()
def test_encrypt_fails_when_file_does_not_exist(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Verschlüsselung schlägt fehl wenn die Datei nicht existiert."""
nonexistent = tmp_monorepo / "privat" / "nonexistent.env"
result = manager.encrypt_file(nonexistent, "privat")
assert result.success is False
assert "existiert nicht" in result.error
def test_encrypt_fails_when_gitcrypt_not_available(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Verschlüsselung schlägt fehl wenn git-crypt nicht installiert ist."""
secret_file = tmp_monorepo / "dhive" / "credentials.pem"
secret_file.write_text("-----BEGIN PRIVATE KEY-----\n...")
with patch.object(manager, "_run_gitcrypt") as mock_gc:
mock_gc.side_effect = GitCryptNotAvailableError(
"git-crypt ist nicht installiert"
)
result = manager.encrypt_file(secret_file, "dhive")
assert result.success is False
assert "git-crypt" in result.error.lower()
def test_encrypt_for_bahn_context(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Verschlüsselung funktioniert auch für Bahn-Kontext."""
bahn_secret = tmp_monorepo / "bahn" / "api-token.key"
bahn_secret.write_text("token-abc-123")
with patch.object(manager, "_run_gitcrypt"):
result = manager.encrypt_file(bahn_secret, "bahn")
assert result.success is True
assert result.context == "bahn"
# ---------------------------------------------------------------------------
# Tests: decrypt_file()
# ---------------------------------------------------------------------------
class TestDecryptFile:
"""Tests für SecretEncryptionManager.decrypt_file()."""
def test_decrypt_success_with_authorized_context(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Entschlüsselung gelingt bei autorisiertem Kontext."""
secret_file = tmp_monorepo / "privat" / ".env"
secret_content = b"DB_PASSWORD=geheim123"
secret_file.write_bytes(secret_content)
with patch.object(manager, "_run_gitcrypt"):
result = manager.decrypt_file(secret_file)
assert isinstance(result, DecryptionResult)
assert result.success is True
assert result.content == secret_content
def test_decrypt_fails_with_unauthorized_context(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Entschlüsselung schlägt fehl bei nicht-autorisiertem Kontext."""
privat_secret = tmp_monorepo / "privat" / ".env"
privat_secret.write_text("PRIVATE_SECRET=xyz")
result = limited_manager.decrypt_file(privat_secret)
assert result.success is False
assert result.content is None
assert "verweigert" in result.error.lower() or "autorisiert" in result.error.lower()
def test_decrypt_succeeds_for_own_context(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Eingeschränkter Manager kann eigenen Kontext entschlüsseln."""
dhive_secret = tmp_monorepo / "dhive" / ".env"
dhive_content = b"DHIVE_API_KEY=abc"
dhive_secret.write_bytes(dhive_content)
with patch.object(limited_manager, "_run_gitcrypt"):
result = limited_manager.decrypt_file(dhive_secret)
assert result.success is True
assert result.content == dhive_content
def test_decrypt_fails_when_file_does_not_exist(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Entschlüsselung schlägt fehl wenn die Datei nicht existiert."""
nonexistent = tmp_monorepo / "dhive" / "missing.key"
result = manager.decrypt_file(nonexistent)
assert result.success is False
assert "existiert nicht" in result.error
def test_decrypt_resolves_context_from_path(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Entschlüsselung ermittelt den Kontext automatisch aus dem Dateipfad."""
bahn_secret = tmp_monorepo / "bahn" / "project-x" / "secret.key"
bahn_secret.parent.mkdir(parents=True, exist_ok=True)
bahn_secret.write_bytes(b"key-data")
with patch.object(manager, "_run_gitcrypt"):
result = manager.decrypt_file(bahn_secret)
assert result.success is True
assert result.content == b"key-data"
def test_decrypt_fails_for_file_outside_context_dirs(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Entschlüsselung schlägt fehl für Dateien außerhalb der Kontextordner."""
outside_file = tmp_monorepo / "unknown-dir" / "secrets.env"
outside_file.parent.mkdir(parents=True, exist_ok=True)
outside_file.write_text("SHOULD_NOT_DECRYPT=true")
result = manager.decrypt_file(outside_file)
assert result.success is False
assert "kontext" in result.error.lower()
def test_decrypt_fails_for_root_level_file(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Entschlüsselung schlägt fehl für Datei direkt im Root (kein Kontext)."""
root_file = tmp_monorepo / ".env"
root_file.write_text("ROOT_SECRET=hidden")
result = manager.decrypt_file(root_file)
# .env im Root hat keinen gültigen Kontext als erstes Pfadsegment
assert result.success is False
# ---------------------------------------------------------------------------
# Tests: is_authorized()
# ---------------------------------------------------------------------------
class TestIsAuthorized:
"""Tests für SecretEncryptionManager.is_authorized()."""
def test_authorized_for_all_contexts_full_access(
self, manager: SecretEncryptionManager
) -> None:
"""Full-Access-Manager ist für alle drei Kontexte autorisiert."""
assert manager.is_authorized("privat") is True
assert manager.is_authorized("dhive") is True
assert manager.is_authorized("bahn") is True
def test_authorized_only_for_own_context(
self, limited_manager: SecretEncryptionManager
) -> None:
"""Eingeschränkter Manager ist nur für seinen Kontext autorisiert."""
assert limited_manager.is_authorized("dhive") is True
assert limited_manager.is_authorized("privat") is False
assert limited_manager.is_authorized("bahn") is False
def test_not_authorized_for_any_context(
self, unauthorized_manager: SecretEncryptionManager
) -> None:
"""Manager ohne Autorisierung ist für keinen Kontext autorisiert."""
assert unauthorized_manager.is_authorized("privat") is False
assert unauthorized_manager.is_authorized("dhive") is False
assert unauthorized_manager.is_authorized("bahn") is False
def test_not_authorized_for_nonexistent_context(
self, manager: SecretEncryptionManager
) -> None:
"""Nicht existierender Kontext ist nie autorisiert."""
assert manager.is_authorized("nonexistent") is False
assert manager.is_authorized("") is False
def test_shared_context_not_in_authorized_list(
self, manager: SecretEncryptionManager
) -> None:
"""'shared' ist per Design nicht in der Autorisierungsliste."""
# shared wird über andere Mechanismen gesteuert
assert manager.is_authorized("shared") is False
# ---------------------------------------------------------------------------
# Tests: Fehlermeldungen offenlegen KEINEN Dateiinhalt (Requirement 9.8)
# ---------------------------------------------------------------------------
class TestErrorMessagesSecurity:
"""Fehlermeldungen dürfen KEINEN Dateiinhalt offenlegen."""
def test_encrypt_error_does_not_reveal_content(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Verschlüsselungs-Fehlermeldung enthält keinen Dateiinhalt."""
secret_file = tmp_monorepo / "privat" / ".env"
secret_content = "SUPER_SECRET_API_KEY=sk-12345abcde"
secret_file.write_text(secret_content)
result = limited_manager.encrypt_file(secret_file, "privat")
assert result.success is False
# Fehlermeldung darf KEINEN Teil des Dateiinhalts enthalten
assert "SUPER_SECRET_API_KEY" not in (result.error or "")
assert "sk-12345abcde" not in (result.error or "")
assert secret_content not in (result.error or "")
def test_decrypt_error_does_not_reveal_content(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Entschlüsselungs-Fehlermeldung enthält keinen Dateiinhalt."""
secret_file = tmp_monorepo / "privat" / "credentials.key"
secret_content = "-----BEGIN RSA PRIVATE KEY-----\nMIIE...very-secret"
secret_file.write_text(secret_content)
result = limited_manager.decrypt_file(secret_file)
assert result.success is False
assert result.content is None
# Fehlermeldung darf KEINEN Teil des Dateiinhalts enthalten
assert "BEGIN RSA PRIVATE KEY" not in (result.error or "")
assert "MIIE" not in (result.error or "")
assert "very-secret" not in (result.error or "")
def test_decrypt_error_for_unauthorized_binary_file(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Fehlermeldung für verschlüsselte Binärdatei enthält keine Bytes."""
binary_file = tmp_monorepo / "bahn" / "cert.pem"
binary_content = b"\x00\x01\x02\x03PRIVATE_DATA\xff\xfe"
binary_file.write_bytes(binary_content)
result = limited_manager.decrypt_file(binary_file)
assert result.success is False
assert result.content is None
# Keine binären Daten oder Schlüsselwörter in der Fehlermeldung
assert "PRIVATE_DATA" not in (result.error or "")
def test_encrypt_error_has_useful_context_info(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Fehlermeldung enthält nützliche Kontextinformationen (ohne Secrets)."""
secret_file = tmp_monorepo / "privat" / "token.secret"
secret_file.write_text("geheim-token-value")
result = limited_manager.encrypt_file(secret_file, "privat")
assert result.success is False
# Fehlermeldung sollte den Maschinennamen oder Kontext benennen
assert "dhive-laptop" in result.error or "privat" in result.error
# ---------------------------------------------------------------------------
# Tests: setup_gitcrypt_filters() .gitattributes-Generierung
# ---------------------------------------------------------------------------
class TestSetupGitcryptFilters:
"""Tests für SecretEncryptionManager.setup_gitcrypt_filters()."""
def test_generates_gitattributes_for_privat(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Erzeugt .gitattributes im privat-Kontext mit korrekten Filtern."""
manager.setup_gitcrypt_filters("privat")
gitattr_path = tmp_monorepo / "privat" / ".gitattributes"
assert gitattr_path.exists()
content = gitattr_path.read_text(encoding="utf-8")
assert "filter=git-crypt-privat" in content
assert "diff=git-crypt-privat" in content
def test_generates_gitattributes_for_dhive(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Erzeugt .gitattributes im dhive-Kontext."""
manager.setup_gitcrypt_filters("dhive")
gitattr_path = tmp_monorepo / "dhive" / ".gitattributes"
assert gitattr_path.exists()
content = gitattr_path.read_text(encoding="utf-8")
assert "filter=git-crypt-dhive" in content
assert "diff=git-crypt-dhive" in content
def test_generates_gitattributes_for_bahn(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Erzeugt .gitattributes im bahn-Kontext."""
manager.setup_gitcrypt_filters("bahn")
gitattr_path = tmp_monorepo / "bahn" / ".gitattributes"
assert gitattr_path.exists()
content = gitattr_path.read_text(encoding="utf-8")
assert "filter=git-crypt-bahn" in content
assert "diff=git-crypt-bahn" in content
def test_contains_all_secret_patterns(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Die generierten .gitattributes enthalten alle Secret-Patterns."""
manager.setup_gitcrypt_filters("privat")
content = (tmp_monorepo / "privat" / ".gitattributes").read_text(
encoding="utf-8"
)
# Alle SECRET_PATTERNS müssen als gitattributes-Patterns vorhanden sein
assert ".env" in content
assert "*.pem" in content
assert "*.key" in content
assert "*token*" in content
assert "*secret*" in content
def test_filter_names_are_context_specific(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Jeder Kontext hat seinen eigenen Filter-Namen."""
manager.setup_gitcrypt_filters("privat")
manager.setup_gitcrypt_filters("dhive")
manager.setup_gitcrypt_filters("bahn")
privat_content = (tmp_monorepo / "privat" / ".gitattributes").read_text()
dhive_content = (tmp_monorepo / "dhive" / ".gitattributes").read_text()
bahn_content = (tmp_monorepo / "bahn" / ".gitattributes").read_text()
# Jeder Kontext referenziert nur seinen eigenen Filter
assert "git-crypt-privat" in privat_content
assert "git-crypt-dhive" not in privat_content
assert "git-crypt-dhive" in dhive_content
assert "git-crypt-privat" not in dhive_content
assert "git-crypt-bahn" in bahn_content
assert "git-crypt-privat" not in bahn_content
def test_creates_context_directory_if_missing(
self, tmp_path: Path, full_context: MachineContext
) -> None:
"""Erstellt den Kontextordner falls er nicht existiert."""
mgr = SecretEncryptionManager(tmp_path, full_context)
mgr.setup_gitcrypt_filters("privat")
assert (tmp_path / "privat" / ".gitattributes").exists()
def test_overwrites_existing_gitattributes(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Überschreibt bestehende .gitattributes-Datei."""
gitattr_path = tmp_monorepo / "privat" / ".gitattributes"
gitattr_path.write_text("# alte Konfiguration\n*.old filter=old")
manager.setup_gitcrypt_filters("privat")
content = gitattr_path.read_text(encoding="utf-8")
assert "alte Konfiguration" not in content
assert "filter=git-crypt-privat" in content
def test_gitattributes_has_header_comment(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Die .gitattributes-Datei enthält einen informativen Header."""
manager.setup_gitcrypt_filters("bahn")
content = (tmp_monorepo / "bahn" / ".gitattributes").read_text(encoding="utf-8")
assert "# git-crypt Filter" in content
assert "bahn" in content
# ---------------------------------------------------------------------------
# Tests: Edge Cases shared-Kontext und Dateien außerhalb
# ---------------------------------------------------------------------------
class TestEdgeCases:
"""Edge Cases für die Verschlüsselungs-Operationen."""
def test_shared_context_file_decrypt_unauthorized(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Datei im shared-Kontext: 'shared' ist nicht in authorized_contexts."""
shared_file = tmp_monorepo / "shared" / "config" / "secrets.env"
shared_file.parent.mkdir(parents=True, exist_ok=True)
shared_file.write_text("SHARED_SECRET=123")
# 'shared' ist nicht in der authorized_contexts-Liste des full_context
result = manager.decrypt_file(shared_file)
# Da 'shared' nicht in authorized_contexts ist, schlägt es fehl
assert result.success is False
def test_deeply_nested_file_resolves_context(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Tief verschachtelte Datei wird korrekt dem Kontext zugeordnet."""
deep_file = tmp_monorepo / "dhive" / "proj" / "module" / "sub" / ".env"
deep_file.parent.mkdir(parents=True, exist_ok=True)
deep_file.write_bytes(b"DEEP_SECRET=value")
with patch.object(manager, "_run_gitcrypt"):
result = manager.decrypt_file(deep_file)
assert result.success is True
assert result.content == b"DEEP_SECRET=value"
def test_encrypt_file_with_empty_content(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Leere Datei kann verschlüsselt werden."""
empty_file = tmp_monorepo / "privat" / "empty.key"
empty_file.write_text("")
with patch.object(manager, "_run_gitcrypt"):
result = manager.encrypt_file(empty_file, "privat")
assert result.success is True
def test_encrypt_file_path_outside_monorepo(
self, tmp_path: Path, full_context: MachineContext
) -> None:
"""Datei komplett außerhalb des Monorepo-Roots."""
# Erstelle einen separaten Pfad als "Monorepo-Root"
mono_root = tmp_path / "monorepo"
mono_root.mkdir()
(mono_root / "privat").mkdir()
mgr = SecretEncryptionManager(mono_root, full_context)
# Datei außerhalb
outside_file = tmp_path / "outside" / "secret.env"
outside_file.parent.mkdir(parents=True, exist_ok=True)
outside_file.write_text("OUTSIDE=true")
# encrypt_file prüft nur Existenz und Autorisierung, nicht den Pfad
# aber es sollte nicht abstürzen
with patch.object(mgr, "_run_gitcrypt"):
result = mgr.encrypt_file(outside_file, "privat")
assert result.success is True # encrypt prüft Pfad nicht, nur Kontext-Param
def test_decrypt_file_path_outside_monorepo(
self, tmp_path: Path, full_context: MachineContext
) -> None:
"""Datei außerhalb des Monorepo-Roots kann nicht entschlüsselt werden."""
mono_root = tmp_path / "monorepo"
mono_root.mkdir()
mgr = SecretEncryptionManager(mono_root, full_context)
outside_file = tmp_path / "somewhere-else" / "secret.env"
outside_file.parent.mkdir(parents=True, exist_ok=True)
outside_file.write_text("OUTSIDE=true")
result = mgr.decrypt_file(outside_file)
# Kontext kann nicht aus dem Pfad ermittelt werden
assert result.success is False
assert "kontext" in result.error.lower()
def test_resolve_merge_for_shared_context_file(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Merge-Auflösung für Datei im shared-Ordner: ours gewinnt (shared nicht autorisiert)."""
shared_file = tmp_monorepo / "shared" / "config.yaml"
shared_file.write_text("config: value")
result = manager.resolve_merge(shared_file, b"ours", b"theirs")
# shared ist nicht in authorized_contexts → ours beibehalten
assert result == b"ours"
def test_setup_gitcrypt_filters_for_shared_context(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""setup_gitcrypt_filters funktioniert auch für den shared-Kontext."""
manager.setup_gitcrypt_filters("shared")
gitattr_path = tmp_monorepo / "shared" / ".gitattributes"
assert gitattr_path.exists()
content = gitattr_path.read_text(encoding="utf-8")
assert "filter=git-crypt-shared" in content