439 lines
16 KiB
Python
439 lines
16 KiB
Python
"""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
|