"""Unit-Tests für den Read-Only-Schutzmechanismus und Sync-Fehlerprotokollierung. Testet: - protect_readonly installiert pre-commit Hook - Hook verhindert Commits auf geschützte Verzeichnisse - Fehlermeldung enthält Repo-Name und Read-Only-Status - install_all_readonly_hooks schützt alle read-only Repos - log_sync_error protokolliert Fehler in Audit-Datei - Bestehende Hooks werden nicht überschrieben Validates: Requirements 4.2, 4.5, 4.9 """ from __future__ import annotations import subprocess from pathlib import Path import pytest import yaml from monorepo.models import RepoEntry from monorepo.repos import RepoManager, _HOOK_END_MARKER, _HOOK_START_MARKER @pytest.fixture def repos_config(tmp_path: Path) -> Path: """Erstellt eine repos.yaml mit Read-Only- und Upstream-Repos.""" config_path = tmp_path / "config" / "repos.yaml" config_path.parent.mkdir(parents=True) data = { "repos": [ { "name": "symphony-spec", "url": "https://github.com/openai/symphony", "mode": "read-only", "target": "shared/references/symphony", "pinned": "v1.0.0", "mechanism": "subtree", }, { "name": "another-readonly", "url": "https://github.com/example/lib", "mode": "read-only", "target": "shared/references/lib", "pinned": "main", "mechanism": "subtree", }, { "name": "db-wissensdatenbank", "url": "https://gitlab.example.com/db-wissen", "mode": "upstream", "target": "bahn/db-wissensdatenbank", "pinned": "main", "mechanism": "subtree", }, ] } with open(config_path, "w", encoding="utf-8") as f: yaml.dump(data, f) return config_path @pytest.fixture def monorepo_root(tmp_path: Path) -> Path: """Erstellt ein Monorepo-Verzeichnis mit .git/hooks Struktur.""" root = tmp_path / "monorepo" root.mkdir() (root / ".git" / "hooks").mkdir(parents=True) return root @pytest.fixture def manager(repos_config: Path, monorepo_root: Path) -> RepoManager: """Erstellt einen RepoManager mit Testkonfiguration.""" return RepoManager(config_path=repos_config, monorepo_root=monorepo_root) # --------------------------------------------------------------------------- # protect_readonly: Hook-Installation # --------------------------------------------------------------------------- class TestProtectReadonly: """Testet die Installation des Read-Only-Schutzmechanismus.""" def test_creates_pre_commit_hook( self, manager: RepoManager, monorepo_root: Path ) -> None: """protect_readonly erstellt einen pre-commit Hook.""" target = monorepo_root / "shared" / "references" / "symphony" target.mkdir(parents=True) manager.protect_readonly(target) hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" assert hook_path.exists() def test_hook_contains_protected_path( self, manager: RepoManager, monorepo_root: Path ) -> None: """Der Hook enthält den geschützten Pfad.""" target = monorepo_root / "shared" / "references" / "symphony" target.mkdir(parents=True) manager.protect_readonly(target) hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" content = hook_path.read_text(encoding="utf-8") assert "shared/references/symphony" in content def test_hook_contains_error_message_template( self, manager: RepoManager, monorepo_root: Path ) -> None: """Der Hook enthält die Fehlermeldung mit Read-Only-Status.""" target = monorepo_root / "shared" / "references" / "symphony" target.mkdir(parents=True) manager.protect_readonly(target) hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" content = hook_path.read_text(encoding="utf-8") assert "FEHLER:" in content assert "read-only" in content assert "Schreibzugriff" in content def test_hook_is_executable( self, manager: RepoManager, monorepo_root: Path ) -> None: """Der Hook hat Ausführungsrechte (nur relevant auf Unix-Systemen).""" import os import sys target = monorepo_root / "shared" / "references" / "symphony" target.mkdir(parents=True) manager.protect_readonly(target) hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" if sys.platform != "win32": mode = hook_path.stat().st_mode # Prüfe Owner-Execute-Bit assert mode & 0o100 else: # Auf Windows existiert das Execute-Bit-Konzept nicht, # aber die chmod-Operation soll keinen Fehler werfen. assert hook_path.exists() def test_hook_uses_git_diff_cached( self, manager: RepoManager, monorepo_root: Path ) -> None: """Der Hook verwendet 'git diff --cached --name-only'.""" target = monorepo_root / "shared" / "references" / "symphony" target.mkdir(parents=True) manager.protect_readonly(target) hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" content = hook_path.read_text(encoding="utf-8") assert "git diff --cached --name-only" in content def test_hook_starts_with_shebang( self, manager: RepoManager, monorepo_root: Path ) -> None: """Der Hook beginnt mit einem Shell-Shebang.""" target = monorepo_root / "shared" / "references" / "symphony" target.mkdir(parents=True) manager.protect_readonly(target) hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" content = hook_path.read_text(encoding="utf-8") assert content.startswith("#!/bin/sh") def test_raises_error_for_path_outside_monorepo( self, manager: RepoManager, tmp_path: Path ) -> None: """ValueError wenn Pfad außerhalb des Monorepo-Roots liegt.""" outside_path = tmp_path / "outside" / "some-repo" outside_path.mkdir(parents=True) with pytest.raises(ValueError, match="liegt nicht innerhalb"): manager.protect_readonly(outside_path) # --------------------------------------------------------------------------- # protect_readonly: Bestehenden Hook aktualisieren # --------------------------------------------------------------------------- class TestProtectReadonlyUpdate: """Testet das Aktualisieren eines bestehenden pre-commit Hooks.""" def test_appends_to_existing_hook( self, manager: RepoManager, monorepo_root: Path ) -> None: """Fügt Schutzabschnitt an bestehenden Hook an.""" hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" hook_path.write_text("#!/bin/sh\necho 'existing hook'\n", encoding="utf-8") target = monorepo_root / "shared" / "references" / "symphony" target.mkdir(parents=True) manager.protect_readonly(target) content = hook_path.read_text(encoding="utf-8") assert "existing hook" in content assert "PROTECTED_PATHS" in content def test_updates_existing_readonly_section( self, manager: RepoManager, monorepo_root: Path ) -> None: """Ersetzt bestehenden Read-Only-Abschnitt statt ihn zu duplizieren.""" target1 = monorepo_root / "shared" / "references" / "symphony" target1.mkdir(parents=True) target2 = monorepo_root / "shared" / "references" / "lib" target2.mkdir(parents=True) # Erster Aufruf: Symphony schützen manager.protect_readonly(target1) # Zweiter Aufruf: Lib schützen (erweitert den bestehenden Abschnitt) manager.protect_readonly(target2) hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" content = hook_path.read_text(encoding="utf-8") # Beide Pfade sollen im Hook sein assert "shared/references/symphony" in content assert "shared/references/lib" in content # Nur ein Start-Marker (kein Duplizieren) assert content.count(_HOOK_START_MARKER) == 1 assert content.count(_HOOK_END_MARKER) == 1 def test_does_not_duplicate_path( self, manager: RepoManager, monorepo_root: Path ) -> None: """Fügt denselben Pfad nicht doppelt hinzu.""" target = monorepo_root / "shared" / "references" / "symphony" target.mkdir(parents=True) manager.protect_readonly(target) manager.protect_readonly(target) hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" content = hook_path.read_text(encoding="utf-8") # Der Pfad soll nur einmal im PROTECTED_PATHS-Array vorkommen assert content.count('"shared/references/symphony"') == 1 # --------------------------------------------------------------------------- # install_all_readonly_hooks # --------------------------------------------------------------------------- class TestInstallAllReadonlyHooks: """Testet die Installation für alle read-only Repos.""" def test_installs_hooks_for_all_readonly_repos( self, manager: RepoManager, monorepo_root: Path ) -> None: """Installiert Schutz für alle read-only konfigurierten Repos.""" # Erstelle die Target-Verzeichnisse (monorepo_root / "shared" / "references" / "symphony").mkdir(parents=True) (monorepo_root / "shared" / "references" / "lib").mkdir(parents=True) manager.install_all_readonly_hooks() hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" assert hook_path.exists() content = hook_path.read_text(encoding="utf-8") # Beide read-only Repos sollen geschützt sein assert "shared/references/symphony" in content assert "shared/references/lib" in content def test_does_not_protect_upstream_repos( self, manager: RepoManager, monorepo_root: Path ) -> None: """Upstream-Repos werden NICHT als read-only geschützt.""" (monorepo_root / "shared" / "references" / "symphony").mkdir(parents=True) (monorepo_root / "shared" / "references" / "lib").mkdir(parents=True) manager.install_all_readonly_hooks() hook_path = monorepo_root / ".git" / "hooks" / "pre-commit" content = hook_path.read_text(encoding="utf-8") # Upstream-Repo darf NICHT im Schutzbereich sein assert "bahn/db-wissensdatenbank" not in content def test_no_hook_when_no_readonly_repos( self, tmp_path: Path ) -> None: """Kein Hook wenn keine read-only Repos konfiguriert sind.""" config_path = tmp_path / "config" / "repos.yaml" config_path.parent.mkdir(parents=True) data = { "repos": [ { "name": "upstream-only", "url": "https://example.com/repo.git", "mode": "upstream", "target": "bahn/repo", "pinned": "main", "mechanism": "subtree", } ] } with open(config_path, "w", encoding="utf-8") as f: yaml.dump(data, f) root = tmp_path / "monorepo" root.mkdir() (root / ".git" / "hooks").mkdir(parents=True) mgr = RepoManager(config_path=config_path, monorepo_root=root) mgr.install_all_readonly_hooks() hook_path = root / ".git" / "hooks" / "pre-commit" assert not hook_path.exists() # --------------------------------------------------------------------------- # log_sync_error: Fehlerprotokollierung (Req 4.9) # --------------------------------------------------------------------------- class TestLogSyncError: """Testet die Sync-Fehlerprotokollierung.""" def test_creates_audit_directory( self, manager: RepoManager, monorepo_root: Path ) -> None: """Erstellt das .audit-Verzeichnis falls nicht vorhanden.""" manager.log_sync_error("test-repo", "Network timeout", "pull") assert (monorepo_root / ".audit").exists() def test_creates_sync_errors_log( self, manager: RepoManager, monorepo_root: Path ) -> None: """Erstellt die sync-errors.log Datei.""" manager.log_sync_error("test-repo", "Connection refused", "push") log_path = monorepo_root / ".audit" / "sync-errors.log" assert log_path.exists() def test_log_contains_repo_name( self, manager: RepoManager, monorepo_root: Path ) -> None: """Log-Eintrag enthält den Repo-Namen.""" manager.log_sync_error("symphony-spec", "Auth failed", "pull") log_path = monorepo_root / ".audit" / "sync-errors.log" content = log_path.read_text(encoding="utf-8") assert "repo=symphony-spec" in content def test_log_contains_direction( self, manager: RepoManager, monorepo_root: Path ) -> None: """Log-Eintrag enthält die Sync-Richtung.""" manager.log_sync_error("test-repo", "Merge conflict", "full") log_path = monorepo_root / ".audit" / "sync-errors.log" content = log_path.read_text(encoding="utf-8") assert "direction=full" in content def test_log_contains_error_reason( self, manager: RepoManager, monorepo_root: Path ) -> None: """Log-Eintrag enthält den Fehlergrund.""" manager.log_sync_error("test-repo", "fatal: unable to access", "pull") log_path = monorepo_root / ".audit" / "sync-errors.log" content = log_path.read_text(encoding="utf-8") assert "error=fatal: unable to access" in content def test_log_contains_timestamp( self, manager: RepoManager, monorepo_root: Path ) -> None: """Log-Eintrag enthält einen ISO-Zeitstempel.""" manager.log_sync_error("test-repo", "Network error", "pull") log_path = monorepo_root / ".audit" / "sync-errors.log" content = log_path.read_text(encoding="utf-8") # ISO-Timestamp Format: [YYYY-MM-DDTHH:MM:SS] assert content.startswith("[20") assert "T" in content.split("]")[0] def test_appends_to_existing_log( self, manager: RepoManager, monorepo_root: Path ) -> None: """Mehrere Fehler werden untereinander protokolliert.""" manager.log_sync_error("repo-1", "Error 1", "pull") manager.log_sync_error("repo-2", "Error 2", "push") log_path = monorepo_root / ".audit" / "sync-errors.log" content = log_path.read_text(encoding="utf-8") lines = [line for line in content.strip().splitlines() if line] assert len(lines) == 2 assert "repo-1" in lines[0] assert "repo-2" in lines[1] # --------------------------------------------------------------------------- # Integration: _handle_sync_error schreibt auch in Audit-Log # --------------------------------------------------------------------------- class TestSyncErrorIntegration: """Testet dass Sync-Fehler automatisch in die Audit-Datei geschrieben werden.""" def test_sync_error_writes_to_audit_log( self, manager: RepoManager, monorepo_root: Path ) -> None: """Ein Netzwerkfehler bei Sync wird in sync-errors.log protokolliert.""" from unittest.mock import patch with patch("monorepo.repos.RepoManager._run_git") as mock_git: mock_git.side_effect = subprocess.CalledProcessError( returncode=128, cmd=["git", "subtree", "pull"], output="", stderr="fatal: unable to access 'https://...': Could not resolve host", ) result = manager.sync("symphony-spec") assert result.success is False log_path = monorepo_root / ".audit" / "sync-errors.log" assert log_path.exists() content = log_path.read_text(encoding="utf-8") assert "repo=symphony-spec" in content assert "Could not resolve host" in content