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,462 @@
"""Tests für SecretEncryptionManager Maschinenkontext-Verwaltung (Task 4.2).
Testet:
- get_context_key: Schlüsselabruf aus verschiedenen Quellen
- onboard_machine: Einrichtung neuer Maschinen
- resolve_merge: Merge-Konflikt-Auflösung auf verschlüsselter Ebene
- MachineContextManager: Laden der Konfiguration aus YAML
- Passwort-Manager-Integration
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from monorepo.encryption import (
MachineContextManager,
PasswordManagerError,
SecretEncryptionManager,
)
from monorepo.models import (
EncryptionKey,
MachineContext,
OnboardingResult,
PasswordManagerConfig,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_monorepo(tmp_path: Path) -> Path:
"""Erstellt eine minimale Monorepo-Struktur in tmp_path."""
# Kontextordner
for ctx in ("privat", "dhive", "bahn", "shared"):
(tmp_path / ctx).mkdir()
# .git/git-crypt/keys Struktur simulieren
keys_dir = tmp_path / ".git" / "git-crypt" / "keys"
keys_dir.mkdir(parents=True)
(keys_dir / "default").mkdir()
(keys_dir / "privat").mkdir()
(keys_dir / "dhive").mkdir()
# Konfigurationsdateien
config_dir = tmp_path / "shared" / "config"
config_dir.mkdir(parents=True)
machine_context_yaml = {
"machine": {
"name": "test-rechner",
"description": "Testmaschine",
"authorized_contexts": ["privat", "dhive", "bahn"],
"key_source": "keyring",
"password_manager": {
"type": "bitwarden",
"vault": "monorepo-keys",
},
}
}
(config_dir / "machine-context.yaml").write_text(
yaml.dump(machine_context_yaml), encoding="utf-8"
)
return tmp_path
@pytest.fixture
def full_access_context() -> MachineContext:
"""MachineContext mit Zugriff auf alle Kontexte."""
return MachineContext(
name="andre-hauptrechner",
description="Voller Zugriff",
authorized_contexts=["privat", "dhive", "bahn"],
key_source="keyring",
password_manager=PasswordManagerConfig(
type="bitwarden",
vault="monorepo-keys",
),
)
@pytest.fixture
def limited_context() -> MachineContext:
"""MachineContext mit eingeschränktem Zugriff (nur dhive)."""
return MachineContext(
name="dhive-laptop",
description="Nur dhive-Zugriff",
authorized_contexts=["dhive"],
key_source="keyring",
)
@pytest.fixture
def manager(tmp_monorepo: Path, full_access_context: MachineContext) -> SecretEncryptionManager:
"""SecretEncryptionManager mit vollem Zugriff."""
return SecretEncryptionManager(tmp_monorepo, full_access_context)
@pytest.fixture
def limited_manager(
tmp_monorepo: Path, limited_context: MachineContext
) -> SecretEncryptionManager:
"""SecretEncryptionManager mit eingeschränktem Zugriff."""
return SecretEncryptionManager(tmp_monorepo, limited_context)
# ---------------------------------------------------------------------------
# Tests: get_context_key
# ---------------------------------------------------------------------------
class TestGetContextKey:
"""Tests für die Methode get_context_key."""
def test_returns_key_for_authorized_context_from_keyring(
self, manager: SecretEncryptionManager
) -> None:
"""Autorisierter Kontext mit vorhandenem Schlüssel liefert EncryptionKey."""
key = manager.get_context_key("privat")
assert key is not None
assert isinstance(key, EncryptionKey)
assert key.context == "privat"
assert key.source == "keyring"
def test_returns_key_for_dhive_context(
self, manager: SecretEncryptionManager
) -> None:
"""Dhive-Kontext liefert ebenfalls einen Schlüssel."""
key = manager.get_context_key("dhive")
assert key is not None
assert key.context == "dhive"
def test_returns_none_for_unauthorized_context(
self, limited_manager: SecretEncryptionManager
) -> None:
"""Nicht-autorisierter Kontext liefert None."""
key = limited_manager.get_context_key("privat")
assert key is None
def test_returns_none_for_nonexistent_key(
self, tmp_monorepo: Path
) -> None:
"""Kontext ohne vorhandenen Schlüssel liefert None."""
# Kein .git/git-crypt/keys/bahn Verzeichnis
ctx = MachineContext(
name="test",
description="",
authorized_contexts=["bahn"],
key_source="keyring",
)
mgr = SecretEncryptionManager(tmp_monorepo, ctx)
# bahn existiert nicht im keys-Verzeichnis, aber default existiert
key = mgr.get_context_key("bahn")
# Fallback auf default key
assert key is not None
assert "default" in key.key_id
def test_file_source_returns_key_when_file_exists(
self, tmp_monorepo: Path
) -> None:
"""Dateiquelle liefert Schlüssel wenn Datei vorhanden."""
# Schlüsseldatei anlegen
key_dir = Path.home() / ".monorepo" / "keys"
key_dir.mkdir(parents=True, exist_ok=True)
key_file = key_dir / "privat.key"
key_file.write_text("test-key-content")
try:
ctx = MachineContext(
name="file-test",
description="",
authorized_contexts=["privat"],
key_source="file",
)
mgr = SecretEncryptionManager(tmp_monorepo, ctx)
key = mgr.get_context_key("privat")
assert key is not None
assert key.source == "file"
assert key.context == "privat"
finally:
key_file.unlink(missing_ok=True)
def test_file_source_returns_none_when_no_file(
self, tmp_monorepo: Path
) -> None:
"""Dateiquelle liefert None wenn keine Datei vorhanden."""
# Stelle sicher, dass keine Datei existiert
key_file = Path.home() / ".monorepo" / "keys" / "nonexistent-ctx.key"
key_file.unlink(missing_ok=True)
ctx = MachineContext(
name="file-test",
description="",
authorized_contexts=["nonexistent-ctx"],
key_source="file",
)
mgr = SecretEncryptionManager(tmp_monorepo, ctx)
key = mgr.get_context_key("nonexistent-ctx")
assert key is None
# ---------------------------------------------------------------------------
# Tests: onboard_machine
# ---------------------------------------------------------------------------
class TestOnboardMachine:
"""Tests für die Methode onboard_machine."""
def test_successful_onboarding(self, manager: SecretEncryptionManager) -> None:
"""Erfolgreiches Onboarding installiert Schlüssel und aktualisiert Config."""
result = manager.onboard_machine("neuer-rechner", ["privat", "dhive"])
assert isinstance(result, OnboardingResult)
assert result.success is True
assert result.machine_name == "neuer-rechner"
assert "privat" in result.authorized_contexts
assert "dhive" in result.authorized_contexts
assert len(result.installed_keys) >= 1
assert len(result.errors) == 0
def test_onboarding_with_invalid_context(
self, manager: SecretEncryptionManager
) -> None:
"""Ungültiger Kontext erzeugt Fehler im OnboardingResult."""
result = manager.onboard_machine("test-rechner", ["invalid-context"])
assert result.success is False
assert "Ungültiger Kontext: 'invalid-context'" in result.errors
def test_onboarding_partial_success(
self, manager: SecretEncryptionManager
) -> None:
"""Mix aus gültigen und ungültigen Kontexten."""
result = manager.onboard_machine("mix-rechner", ["privat", "invalid"])
# privat sollte erfolgreich sein, invalid erzeugt Fehler
assert "Ungültiger Kontext: 'invalid'" in result.errors
assert "privat" in result.authorized_contexts or len(result.installed_keys) > 0
def test_onboarding_updates_config_file(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Onboarding aktualisiert die machine-context.yaml."""
manager.onboard_machine("config-test-rechner", ["privat"])
config_path = tmp_monorepo / "shared" / "config" / "machine-context.yaml"
assert config_path.exists()
with open(config_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
assert data["machine"]["name"] == "config-test-rechner"
assert "privat" in data["machine"]["authorized_contexts"]
def test_onboarding_empty_contexts(
self, manager: SecretEncryptionManager
) -> None:
"""Leere Kontextliste führt zu Misserfolg."""
result = manager.onboard_machine("empty-rechner", [])
assert result.success is False
# ---------------------------------------------------------------------------
# Tests: resolve_merge
# ---------------------------------------------------------------------------
class TestResolveMerge:
"""Tests für die Methode resolve_merge."""
def test_identical_versions_return_same(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Identische Versionen erzeugen keinen Konflikt."""
file_path = tmp_monorepo / "privat" / "test.env"
file_path.write_bytes(b"content")
result = manager.resolve_merge(file_path, b"same", b"same")
assert result == b"same"
def test_theirs_wins_for_authorized_context(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Bei autorisiertem Kontext gewinnt theirs (Team-Repo-Vorrang)."""
file_path = tmp_monorepo / "privat" / "secret.key"
file_path.write_bytes(b"dummy")
result = manager.resolve_merge(file_path, b"ours-version", b"theirs-version")
assert result == b"theirs-version"
def test_ours_wins_for_unauthorized_context(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Bei nicht-autorisiertem Kontext wird ours beibehalten."""
file_path = tmp_monorepo / "privat" / "secret.key"
file_path.write_bytes(b"dummy")
result = limited_manager.resolve_merge(
file_path, b"ours-version", b"theirs-version"
)
assert result == b"ours-version"
def test_ours_wins_for_unknown_context(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Datei außerhalb der Kontextordner: ours beibehalten."""
file_path = tmp_monorepo / "unknown-dir" / "file.txt"
result = manager.resolve_merge(file_path, b"ours", b"theirs")
assert result == b"ours"
def test_binary_content_handled(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Binärdaten werden korrekt verarbeitet."""
file_path = tmp_monorepo / "dhive" / "cert.pem"
file_path.write_bytes(b"\x00\x01\x02")
ours = b"\x00\x01\x02\x03"
theirs = b"\x00\x01\x02\x04\x05"
result = manager.resolve_merge(file_path, ours, theirs)
assert result == theirs # dhive ist autorisiert
# ---------------------------------------------------------------------------
# Tests: MachineContextManager
# ---------------------------------------------------------------------------
class TestMachineContextManager:
"""Tests für den MachineContextManager."""
def test_load_valid_config(self, tmp_monorepo: Path) -> None:
"""Lädt gültige machine-context.yaml korrekt."""
mcm = MachineContextManager(tmp_monorepo)
ctx = mcm.load()
assert ctx.name == "test-rechner"
assert ctx.description == "Testmaschine"
assert "privat" in ctx.authorized_contexts
assert "dhive" in ctx.authorized_contexts
assert "bahn" in ctx.authorized_contexts
assert ctx.key_source == "keyring"
assert ctx.password_manager is not None
assert ctx.password_manager.type == "bitwarden"
assert ctx.password_manager.vault == "monorepo-keys"
def test_load_missing_config_raises(self, tmp_path: Path) -> None:
"""Fehlende Konfigurationsdatei wirft FileNotFoundError."""
mcm = MachineContextManager(tmp_path)
with pytest.raises(FileNotFoundError):
mcm.load()
def test_load_invalid_config_raises(self, tmp_path: Path) -> None:
"""Ungültiges YAML-Format wirft ValueError."""
config_dir = tmp_path / "shared" / "config"
config_dir.mkdir(parents=True)
(config_dir / "machine-context.yaml").write_text("invalid: true")
mcm = MachineContextManager(tmp_path)
with pytest.raises(ValueError, match="machine"):
mcm.load()
def test_machine_context_property_before_load_raises(
self, tmp_monorepo: Path
) -> None:
"""Zugriff auf machine_context vor load() wirft RuntimeError."""
mcm = MachineContextManager(tmp_monorepo)
with pytest.raises(RuntimeError, match="load"):
_ = mcm.machine_context
def test_machine_context_property_after_load(
self, tmp_monorepo: Path
) -> None:
"""Nach load() ist machine_context verfügbar."""
mcm = MachineContextManager(tmp_monorepo)
mcm.load()
ctx = mcm.machine_context
assert ctx.name == "test-rechner"
def test_create_encryption_manager(self, tmp_monorepo: Path) -> None:
"""create_encryption_manager erstellt funktionsfähigen Manager."""
mcm = MachineContextManager(tmp_monorepo)
mgr = mcm.create_encryption_manager()
assert isinstance(mgr, SecretEncryptionManager)
assert mgr.machine_context.name == "test-rechner"
def test_custom_config_path(self, tmp_path: Path) -> None:
"""Benutzerdefinierter config_path wird korrekt verwendet."""
custom_config = tmp_path / "custom-config.yaml"
custom_config.write_text(
yaml.dump({
"machine": {
"name": "custom-machine",
"description": "Custom",
"authorized_contexts": ["privat"],
"key_source": "file",
}
}),
encoding="utf-8",
)
mcm = MachineContextManager(tmp_path, config_path=custom_config)
ctx = mcm.load()
assert ctx.name == "custom-machine"
assert ctx.key_source == "file"
def test_config_without_password_manager(self, tmp_path: Path) -> None:
"""Konfiguration ohne password_manager-Feld funktioniert."""
config_dir = tmp_path / "shared" / "config"
config_dir.mkdir(parents=True)
(config_dir / "machine-context.yaml").write_text(
yaml.dump({
"machine": {
"name": "no-pm-machine",
"description": "Ohne PM",
"authorized_contexts": ["bahn"],
"key_source": "keyring",
}
}),
encoding="utf-8",
)
mcm = MachineContextManager(tmp_path)
ctx = mcm.load()
assert ctx.password_manager is None
assert ctx.key_source == "keyring"
# ---------------------------------------------------------------------------
# Tests: OnboardingResult Dataclass
# ---------------------------------------------------------------------------
class TestOnboardingResultDataclass:
"""Tests für die OnboardingResult Dataclass."""
def test_default_values(self) -> None:
"""Standard-Initialwerte sind korrekt."""
result = OnboardingResult(success=True, machine_name="test")
assert result.success is True
assert result.machine_name == "test"
assert result.authorized_contexts == []
assert result.installed_keys == []
assert result.errors == []
def test_full_initialization(self) -> None:
"""Vollständige Initialisierung funktioniert."""
result = OnboardingResult(
success=True,
machine_name="full-test",
authorized_contexts=["privat", "dhive"],
installed_keys=["key-1", "key-2"],
errors=[],
)
assert result.machine_name == "full-test"
assert len(result.authorized_contexts) == 2
assert len(result.installed_keys) == 2