Initial monorepo structure
This commit is contained in:
@@ -0,0 +1,812 @@
|
||||
"""Ergänzende Unit-Tests für FederationManager (Task 15.7).
|
||||
|
||||
Deckt Edge-Cases und Szenarien ab, die in test_federation.py und
|
||||
test_federation_isolation.py noch nicht vorhanden sind:
|
||||
|
||||
- Subtree-Pull/Push mit verschiedenen Fehlertypen (rename, delete-modify)
|
||||
- full_sync: Push-Fehler nach erfolgreichem Pull
|
||||
- resolve_conflict mit konfigurierbarer Strategie
|
||||
- mirror_shared: Gemischte Erfolge/Fehler
|
||||
- Onboarding: Kein Leak von Monorepo-Struktur-Details
|
||||
- Sync-Abbruch bei Merge-Konflikten mit Protokollierung
|
||||
|
||||
Validates: Requirements 10.1, 10.3, 10.5, 10.6, 10.7, 10.9, 10.12
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from monorepo.federation import (
|
||||
ConflictResult,
|
||||
FederationManager,
|
||||
MemberInfo,
|
||||
MirrorResult,
|
||||
SubtreeSyncEngine,
|
||||
)
|
||||
from monorepo.models import ConflictInfo, SyncResult
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team_repos_config(tmp_path: Path) -> Path:
|
||||
"""Erstellt eine temporäre team-repos.yaml."""
|
||||
config_path = tmp_path / "config" / "team-repos.yaml"
|
||||
config_path.parent.mkdir(parents=True)
|
||||
data = {
|
||||
"version": "1.0",
|
||||
"federation": {
|
||||
"topology": "hub-and-spoke",
|
||||
"hub_owner": "andre",
|
||||
"conflict_strategy": "team-wins",
|
||||
},
|
||||
"team_repos": [
|
||||
{
|
||||
"context": "privat",
|
||||
"url": "https://github.com/test/privat-team.git",
|
||||
"branch": "main",
|
||||
"sync_direction": "bidirectional",
|
||||
"sync_frequency": "on-push",
|
||||
"shared_mirror": {
|
||||
"enabled": True,
|
||||
"paths": [
|
||||
"shared/tools/common/",
|
||||
"shared/config/base-config.yaml",
|
||||
],
|
||||
"mode": "read-only",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "dhive",
|
||||
"url": "https://gitlab.dhive.io/team/dhive-mono.git",
|
||||
"branch": "main",
|
||||
"sync_direction": "bidirectional",
|
||||
"sync_frequency": "on-push",
|
||||
"shared_mirror": {
|
||||
"enabled": True,
|
||||
"paths": ["shared/tools/", "shared/powers/"],
|
||||
"mode": "read-only",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "bahn",
|
||||
"url": "https://gitlab.example.com/bahn-workspace.git",
|
||||
"branch": "develop",
|
||||
"sync_direction": "bidirectional",
|
||||
"sync_frequency": "daily",
|
||||
"shared_mirror": {
|
||||
"enabled": True,
|
||||
"paths": ["shared/tools/", "shared/knowledge-store/"],
|
||||
"mode": "read-only",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
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 temporäres Monorepo-Verzeichnis mit Struktur."""
|
||||
root = tmp_path / "monorepo"
|
||||
root.mkdir()
|
||||
(root / "privat").mkdir()
|
||||
(root / "dhive").mkdir()
|
||||
(root / "bahn").mkdir()
|
||||
(root / "shared" / "tools" / "common").mkdir(parents=True)
|
||||
(root / "shared" / "config").mkdir(parents=True)
|
||||
(root / "shared" / "powers").mkdir(parents=True)
|
||||
(root / "shared" / "knowledge-store").mkdir(parents=True)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def federation_manager(
|
||||
team_repos_config: Path, monorepo_root: Path
|
||||
) -> FederationManager:
|
||||
"""Erstellt einen FederationManager mit Testkonfiguration."""
|
||||
return FederationManager(
|
||||
config_path=team_repos_config,
|
||||
monorepo_root=monorepo_root,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(monorepo_root: Path) -> SubtreeSyncEngine:
|
||||
"""Erstellt eine SubtreeSyncEngine mit Test-Root."""
|
||||
return SubtreeSyncEngine(monorepo_root=monorepo_root)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: SubtreeSyncEngine - Erweiterte Konflikt-Typen
|
||||
# Validates: Requirements 10.1, 10.3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSubtreeSyncEngineConflictTypes:
|
||||
"""Tests für verschiedene Konflikt-Typen bei Subtree-Operationen."""
|
||||
|
||||
def test_subtree_pull_rename_conflict(self, engine: SubtreeSyncEngine) -> None:
|
||||
"""Prüft Erkennung von Rename-Konflikten beim Pull."""
|
||||
with patch.object(engine, "_run_git") as mock_git, \
|
||||
patch.object(engine, "_abort_merge"):
|
||||
error = subprocess.CalledProcessError(1, "git")
|
||||
error.stdout = (
|
||||
"CONFLICT (rename/rename): Rename dhive/old.py->dhive/new.py "
|
||||
"in HEAD. Rename dhive/old.py->dhive/other.py in remote."
|
||||
)
|
||||
error.stderr = ""
|
||||
mock_git.side_effect = error
|
||||
|
||||
result = engine.subtree_pull(
|
||||
remote="https://example.com/repo.git",
|
||||
prefix="dhive",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert len(result.conflicts) >= 1
|
||||
assert result.conflicts[0].conflict_type == "rename"
|
||||
|
||||
def test_subtree_pull_delete_modify_conflict(
|
||||
self, engine: SubtreeSyncEngine
|
||||
) -> None:
|
||||
"""Prüft Erkennung von Delete-Modify-Konflikten beim Pull."""
|
||||
with patch.object(engine, "_run_git") as mock_git, \
|
||||
patch.object(engine, "_abort_merge"):
|
||||
error = subprocess.CalledProcessError(1, "git")
|
||||
error.stdout = (
|
||||
"CONFLICT (modify/delete): bahn/removed.py deleted in remote "
|
||||
"and modified in HEAD."
|
||||
)
|
||||
error.stderr = ""
|
||||
mock_git.side_effect = error
|
||||
|
||||
result = engine.subtree_pull(
|
||||
remote="https://example.com/repo.git",
|
||||
prefix="bahn",
|
||||
branch="develop",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert len(result.conflicts) >= 1
|
||||
assert result.conflicts[0].conflict_type == "delete-modify"
|
||||
|
||||
def test_subtree_pull_multiple_conflicts(
|
||||
self, engine: SubtreeSyncEngine
|
||||
) -> None:
|
||||
"""Prüft Erkennung mehrerer Konflikte in einem Pull."""
|
||||
with patch.object(engine, "_run_git") as mock_git, \
|
||||
patch.object(engine, "_abort_merge"):
|
||||
error = subprocess.CalledProcessError(1, "git")
|
||||
error.stdout = (
|
||||
"CONFLICT (content): Merge conflict in privat/a.py\n"
|
||||
"CONFLICT (content): Merge conflict in privat/b.py\n"
|
||||
"CONFLICT (rename/rename): Rename privat/c.py\n"
|
||||
"Automatic merge failed; fix conflicts and then commit."
|
||||
)
|
||||
error.stderr = ""
|
||||
mock_git.side_effect = error
|
||||
|
||||
result = engine.subtree_pull(
|
||||
remote="https://example.com/repo.git",
|
||||
prefix="privat",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert len(result.conflicts) == 3
|
||||
content_conflicts = [
|
||||
c for c in result.conflicts if c.conflict_type == "content"
|
||||
]
|
||||
rename_conflicts = [
|
||||
c for c in result.conflicts if c.conflict_type == "rename"
|
||||
]
|
||||
assert len(content_conflicts) == 2
|
||||
assert len(rename_conflicts) == 1
|
||||
|
||||
def test_subtree_push_auth_error(self, engine: SubtreeSyncEngine) -> None:
|
||||
"""Prüft Fehlerbehandlung bei Authentifizierungsfehler."""
|
||||
with patch.object(engine, "_run_git") as mock_git:
|
||||
error = subprocess.CalledProcessError(128, "git")
|
||||
error.stdout = ""
|
||||
error.stderr = "fatal: Authentication failed for 'https://...'"
|
||||
mock_git.side_effect = error
|
||||
|
||||
result = engine.subtree_push(
|
||||
remote="https://example.com/repo.git",
|
||||
prefix="dhive",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.conflicts[0].source == "monorepo"
|
||||
assert "fehlgeschlagen" in result.conflicts[0].details
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: full_sync - Push-Fehler nach erfolgreichem Pull
|
||||
# Validates: Requirements 10.3, 10.7
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFullSyncPushFailure:
|
||||
"""Tests für full_sync wenn Push nach Pull fehlschlägt."""
|
||||
|
||||
def test_full_sync_push_fails_after_pull_success(
|
||||
self, federation_manager: FederationManager
|
||||
) -> None:
|
||||
"""Prüft dass Push-Fehler nach erfolgreichem Pull korrekt gemeldet wird."""
|
||||
with patch.object(
|
||||
federation_manager.sync_engine, "subtree_pull"
|
||||
) as mock_pull, patch.object(
|
||||
federation_manager.sync_engine, "subtree_push"
|
||||
) as mock_push, patch.object(
|
||||
federation_manager.sync_engine, "detect_conflicts",
|
||||
return_value=[],
|
||||
):
|
||||
mock_pull.return_value = SyncResult(
|
||||
success=True, context="dhive", direction="pull",
|
||||
commits_synced=5, conflicts=[], timestamp=datetime.now(),
|
||||
)
|
||||
mock_push.return_value = SyncResult(
|
||||
success=False, context="dhive", direction="push",
|
||||
commits_synced=0,
|
||||
conflicts=[ConflictInfo(
|
||||
file_path="",
|
||||
conflict_type="content",
|
||||
source="monorepo",
|
||||
details="Push rejected: non-fast-forward",
|
||||
)],
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
result = federation_manager.full_sync("dhive")
|
||||
|
||||
assert result.success is False
|
||||
assert result.direction == "full"
|
||||
# Pull commits are counted even though push failed
|
||||
assert result.commits_synced == 5
|
||||
assert len(result.conflicts) == 1
|
||||
assert "Push rejected" in result.conflicts[0].details
|
||||
|
||||
def test_full_sync_uses_correct_branch_per_context(
|
||||
self, federation_manager: FederationManager
|
||||
) -> None:
|
||||
"""Prüft dass full_sync den konfigurierten Branch verwendet."""
|
||||
with patch.object(
|
||||
federation_manager.sync_engine, "subtree_pull"
|
||||
) as mock_pull, patch.object(
|
||||
federation_manager.sync_engine, "subtree_push"
|
||||
) as mock_push, patch.object(
|
||||
federation_manager.sync_engine, "detect_conflicts",
|
||||
return_value=[],
|
||||
):
|
||||
mock_pull.return_value = SyncResult(
|
||||
success=True, context="bahn", direction="pull",
|
||||
commits_synced=1, conflicts=[], timestamp=datetime.now(),
|
||||
)
|
||||
mock_push.return_value = SyncResult(
|
||||
success=True, context="bahn", direction="push",
|
||||
commits_synced=1, conflicts=[], timestamp=datetime.now(),
|
||||
)
|
||||
federation_manager.full_sync("bahn")
|
||||
|
||||
# bahn uses "develop" branch in our config
|
||||
mock_pull.assert_called_once_with(
|
||||
remote="https://gitlab.example.com/bahn-workspace.git",
|
||||
prefix="bahn",
|
||||
branch="develop",
|
||||
)
|
||||
mock_push.assert_called_once_with(
|
||||
remote="https://gitlab.example.com/bahn-workspace.git",
|
||||
prefix="bahn",
|
||||
branch="develop",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Sync-Abbruch bei Merge-Konflikten mit Protokollierung
|
||||
# Validates: Requirements 10.7
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSyncAbortOnConflict:
|
||||
"""Tests für Sync-Abbruch und Konflikt-Protokollierung."""
|
||||
|
||||
def test_pull_conflict_aborts_merge_cleanly(
|
||||
self, engine: SubtreeSyncEngine
|
||||
) -> None:
|
||||
"""Prüft dass bei Merge-Konflikt ein abort durchgeführt wird."""
|
||||
with patch.object(engine, "_run_git") as mock_git, \
|
||||
patch.object(engine, "_abort_merge") as mock_abort:
|
||||
error = subprocess.CalledProcessError(1, "git")
|
||||
error.stdout = "CONFLICT (content): Merge conflict in file.py"
|
||||
error.stderr = ""
|
||||
mock_git.side_effect = error
|
||||
|
||||
result = engine.subtree_pull(
|
||||
remote="https://example.com/repo.git",
|
||||
prefix="privat",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
# Merge abort muss aufgerufen werden
|
||||
mock_abort.assert_called_once()
|
||||
assert result.success is False
|
||||
assert result.commits_synced == 0
|
||||
|
||||
def test_conflict_details_contain_file_info(
|
||||
self, engine: SubtreeSyncEngine
|
||||
) -> None:
|
||||
"""Prüft dass Konflikt-Details Dateiinformationen enthalten."""
|
||||
with patch.object(engine, "_run_git") as mock_git, \
|
||||
patch.object(engine, "_abort_merge"):
|
||||
error = subprocess.CalledProcessError(1, "git")
|
||||
error.stdout = (
|
||||
"CONFLICT (content): Merge conflict in privat/src/main.py\n"
|
||||
"Automatic merge failed; fix conflicts and then commit."
|
||||
)
|
||||
error.stderr = ""
|
||||
mock_git.side_effect = error
|
||||
|
||||
result = engine.subtree_pull(
|
||||
remote="https://example.com/repo.git",
|
||||
prefix="privat",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result.conflicts[0].file_path == "privat/src/main.py"
|
||||
assert result.conflicts[0].source == "team-repo"
|
||||
assert "CONFLICT" in result.conflicts[0].details
|
||||
|
||||
def test_full_sync_abort_preserves_pull_commit_count(
|
||||
self, federation_manager: FederationManager
|
||||
) -> None:
|
||||
"""Prüft dass bei Abbruch die bisher gezählten Commits erhalten bleiben."""
|
||||
with patch.object(
|
||||
federation_manager.sync_engine, "detect_conflicts",
|
||||
return_value=[ConflictInfo(
|
||||
file_path="privat/dirty.py",
|
||||
conflict_type="content",
|
||||
source="monorepo",
|
||||
details="Uncommitted lokale Änderungen erkannt",
|
||||
)],
|
||||
):
|
||||
result = federation_manager.full_sync("privat")
|
||||
|
||||
assert result.success is False
|
||||
assert result.commits_synced == 0
|
||||
assert result.conflicts[0].file_path == "privat/dirty.py"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Konflikt-Auflösung mit team-wins Strategie (erweitert)
|
||||
# Validates: Requirements 10.6
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveConflictExtended:
|
||||
"""Erweiterte Tests für resolve_conflict mit team-wins."""
|
||||
|
||||
def test_team_wins_is_configured_default_strategy(
|
||||
self, federation_manager: FederationManager
|
||||
) -> None:
|
||||
"""Prüft dass team-wins die konfigurierte Standard-Strategie ist."""
|
||||
assert federation_manager.get_conflict_strategy() == "team-wins"
|
||||
|
||||
def test_resolve_uses_theirs_for_team_wins(
|
||||
self, federation_manager: FederationManager
|
||||
) -> None:
|
||||
"""Prüft dass team-wins git checkout --theirs für jede Datei verwendet."""
|
||||
calls_made: list[list[str]] = []
|
||||
|
||||
def capture_calls(args: list[str]) -> MagicMock:
|
||||
calls_made.append(args)
|
||||
if args[0] == "diff":
|
||||
return MagicMock(stdout="privat/file1.py\n", stderr="")
|
||||
return MagicMock(stdout="", stderr="")
|
||||
|
||||
with patch.object(
|
||||
federation_manager.sync_engine, "_run_git",
|
||||
side_effect=capture_calls,
|
||||
):
|
||||
result = federation_manager.resolve_conflict(
|
||||
"privat", strategy="team-wins"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
# Verify --theirs was used
|
||||
checkout_calls = [c for c in calls_made if "checkout" in c]
|
||||
assert len(checkout_calls) == 1
|
||||
assert "--theirs" in checkout_calls[0]
|
||||
|
||||
def test_resolve_uses_ours_for_hub_wins(
|
||||
self, federation_manager: FederationManager
|
||||
) -> None:
|
||||
"""Prüft dass hub-wins git checkout --ours verwendet."""
|
||||
calls_made: list[list[str]] = []
|
||||
|
||||
def capture_calls(args: list[str]) -> MagicMock:
|
||||
calls_made.append(args)
|
||||
if args[0] == "diff":
|
||||
return MagicMock(stdout="dhive/app.py\n", stderr="")
|
||||
return MagicMock(stdout="", stderr="")
|
||||
|
||||
with patch.object(
|
||||
federation_manager.sync_engine, "_run_git",
|
||||
side_effect=capture_calls,
|
||||
):
|
||||
result = federation_manager.resolve_conflict(
|
||||
"dhive", strategy="hub-wins"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
checkout_calls = [c for c in calls_made if "checkout" in c]
|
||||
assert "--ours" in checkout_calls[0]
|
||||
|
||||
def test_resolve_stages_files_after_resolution(
|
||||
self, federation_manager: FederationManager
|
||||
) -> None:
|
||||
"""Prüft dass aufgelöste Dateien mit git add gestaged werden."""
|
||||
calls_made: list[list[str]] = []
|
||||
|
||||
def capture_calls(args: list[str]) -> MagicMock:
|
||||
calls_made.append(args)
|
||||
if args[0] == "diff":
|
||||
return MagicMock(stdout="privat/resolved.py\n", stderr="")
|
||||
return MagicMock(stdout="", stderr="")
|
||||
|
||||
with patch.object(
|
||||
federation_manager.sync_engine, "_run_git",
|
||||
side_effect=capture_calls,
|
||||
):
|
||||
federation_manager.resolve_conflict("privat")
|
||||
|
||||
add_calls = [c for c in calls_made if c[0] == "add"]
|
||||
assert len(add_calls) == 1
|
||||
assert "privat/resolved.py" in add_calls[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Isolation-Check erweitert
|
||||
# Validates: Requirements 10.5, 10.9
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsolationCheckExtended:
|
||||
"""Erweiterte Isolation-Check-Tests für Leakage-Erkennung."""
|
||||
|
||||
def test_isolation_detects_mixed_path_and_env_leaks(
|
||||
self, federation_manager: FederationManager, tmp_path: Path
|
||||
) -> None:
|
||||
"""Prüft Erkennung gemischter Leaks (Pfad + Env-Var) in einer Datei."""
|
||||
team_repo = tmp_path / "team-repo"
|
||||
team_repo.mkdir()
|
||||
(team_repo / "settings.py").write_text(
|
||||
'import os\n'
|
||||
'DB_PATH = "bahn/database"\n'
|
||||
'KEY = os.getenv("DHIVE_SECRET_KEY")\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = federation_manager.verify_isolation(team_repo, "privat")
|
||||
assert report.is_isolated is False
|
||||
leak_types = {l.leak_type for l in report.leaks}
|
||||
assert "path" in leak_types
|
||||
assert "env_var" in leak_types
|
||||
|
||||
def test_isolation_binary_files_skipped(
|
||||
self, federation_manager: FederationManager, tmp_path: Path
|
||||
) -> None:
|
||||
"""Prüft dass Binärdateien übersprungen werden (kein Crash)."""
|
||||
team_repo = tmp_path / "team-repo"
|
||||
team_repo.mkdir()
|
||||
# Schreibe Binär-Inhalt (nicht-UTF8)
|
||||
(team_repo / "image.png").write_bytes(b'\x89PNG\r\n\x1a\n\x00\x00')
|
||||
# Schreibe saubere Textdatei
|
||||
(team_repo / "clean.py").write_text("x = 1\n", encoding="utf-8")
|
||||
|
||||
report = federation_manager.verify_isolation(team_repo, "privat")
|
||||
assert report.is_isolated is True
|
||||
|
||||
def test_isolation_git_directory_skipped(
|
||||
self, federation_manager: FederationManager, tmp_path: Path
|
||||
) -> None:
|
||||
"""Prüft dass .git-Verzeichnisse nicht gescannt werden."""
|
||||
team_repo = tmp_path / "team-repo"
|
||||
team_repo.mkdir()
|
||||
git_dir = team_repo / ".git" / "refs"
|
||||
git_dir.mkdir(parents=True)
|
||||
# .git enthält Referenzen die wie Leaks aussehen
|
||||
(git_dir / "heads").write_text("dhive/feature\n", encoding="utf-8")
|
||||
# Saubere Hauptdatei
|
||||
(team_repo / "main.py").write_text("# clean\n", encoding="utf-8")
|
||||
|
||||
report = federation_manager.verify_isolation(team_repo, "privat")
|
||||
assert report.is_isolated is True
|
||||
|
||||
def test_isolation_deep_nested_files_checked(
|
||||
self, federation_manager: FederationManager, tmp_path: Path
|
||||
) -> None:
|
||||
"""Prüft dass tief verschachtelte Dateien auch gescannt werden."""
|
||||
team_repo = tmp_path / "team-repo"
|
||||
deep_dir = team_repo / "src" / "core" / "utils" / "helpers"
|
||||
deep_dir.mkdir(parents=True)
|
||||
(deep_dir / "config.yaml").write_text(
|
||||
"remote: bahn/api-gateway\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
report = federation_manager.verify_isolation(team_repo, "privat")
|
||||
assert report.is_isolated is False
|
||||
# Path separator is OS-dependent, check with Path for platform compatibility
|
||||
expected_parts = ("src", "core", "utils", "helpers", "config.yaml")
|
||||
assert any(
|
||||
all(part in l.file_path for part in expected_parts)
|
||||
for l in report.leaks
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Shared-Mirror erweitert
|
||||
# Validates: Requirements 10.12
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMirrorSharedExtended:
|
||||
"""Erweiterte Tests für mirror_shared – Read-Only-Spiegelung."""
|
||||
|
||||
def test_mirror_mixed_success_and_failure(
|
||||
self, federation_manager: FederationManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Prüft dass bei gemischten Pfaden Teilfehler korrekt gemeldet werden."""
|
||||
# Existierender Pfad
|
||||
tools_dir = monorepo_root / "shared" / "tools" / "common"
|
||||
tools_dir.mkdir(parents=True, exist_ok=True)
|
||||
(tools_dir / "helper.sh").write_text("#!/bin/bash\n", encoding="utf-8")
|
||||
|
||||
result = federation_manager.mirror_shared("privat", [
|
||||
"shared/tools/common/helper.sh", # existiert
|
||||
"shared/tools/common/missing.sh", # existiert nicht
|
||||
])
|
||||
|
||||
# Teilerfolg: eine Datei gespiegelt, eine fehlgeschlagen
|
||||
assert result.success is False # Fehler vorhanden
|
||||
assert "shared/tools/common/helper.sh" in result.mirrored_paths
|
||||
assert any("existiert nicht" in e for e in result.errors)
|
||||
|
||||
def test_mirror_preserves_file_content(
|
||||
self, federation_manager: FederationManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Prüft dass der Dateiinhalt exakt gespiegelt wird."""
|
||||
tools_dir = monorepo_root / "shared" / "tools" / "common"
|
||||
tools_dir.mkdir(parents=True, exist_ok=True)
|
||||
original_content = "#!/bin/bash\necho 'Version 2.0'\nexit 0\n"
|
||||
(tools_dir / "deploy.sh").write_text(original_content, encoding="utf-8")
|
||||
|
||||
federation_manager.mirror_shared("privat", ["shared/tools/common/deploy.sh"])
|
||||
|
||||
mirror_file = (
|
||||
monorepo_root / "privat" / ".shared-mirror"
|
||||
/ "shared" / "tools" / "common" / "deploy.sh"
|
||||
)
|
||||
assert mirror_file.read_text(encoding="utf-8") == original_content
|
||||
|
||||
def test_mirror_multiple_allowed_paths(
|
||||
self, federation_manager: FederationManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Prüft Spiegelung mehrerer erlaubter Pfade."""
|
||||
# Erstelle Dateien in verschiedenen erlaubten Pfaden
|
||||
(monorepo_root / "shared" / "tools" / "common" / "a.sh").write_text(
|
||||
"a", encoding="utf-8"
|
||||
)
|
||||
config_file = monorepo_root / "shared" / "config" / "base-config.yaml"
|
||||
config_file.write_text("key: val", encoding="utf-8")
|
||||
|
||||
result = federation_manager.mirror_shared("privat", [
|
||||
"shared/tools/common/a.sh",
|
||||
"shared/config/base-config.yaml",
|
||||
])
|
||||
|
||||
assert result.success is True
|
||||
assert len(result.mirrored_paths) == 2
|
||||
|
||||
def test_mirror_read_only_prevents_write(
|
||||
self, federation_manager: FederationManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Prüft dass gespiegelte Dateien nicht beschrieben werden können."""
|
||||
import stat
|
||||
|
||||
tools_dir = monorepo_root / "shared" / "tools" / "common"
|
||||
tools_dir.mkdir(parents=True, exist_ok=True)
|
||||
(tools_dir / "readonly.sh").write_text("content", encoding="utf-8")
|
||||
|
||||
federation_manager.mirror_shared("privat", ["shared/tools/common/readonly.sh"])
|
||||
|
||||
mirror_file = (
|
||||
monorepo_root / "privat" / ".shared-mirror"
|
||||
/ "shared" / "tools" / "common" / "readonly.sh"
|
||||
)
|
||||
mode = mirror_file.stat().st_mode
|
||||
# Prüfe 0o444: nur Leserechte
|
||||
assert mode & stat.S_IRUSR # Lesen für Owner
|
||||
assert not (mode & stat.S_IWUSR) # Kein Schreiben
|
||||
assert not (mode & stat.S_IXUSR) # Kein Execute
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Onboarding ohne Monorepo-Kenntnis
|
||||
# Validates: Requirements 10.9
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOnboardingNoMonorepoKnowledge:
|
||||
"""Tests dass Onboarding keine Monorepo-Struktur offenbart."""
|
||||
|
||||
def test_onboard_returns_only_team_repo_url(
|
||||
self, federation_manager: FederationManager
|
||||
) -> None:
|
||||
"""Prüft dass nur die Team-Repo-URL zurückgegeben wird."""
|
||||
member = MemberInfo(
|
||||
name="Neue Kollegin", email="kollegin@dhive.io", role="developer"
|
||||
)
|
||||
result = federation_manager.onboard_member("dhive", member)
|
||||
|
||||
assert result.success is True
|
||||
assert result.team_repo_url == "https://gitlab.dhive.io/team/dhive-mono.git"
|
||||
# Keine Erwähnung des Monorepos oder anderer Kontexte
|
||||
assert "monorepo" not in result.team_repo_url.lower()
|
||||
assert "privat" not in result.team_repo_url
|
||||
assert "bahn" not in result.team_repo_url
|
||||
|
||||
def test_onboard_each_context_provides_correct_url(
|
||||
self, federation_manager: FederationManager
|
||||
) -> None:
|
||||
"""Prüft dass jeder Kontext die korrekte Team-Repo-URL bekommt."""
|
||||
expected_urls = {
|
||||
"privat": "https://github.com/test/privat-team.git",
|
||||
"dhive": "https://gitlab.dhive.io/team/dhive-mono.git",
|
||||
"bahn": "https://gitlab.example.com/bahn-workspace.git",
|
||||
}
|
||||
|
||||
for context, expected_url in expected_urls.items():
|
||||
member = MemberInfo(
|
||||
name=f"User {context}",
|
||||
email=f"user@{context}.io",
|
||||
)
|
||||
result = federation_manager.onboard_member(context, member)
|
||||
assert result.success is True
|
||||
assert result.team_repo_url == expected_url
|
||||
assert result.context == context
|
||||
|
||||
def test_onboard_does_not_reveal_hub_topology(
|
||||
self, federation_manager: FederationManager
|
||||
) -> None:
|
||||
"""Prüft dass Hub-and-Spoke-Topologie nicht offenbart wird."""
|
||||
member = MemberInfo(name="Team Member", email="member@bahn.de")
|
||||
result = federation_manager.onboard_member("bahn", member)
|
||||
|
||||
# Serialisiere Ergebnis um nach Leaks zu suchen
|
||||
result_repr = repr(result)
|
||||
assert "hub" not in result_repr.lower()
|
||||
assert "spoke" not in result_repr.lower()
|
||||
assert "andre" not in result_repr.lower()
|
||||
|
||||
def test_onboard_invalid_email_format_still_rejected(
|
||||
self, federation_manager: FederationManager
|
||||
) -> None:
|
||||
"""Prüft dass leere E-Mail abgelehnt wird."""
|
||||
member = MemberInfo(name="Valid Name", email=" ")
|
||||
result = federation_manager.onboard_member("privat", member)
|
||||
assert result.success is False
|
||||
assert any("E-Mail" in e for e in result.errors)
|
||||
|
||||
def test_onboard_preserves_member_info(
|
||||
self, federation_manager: FederationManager
|
||||
) -> None:
|
||||
"""Prüft dass Member-Info im Ergebnis korrekt zurückgegeben wird."""
|
||||
member = MemberInfo(
|
||||
name="Dr. Anna Becker",
|
||||
email="anna.becker@dhive.io",
|
||||
role="architect",
|
||||
)
|
||||
result = federation_manager.onboard_member("dhive", member)
|
||||
|
||||
assert result.success is True
|
||||
assert result.member_name == "Dr. Anna Becker"
|
||||
assert result.context == "dhive"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: SubtreeSyncEngine Hilfsmethoden
|
||||
# Validates: Requirements 10.1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSyncEngineHelpers:
|
||||
"""Tests für interne Hilfsmethoden der SubtreeSyncEngine."""
|
||||
|
||||
def test_is_merge_conflict_various_indicators(
|
||||
self, engine: SubtreeSyncEngine
|
||||
) -> None:
|
||||
"""Prüft Erkennung verschiedener Merge-Konflikt-Indikatoren."""
|
||||
indicators = [
|
||||
"CONFLICT (content): Merge conflict in file.py",
|
||||
"Automatic merge failed; fix conflicts and then commit.",
|
||||
"merge conflict detected",
|
||||
"Merge conflict in path/to/file",
|
||||
"fix conflicts and then commit the result",
|
||||
]
|
||||
for msg in indicators:
|
||||
error = subprocess.CalledProcessError(1, "git")
|
||||
error.stdout = msg
|
||||
error.stderr = ""
|
||||
assert engine._is_merge_conflict(error) is True, (
|
||||
f"Should detect merge conflict in: {msg}"
|
||||
)
|
||||
|
||||
def test_is_merge_conflict_false_for_other_errors(
|
||||
self, engine: SubtreeSyncEngine
|
||||
) -> None:
|
||||
"""Prüft dass andere Fehler nicht als Merge-Konflikt erkannt werden."""
|
||||
non_conflict_msgs = [
|
||||
"fatal: Could not read from remote repository.",
|
||||
"error: failed to push some refs",
|
||||
"Permission denied (publickey).",
|
||||
"fatal: not a git repository",
|
||||
]
|
||||
for msg in non_conflict_msgs:
|
||||
error = subprocess.CalledProcessError(1, "git")
|
||||
error.stdout = ""
|
||||
error.stderr = msg
|
||||
assert engine._is_merge_conflict(error) is False, (
|
||||
f"Should NOT detect conflict in: {msg}"
|
||||
)
|
||||
|
||||
def test_count_commits_empty_output(
|
||||
self, engine: SubtreeSyncEngine
|
||||
) -> None:
|
||||
"""Prüft Commit-Zählung bei leerer Ausgabe."""
|
||||
assert engine._count_commits("") == 0
|
||||
|
||||
def test_count_commits_with_changes(
|
||||
self, engine: SubtreeSyncEngine
|
||||
) -> None:
|
||||
"""Prüft Commit-Zählung bei typischer Git-Ausgabe."""
|
||||
output = "abc1234 First commit\ndef5678 Second commit\n1 file changed\n"
|
||||
count = engine._count_commits(output)
|
||||
assert count >= 1
|
||||
|
||||
def test_extract_conflicts_content_type(
|
||||
self, engine: SubtreeSyncEngine
|
||||
) -> None:
|
||||
"""Prüft Extraktion von Content-Konflikten."""
|
||||
error = subprocess.CalledProcessError(1, "git")
|
||||
error.stdout = "CONFLICT (content): Merge conflict in src/main.py"
|
||||
error.stderr = ""
|
||||
conflicts = engine._extract_conflicts(error)
|
||||
assert len(conflicts) == 1
|
||||
assert conflicts[0].conflict_type == "content"
|
||||
assert "src/main.py" in conflicts[0].file_path
|
||||
|
||||
def test_extract_conflicts_no_conflict_lines_fallback(
|
||||
self, engine: SubtreeSyncEngine
|
||||
) -> None:
|
||||
"""Prüft Fallback wenn keine CONFLICT-Zeilen gefunden werden."""
|
||||
error = subprocess.CalledProcessError(1, "git")
|
||||
error.stdout = "Automatic merge failed"
|
||||
error.stderr = ""
|
||||
conflicts = engine._extract_conflicts(error)
|
||||
assert len(conflicts) == 1
|
||||
assert "Merge-Konflikt erkannt" in conflicts[0].details
|
||||
Reference in New Issue
Block a user