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,547 @@
"""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")