Initial monorepo structure
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user