"""Unit-Tests für das hooks-Modul. Tests für: - HookManager: Installation, Deinstallation, Validierung - Read-Only-Schutz: Prüfung gestaged Dateien in geschützten Pfaden - Secret-Verschlüsselungs-Prüfung: Unverschlüsselte Secrets blockieren - Integration mit ContextGuard und SecretEncryptionManager - Hook-Installation bei `init` """ from __future__ import annotations import os import stat from pathlib import Path import pytest import yaml from monorepo.hooks import HookManager, install_hooks, uninstall_hooks # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def monorepo_root(tmp_path: Path) -> Path: """Erstellt ein minimales Monorepo mit .git-Verzeichnis und Konfiguration.""" root = tmp_path / "monorepo" root.mkdir() # Git-Verzeichnis simulieren git_dir = root / ".git" git_dir.mkdir() (git_dir / "hooks").mkdir() # Kontextordner for ctx in ["privat", "dhive", "bahn", "shared"]: (root / ctx).mkdir() # shared/config config_dir = root / "shared" / "config" config_dir.mkdir(parents=True) # repos.yaml mit einem Read-Only-Repo repos_config = { "repos": [ { "name": "symphony-spec", "url": "https://github.com/openai/symphony", "mode": "read-only", "target": "shared/references/symphony", "pinned": "v1.0.0", "mechanism": "subtree", }, { "name": "db-wissensdatenbank", "url": "https://gitlab.2700.2db.it/team/db-wissen.git", "mode": "upstream", "target": "bahn/db-wissensdatenbank", "pinned": "main", "mechanism": "subtree", }, ] } with open(config_dir / "repos.yaml", "w", encoding="utf-8") as f: yaml.dump(repos_config, f) # access-config.yaml (für ContextGuard) access_config = { "contexts": { "privat": { "env_file": "privat/.env", "allowed_shared": ["shared/tools/", "shared/powers/", "shared/config/"], }, "dhive": { "env_file": "dhive/.env", "allowed_shared": ["shared/tools/", "shared/powers/", "shared/config/"], }, "bahn": { "env_file": "bahn/.env", "allowed_shared": ["shared/tools/", "shared/powers/", "shared/config/"], }, "shared": { "env_file": "shared/.env", "allowed_shared": ["*"], }, } } with open(config_dir / "access-config.yaml", "w", encoding="utf-8") as f: yaml.dump(access_config, f) # .gitattributes im privat-Kontext (mit git-crypt-Filter) privat_ga = root / "privat" / ".gitattributes" privat_ga.write_text( "# git-crypt Filter für Kontext: privat\n" ".env filter=git-crypt-privat diff=git-crypt-privat\n" "*.pem filter=git-crypt-privat diff=git-crypt-privat\n" "*.key filter=git-crypt-privat diff=git-crypt-privat\n" "*token* filter=git-crypt-privat diff=git-crypt-privat\n" "*secret* filter=git-crypt-privat diff=git-crypt-privat\n", encoding="utf-8", ) return root @pytest.fixture def hook_manager(monorepo_root: Path) -> HookManager: """Erstellt einen HookManager für das Test-Monorepo.""" return HookManager(monorepo_root=monorepo_root) # --------------------------------------------------------------------------- # Tests: Installation und Deinstallation # --------------------------------------------------------------------------- class TestHookInstallation: """Tests für die Hook-Installation und -Deinstallation.""" def test_install_creates_hook_file(self, hook_manager: HookManager, monorepo_root: Path): """Hook-Installation erstellt die pre-commit Datei.""" hook_manager.install_hooks() hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" assert hook_path.exists() def test_hook_is_executable(self, hook_manager: HookManager, monorepo_root: Path): """Installierter Hook hat Ausführungsrechte (Unix) bzw. existiert (Windows).""" hook_manager.install_hooks() hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" if os.name != "nt": mode = hook_path.stat().st_mode assert mode & stat.S_IXUSR # Owner execute else: # Windows unterstützt keine Unix-Permissions, nur Existenz prüfen assert hook_path.exists() def test_hook_contains_shebang(self, hook_manager: HookManager, monorepo_root: Path): """Installierter Hook beginnt mit Shebang.""" hook_manager.install_hooks() hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" content = hook_path.read_text(encoding="utf-8") assert content.startswith("#!/bin/sh") def test_hook_contains_markers(self, hook_manager: HookManager, monorepo_root: Path): """Installierter Hook enthält Start- und End-Marker.""" hook_manager.install_hooks() hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" content = hook_path.read_text(encoding="utf-8") assert "# === Monorepo Integrated Pre-Commit Hook (auto-generiert) ===" in content assert "# === Ende Monorepo Integrated Pre-Commit Hook ===" in content def test_hook_contains_readonly_section(self, hook_manager: HookManager, monorepo_root: Path): """Installierter Hook enthält Read-Only-Schutz-Abschnitt.""" hook_manager.install_hooks() hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" content = hook_path.read_text(encoding="utf-8") assert "Read-Only-Repos Schutz" in content assert "shared/references/symphony" in content def test_hook_contains_secrets_section(self, hook_manager: HookManager, monorepo_root: Path): """Installierter Hook enthält Secret-Prüfungs-Abschnitt.""" hook_manager.install_hooks() hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" content = hook_path.read_text(encoding="utf-8") assert "Unverschlüsselte Secrets Prüfung" in content assert "check_encrypted" in content assert "is_secret_file" in content def test_hook_preserves_existing_content(self, hook_manager: HookManager, monorepo_root: Path): """Installation bewahrt bestehende Hook-Inhalte.""" hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" hook_path.write_text("#!/bin/sh\n# Custom Hook Logic\necho 'custom'\n", encoding="utf-8") hook_manager.install_hooks() content = hook_path.read_text(encoding="utf-8") # Bestehender Inhalt muss erhalten bleiben assert "# Custom Hook Logic" in content assert "echo 'custom'" in content # Neuer Monorepo-Abschnitt muss vorhanden sein assert "Monorepo Integrated Pre-Commit Hook" in content def test_hook_update_replaces_existing_section(self, hook_manager: HookManager, monorepo_root: Path): """Erneute Installation aktualisiert nur den Monorepo-Abschnitt.""" hook_manager.install_hooks() # Zweite Installation (simuliert Update) hook_manager.install_hooks() hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" content = hook_path.read_text(encoding="utf-8") # Sollte genau einmal den Marker enthalten (kein doppelter Abschnitt) assert content.count("# === Monorepo Integrated Pre-Commit Hook") == 1 def test_uninstall_removes_section(self, hook_manager: HookManager, monorepo_root: Path): """Deinstallation entfernt den Monorepo-Abschnitt.""" hook_manager.install_hooks() hook_manager.uninstall_hooks() hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" # Hook-Datei sollte entfernt sein (war nur Monorepo-Inhalt) assert not hook_path.exists() def test_uninstall_preserves_custom_content(self, hook_manager: HookManager, monorepo_root: Path): """Deinstallation bewahrt benutzerdefinierte Hook-Inhalte.""" hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" hook_path.write_text("#!/bin/sh\n# Custom Logic\necho 'keep'\n", encoding="utf-8") hook_manager.install_hooks() hook_manager.uninstall_hooks() assert hook_path.exists() content = hook_path.read_text(encoding="utf-8") assert "# Custom Logic" in content assert "Monorepo Integrated Pre-Commit Hook" not in content def test_is_hook_installed(self, hook_manager: HookManager): """is_hook_installed gibt korrekt True/False zurück.""" assert not hook_manager.is_hook_installed() hook_manager.install_hooks() assert hook_manager.is_hook_installed() def test_uninstall_when_no_hook(self, hook_manager: HookManager): """Deinstallation ohne vorhandenen Hook läuft ohne Fehler.""" # Sollte keine Exception werfen hook_manager.uninstall_hooks() # --------------------------------------------------------------------------- # Tests: Read-Only-Schutz # --------------------------------------------------------------------------- class TestReadOnlyProtection: """Tests für die Read-Only-Repo-Schutzprüfung.""" def test_readonly_path_is_detected(self, hook_manager: HookManager): """Gestagte Dateien in Read-Only-Repos werden blockiert.""" errors = hook_manager.validate_staged_files([ "shared/references/symphony/README.md", ]) assert len(errors) == 1 assert "read-only" in errors[0] assert "symphony" in errors[0] def test_readonly_subdirectory_is_protected(self, hook_manager: HookManager): """Dateien in Unterverzeichnissen von Read-Only-Repos werden blockiert.""" errors = hook_manager.validate_staged_files([ "shared/references/symphony/src/main.py", ]) assert len(errors) == 1 assert "read-only" in errors[0] def test_non_readonly_repo_is_allowed(self, hook_manager: HookManager): """Dateien in Upstream-Repos werden nicht blockiert (nur Read-Only).""" errors = hook_manager.validate_staged_files([ "bahn/db-wissensdatenbank/docs/README.md", ]) # Upstream-Repos sollten nicht blockiert werden readonly_errors = [e for e in errors if "read-only" in e] assert len(readonly_errors) == 0 def test_normal_files_are_allowed(self, hook_manager: HookManager): """Normale Dateien außerhalb von Read-Only-Repos werden durchgelassen.""" errors = hook_manager.validate_staged_files([ "privat/my-project/src/main.py", "dhive/tooling/config.yaml", ]) assert len(errors) == 0 def test_multiple_violations_reported(self, hook_manager: HookManager): """Mehrere Verstöße werden alle gemeldet.""" errors = hook_manager.validate_staged_files([ "shared/references/symphony/file1.py", "shared/references/symphony/file2.py", ]) assert len(errors) == 2 def test_protected_paths_returned(self, hook_manager: HookManager): """get_protected_paths gibt die konfigurierten Read-Only-Pfade zurück.""" paths = hook_manager.get_protected_paths() assert "shared/references/symphony" in paths # Upstream-Repos sollten NICHT in der geschützten Liste sein assert "bahn/db-wissensdatenbank" not in paths # --------------------------------------------------------------------------- # Tests: Secret-Verschlüsselungs-Prüfung # --------------------------------------------------------------------------- class TestSecretEncryptionCheck: """Tests für die Prüfung unverschlüsselter Secrets.""" def test_env_file_without_encryption_blocked(self, hook_manager: HookManager, monorepo_root: Path): """Unverschlüsselte .env-Datei wird blockiert (kein Filter konfiguriert).""" # Erstelle eine .env im dhive-Kontext OHNE git-crypt-Filter env_path = monorepo_root / "dhive" / ".env" env_path.write_text("SECRET_KEY=abc123\n", encoding="utf-8") errors = hook_manager.validate_staged_files(["dhive/.env"]) assert len(errors) == 1 assert "nicht verschlüsselt" in errors[0] def test_env_file_with_filter_allowed(self, hook_manager: HookManager, monorepo_root: Path): """Unverschlüsselte .env-Datei mit git-crypt-Filter wird durchgelassen.""" # privat/.env hat einen git-crypt-Filter in .gitattributes env_path = monorepo_root / "privat" / ".env" env_path.write_text("MY_SECRET=xyz\n", encoding="utf-8") errors = hook_manager.validate_staged_files(["privat/.env"]) assert len(errors) == 0 def test_pem_file_without_filter_blocked(self, hook_manager: HookManager, monorepo_root: Path): """Unverschlüsselte .pem-Datei ohne Filter wird blockiert.""" pem_path = monorepo_root / "dhive" / "cert.pem" pem_path.write_text("-----BEGIN CERTIFICATE-----\n...\n", encoding="utf-8") errors = hook_manager.validate_staged_files(["dhive/cert.pem"]) assert len(errors) == 1 assert "nicht verschlüsselt" in errors[0] def test_pem_file_with_filter_allowed(self, hook_manager: HookManager, monorepo_root: Path): """Unverschlüsselte .pem-Datei mit git-crypt-Filter wird durchgelassen.""" pem_path = monorepo_root / "privat" / "server.pem" pem_path.write_text("-----BEGIN RSA PRIVATE KEY-----\n...\n", encoding="utf-8") errors = hook_manager.validate_staged_files(["privat/server.pem"]) assert len(errors) == 0 def test_key_file_detected(self, hook_manager: HookManager, monorepo_root: Path): """*.key-Dateien werden als Secrets erkannt.""" key_path = monorepo_root / "dhive" / "private.key" key_path.write_text("key-data\n", encoding="utf-8") errors = hook_manager.validate_staged_files(["dhive/private.key"]) assert len(errors) == 1 def test_token_file_detected(self, hook_manager: HookManager, monorepo_root: Path): """Dateien mit 'token' im Namen werden als Secrets erkannt.""" token_path = monorepo_root / "dhive" / "api-token.txt" token_path.write_text("token123\n", encoding="utf-8") errors = hook_manager.validate_staged_files(["dhive/api-token.txt"]) assert len(errors) == 1 def test_secret_file_detected(self, hook_manager: HookManager, monorepo_root: Path): """Dateien mit 'secret' im Namen werden als Secrets erkannt.""" secret_path = monorepo_root / "dhive" / "my-secret-config.yaml" secret_path.write_text("data: secret\n", encoding="utf-8") errors = hook_manager.validate_staged_files(["dhive/my-secret-config.yaml"]) assert len(errors) == 1 def test_encrypted_file_is_allowed(self, hook_manager: HookManager, monorepo_root: Path): """Verschlüsselte Dateien (mit git-crypt-Header) werden durchgelassen.""" # Simuliere eine git-crypt-verschlüsselte Datei enc_path = monorepo_root / "dhive" / ".env" enc_path.write_bytes(b"\x00GITCRYPT\x00" + b"encrypted-data") errors = hook_manager.validate_staged_files(["dhive/.env"]) assert len(errors) == 0 def test_normal_files_not_flagged(self, hook_manager: HookManager, monorepo_root: Path): """Normale Dateien (nicht Secret-Patterns) werden nicht geprüft.""" py_path = monorepo_root / "privat" / "main.py" py_path.write_text("print('hello')\n", encoding="utf-8") errors = hook_manager.validate_staged_files(["privat/main.py"]) assert len(errors) == 0 def test_gitattributes_pattern_matching(self, hook_manager: HookManager): """Pattern-Matching für .gitattributes funktioniert korrekt.""" # Interne Methode testen assert hook_manager._pattern_matches_file(".env", ".env") assert hook_manager._pattern_matches_file("*.pem", "server.pem") assert hook_manager._pattern_matches_file("*.key", "private.key") assert hook_manager._pattern_matches_file("*token*", "api-token.txt") assert hook_manager._pattern_matches_file("*secret*", "my-secret-config.yaml") # Negative Cases assert not hook_manager._pattern_matches_file(".env", ".envrc") assert not hook_manager._pattern_matches_file("*.pem", "file.txt") # --------------------------------------------------------------------------- # Tests: Convenience-Funktionen und CLI-Integration # --------------------------------------------------------------------------- class TestConvenienceFunctions: """Tests für install_hooks/uninstall_hooks Convenience-Funktionen.""" def test_install_hooks_function(self, monorepo_root: Path): """install_hooks()-Funktion erstellt den Hook erfolgreich.""" install_hooks(monorepo_root) hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" assert hook_path.exists() content = hook_path.read_text(encoding="utf-8") assert "Monorepo Integrated Pre-Commit Hook" in content def test_uninstall_hooks_function(self, monorepo_root: Path): """uninstall_hooks()-Funktion entfernt den Hook.""" install_hooks(monorepo_root) uninstall_hooks(monorepo_root) hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" assert not hook_path.exists() def test_install_without_access_config(self, tmp_path: Path): """Installation funktioniert auch ohne access-config.yaml.""" root = tmp_path / "bare-mono" root.mkdir() (root / ".git" / "hooks").mkdir(parents=True) (root / "shared" / "config").mkdir(parents=True) # Nur repos.yaml, keine access-config.yaml repos = {"repos": []} with open(root / "shared" / "config" / "repos.yaml", "w") as f: yaml.dump(repos, f) install_hooks(root) hook_path = root / ".git" / "hooks" / "pre-commit" assert hook_path.exists() def test_install_without_repos_yaml(self, tmp_path: Path): """Installation funktioniert auch ohne repos.yaml (leere Schutzliste).""" root = tmp_path / "bare-mono" root.mkdir() (root / ".git" / "hooks").mkdir(parents=True) (root / "shared" / "config").mkdir(parents=True) install_hooks(root) hook_path = root / ".git" / "hooks" / "pre-commit" assert hook_path.exists() content = hook_path.read_text(encoding="utf-8") assert "Keine Read-Only-Repos konfiguriert" in content # --------------------------------------------------------------------------- # Tests: ContextGuard-Integration # --------------------------------------------------------------------------- class TestContextGuardIntegration: """Tests für die Integration mit dem ContextGuard.""" def test_hook_manager_with_context_guard(self, monorepo_root: Path): """HookManager funktioniert mit eingebundenem ContextGuard.""" from monorepo.security import ContextGuard guard = ContextGuard(monorepo_root) hm = HookManager( monorepo_root=monorepo_root, context_guard=guard, ) # Normale Datei sollte kein Problem sein errors = hm.validate_staged_files(["privat/project/main.py"]) assert len(errors) == 0 def test_hook_manager_without_context_guard(self, monorepo_root: Path): """HookManager funktioniert auch ohne ContextGuard.""" hm = HookManager(monorepo_root=monorepo_root) errors = hm.validate_staged_files(["privat/project/main.py"]) assert len(errors) == 0 # --------------------------------------------------------------------------- # Tests: SecretEncryptionManager-Integration # --------------------------------------------------------------------------- class TestEncryptionManagerIntegration: """Tests für die Integration mit dem SecretEncryptionManager.""" def test_hook_manager_with_encryption_manager(self, monorepo_root: Path): """HookManager funktioniert mit eingebundenem SecretEncryptionManager.""" from monorepo.encryption import SecretEncryptionManager from monorepo.models import MachineContext mc = MachineContext( name="test-machine", description="Test", authorized_contexts=["privat", "dhive", "bahn"], key_source="keyring", ) enc = SecretEncryptionManager(monorepo_root, mc) hm = HookManager( monorepo_root=monorepo_root, encryption_manager=enc, ) hm.install_hooks() assert hm.is_hook_installed() # --------------------------------------------------------------------------- # Tests: is_secret_file Erkennung # --------------------------------------------------------------------------- class TestSecretFileDetection: """Tests für die _is_secret_file Methode.""" def test_env_detected(self, hook_manager: HookManager): """Standard .env wird erkannt.""" assert hook_manager._is_secret_file("privat/.env") assert hook_manager._is_secret_file("dhive/.env") assert hook_manager._is_secret_file(".env") def test_env_prefix_detected(self, hook_manager: HookManager): """.env.local und ähnliche werden erkannt.""" assert hook_manager._is_secret_file("privat/.env.local") assert hook_manager._is_secret_file(".env.production") def test_pem_detected(self, hook_manager: HookManager): """.pem-Dateien werden erkannt.""" assert hook_manager._is_secret_file("certs/server.pem") assert hook_manager._is_secret_file("privat/my.pem") def test_key_detected(self, hook_manager: HookManager): """.key-Dateien werden erkannt.""" assert hook_manager._is_secret_file("privat/ssl.key") assert hook_manager._is_secret_file("private.key") def test_token_detected(self, hook_manager: HookManager): """Dateien mit 'token' werden erkannt.""" assert hook_manager._is_secret_file("api-token.txt") assert hook_manager._is_secret_file("github_token") assert hook_manager._is_secret_file("TOKEN_FILE") def test_secret_detected(self, hook_manager: HookManager): """Dateien mit 'secret' werden erkannt.""" assert hook_manager._is_secret_file("my-secret.yaml") assert hook_manager._is_secret_file("SECRET_CONFIG") def test_normal_files_not_detected(self, hook_manager: HookManager): """Normale Dateien werden nicht als Secrets erkannt.""" assert not hook_manager._is_secret_file("main.py") assert not hook_manager._is_secret_file("README.md") assert not hook_manager._is_secret_file("package.json") assert not hook_manager._is_secret_file("config.yaml")