"""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