Files
Orchestrator/shared/tools/monorepo-cli/tests/test_encryption_unit.py
T
2026-06-30 20:37:40 +02:00

627 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit-Tests für SecretEncryptionManager (Task 4.6).
Testet die folgenden Aspekte, die NICHT bereits in test_encryption.py abgedeckt sind:
1. encrypt_file() Erfolg, Autorisierung, fehlende Datei
2. decrypt_file() Erfolg, Autorisierung, fehlende Datei, Kontextauflösung
3. is_authorized() autorisierte und nicht-autorisierte Kontexte
4. Fehlermeldungen enthalten KEINEN Dateiinhalt (keine Offenlegung)
5. setup_gitcrypt_filters() .gitattributes-Generierung pro Kontext
6. Edge Cases: shared-Kontext, Dateien außerhalb von Kontextordnern
_Requirements: 9.1, 9.3, 9.4, 9.8, 9.10, 9.11_
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from monorepo.encryption import (
DecryptionResult,
EncryptionResult,
GitCryptNotAvailableError,
SecretEncryptionManager,
)
from monorepo.models import MachineContext
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_monorepo(tmp_path: Path) -> Path:
"""Erstellt eine minimale Monorepo-Struktur."""
for ctx in ("privat", "dhive", "bahn", "shared"):
(tmp_path / ctx).mkdir()
return tmp_path
@pytest.fixture
def full_context() -> MachineContext:
"""MachineContext mit Zugriff auf alle Kontexte."""
return MachineContext(
name="andre-hauptrechner",
description="Voller Zugriff",
authorized_contexts=["privat", "dhive", "bahn"],
key_source="keyring",
)
@pytest.fixture
def dhive_only_context() -> MachineContext:
"""MachineContext nur für dhive autorisiert."""
return MachineContext(
name="dhive-laptop",
description="Nur dhive",
authorized_contexts=["dhive"],
key_source="keyring",
)
@pytest.fixture
def no_context() -> MachineContext:
"""MachineContext ohne jede Autorisierung."""
return MachineContext(
name="fremder-rechner",
description="Kein Zugriff",
authorized_contexts=[],
key_source="keyring",
)
@pytest.fixture
def manager(tmp_monorepo: Path, full_context: MachineContext) -> SecretEncryptionManager:
"""SecretEncryptionManager mit vollem Zugriff."""
return SecretEncryptionManager(tmp_monorepo, full_context)
@pytest.fixture
def limited_manager(
tmp_monorepo: Path, dhive_only_context: MachineContext
) -> SecretEncryptionManager:
"""SecretEncryptionManager nur für dhive."""
return SecretEncryptionManager(tmp_monorepo, dhive_only_context)
@pytest.fixture
def unauthorized_manager(
tmp_monorepo: Path, no_context: MachineContext
) -> SecretEncryptionManager:
"""SecretEncryptionManager ohne Autorisierung."""
return SecretEncryptionManager(tmp_monorepo, no_context)
# ---------------------------------------------------------------------------
# Tests: encrypt_file()
# ---------------------------------------------------------------------------
class TestEncryptFile:
"""Tests für SecretEncryptionManager.encrypt_file()."""
def test_encrypt_success_with_authorized_context(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Verschlüsselung gelingt bei autorisiertem Kontext und existierender Datei."""
secret_file = tmp_monorepo / "privat" / ".env"
secret_file.write_text("SECRET_KEY=super-geheim-123")
# Mock git-crypt Aufruf, da git-crypt nicht installiert ist
with patch.object(manager, "_run_gitcrypt") as mock_gc:
mock_gc.return_value = None
result = manager.encrypt_file(secret_file, "privat")
assert isinstance(result, EncryptionResult)
assert result.success is True
assert result.file_path == secret_file
assert result.context == "privat"
assert result.error is None
def test_encrypt_fails_with_unauthorized_context(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Verschlüsselung schlägt fehl bei nicht-autorisiertem Kontext."""
secret_file = tmp_monorepo / "privat" / ".env"
secret_file.write_text("SECRET=value")
result = limited_manager.encrypt_file(secret_file, "privat")
assert result.success is False
assert "nicht" in result.error.lower() or "autorisiert" in result.error.lower()
def test_encrypt_fails_when_file_does_not_exist(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Verschlüsselung schlägt fehl wenn die Datei nicht existiert."""
nonexistent = tmp_monorepo / "privat" / "nonexistent.env"
result = manager.encrypt_file(nonexistent, "privat")
assert result.success is False
assert "existiert nicht" in result.error
def test_encrypt_fails_when_gitcrypt_not_available(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Verschlüsselung schlägt fehl wenn git-crypt nicht installiert ist."""
secret_file = tmp_monorepo / "dhive" / "credentials.pem"
secret_file.write_text("-----BEGIN PRIVATE KEY-----\n...")
with patch.object(manager, "_run_gitcrypt") as mock_gc:
mock_gc.side_effect = GitCryptNotAvailableError(
"git-crypt ist nicht installiert"
)
result = manager.encrypt_file(secret_file, "dhive")
assert result.success is False
assert "git-crypt" in result.error.lower()
def test_encrypt_for_bahn_context(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Verschlüsselung funktioniert auch für Bahn-Kontext."""
bahn_secret = tmp_monorepo / "bahn" / "api-token.key"
bahn_secret.write_text("token-abc-123")
with patch.object(manager, "_run_gitcrypt"):
result = manager.encrypt_file(bahn_secret, "bahn")
assert result.success is True
assert result.context == "bahn"
# ---------------------------------------------------------------------------
# Tests: decrypt_file()
# ---------------------------------------------------------------------------
class TestDecryptFile:
"""Tests für SecretEncryptionManager.decrypt_file()."""
def test_decrypt_success_with_authorized_context(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Entschlüsselung gelingt bei autorisiertem Kontext."""
secret_file = tmp_monorepo / "privat" / ".env"
secret_content = b"DB_PASSWORD=geheim123"
secret_file.write_bytes(secret_content)
with patch.object(manager, "_run_gitcrypt"):
result = manager.decrypt_file(secret_file)
assert isinstance(result, DecryptionResult)
assert result.success is True
assert result.content == secret_content
def test_decrypt_fails_with_unauthorized_context(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Entschlüsselung schlägt fehl bei nicht-autorisiertem Kontext."""
privat_secret = tmp_monorepo / "privat" / ".env"
privat_secret.write_text("PRIVATE_SECRET=xyz")
result = limited_manager.decrypt_file(privat_secret)
assert result.success is False
assert result.content is None
assert "verweigert" in result.error.lower() or "autorisiert" in result.error.lower()
def test_decrypt_succeeds_for_own_context(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Eingeschränkter Manager kann eigenen Kontext entschlüsseln."""
dhive_secret = tmp_monorepo / "dhive" / ".env"
dhive_content = b"DHIVE_API_KEY=abc"
dhive_secret.write_bytes(dhive_content)
with patch.object(limited_manager, "_run_gitcrypt"):
result = limited_manager.decrypt_file(dhive_secret)
assert result.success is True
assert result.content == dhive_content
def test_decrypt_fails_when_file_does_not_exist(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Entschlüsselung schlägt fehl wenn die Datei nicht existiert."""
nonexistent = tmp_monorepo / "dhive" / "missing.key"
result = manager.decrypt_file(nonexistent)
assert result.success is False
assert "existiert nicht" in result.error
def test_decrypt_resolves_context_from_path(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Entschlüsselung ermittelt den Kontext automatisch aus dem Dateipfad."""
bahn_secret = tmp_monorepo / "bahn" / "project-x" / "secret.key"
bahn_secret.parent.mkdir(parents=True, exist_ok=True)
bahn_secret.write_bytes(b"key-data")
with patch.object(manager, "_run_gitcrypt"):
result = manager.decrypt_file(bahn_secret)
assert result.success is True
assert result.content == b"key-data"
def test_decrypt_fails_for_file_outside_context_dirs(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Entschlüsselung schlägt fehl für Dateien außerhalb der Kontextordner."""
outside_file = tmp_monorepo / "unknown-dir" / "secrets.env"
outside_file.parent.mkdir(parents=True, exist_ok=True)
outside_file.write_text("SHOULD_NOT_DECRYPT=true")
result = manager.decrypt_file(outside_file)
assert result.success is False
assert "kontext" in result.error.lower()
def test_decrypt_fails_for_root_level_file(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Entschlüsselung schlägt fehl für Datei direkt im Root (kein Kontext)."""
root_file = tmp_monorepo / ".env"
root_file.write_text("ROOT_SECRET=hidden")
result = manager.decrypt_file(root_file)
# .env im Root hat keinen gültigen Kontext als erstes Pfadsegment
assert result.success is False
# ---------------------------------------------------------------------------
# Tests: is_authorized()
# ---------------------------------------------------------------------------
class TestIsAuthorized:
"""Tests für SecretEncryptionManager.is_authorized()."""
def test_authorized_for_all_contexts_full_access(
self, manager: SecretEncryptionManager
) -> None:
"""Full-Access-Manager ist für alle drei Kontexte autorisiert."""
assert manager.is_authorized("privat") is True
assert manager.is_authorized("dhive") is True
assert manager.is_authorized("bahn") is True
def test_authorized_only_for_own_context(
self, limited_manager: SecretEncryptionManager
) -> None:
"""Eingeschränkter Manager ist nur für seinen Kontext autorisiert."""
assert limited_manager.is_authorized("dhive") is True
assert limited_manager.is_authorized("privat") is False
assert limited_manager.is_authorized("bahn") is False
def test_not_authorized_for_any_context(
self, unauthorized_manager: SecretEncryptionManager
) -> None:
"""Manager ohne Autorisierung ist für keinen Kontext autorisiert."""
assert unauthorized_manager.is_authorized("privat") is False
assert unauthorized_manager.is_authorized("dhive") is False
assert unauthorized_manager.is_authorized("bahn") is False
def test_not_authorized_for_nonexistent_context(
self, manager: SecretEncryptionManager
) -> None:
"""Nicht existierender Kontext ist nie autorisiert."""
assert manager.is_authorized("nonexistent") is False
assert manager.is_authorized("") is False
def test_shared_context_not_in_authorized_list(
self, manager: SecretEncryptionManager
) -> None:
"""'shared' ist per Design nicht in der Autorisierungsliste."""
# shared wird über andere Mechanismen gesteuert
assert manager.is_authorized("shared") is False
# ---------------------------------------------------------------------------
# Tests: Fehlermeldungen offenlegen KEINEN Dateiinhalt (Requirement 9.8)
# ---------------------------------------------------------------------------
class TestErrorMessagesSecurity:
"""Fehlermeldungen dürfen KEINEN Dateiinhalt offenlegen."""
def test_encrypt_error_does_not_reveal_content(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Verschlüsselungs-Fehlermeldung enthält keinen Dateiinhalt."""
secret_file = tmp_monorepo / "privat" / ".env"
secret_content = "SUPER_SECRET_API_KEY=sk-12345abcde"
secret_file.write_text(secret_content)
result = limited_manager.encrypt_file(secret_file, "privat")
assert result.success is False
# Fehlermeldung darf KEINEN Teil des Dateiinhalts enthalten
assert "SUPER_SECRET_API_KEY" not in (result.error or "")
assert "sk-12345abcde" not in (result.error or "")
assert secret_content not in (result.error or "")
def test_decrypt_error_does_not_reveal_content(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Entschlüsselungs-Fehlermeldung enthält keinen Dateiinhalt."""
secret_file = tmp_monorepo / "privat" / "credentials.key"
secret_content = "-----BEGIN RSA PRIVATE KEY-----\nMIIE...very-secret"
secret_file.write_text(secret_content)
result = limited_manager.decrypt_file(secret_file)
assert result.success is False
assert result.content is None
# Fehlermeldung darf KEINEN Teil des Dateiinhalts enthalten
assert "BEGIN RSA PRIVATE KEY" not in (result.error or "")
assert "MIIE" not in (result.error or "")
assert "very-secret" not in (result.error or "")
def test_decrypt_error_for_unauthorized_binary_file(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Fehlermeldung für verschlüsselte Binärdatei enthält keine Bytes."""
binary_file = tmp_monorepo / "bahn" / "cert.pem"
binary_content = b"\x00\x01\x02\x03PRIVATE_DATA\xff\xfe"
binary_file.write_bytes(binary_content)
result = limited_manager.decrypt_file(binary_file)
assert result.success is False
assert result.content is None
# Keine binären Daten oder Schlüsselwörter in der Fehlermeldung
assert "PRIVATE_DATA" not in (result.error or "")
def test_encrypt_error_has_useful_context_info(
self, tmp_monorepo: Path, limited_manager: SecretEncryptionManager
) -> None:
"""Fehlermeldung enthält nützliche Kontextinformationen (ohne Secrets)."""
secret_file = tmp_monorepo / "privat" / "token.secret"
secret_file.write_text("geheim-token-value")
result = limited_manager.encrypt_file(secret_file, "privat")
assert result.success is False
# Fehlermeldung sollte den Maschinennamen oder Kontext benennen
assert "dhive-laptop" in result.error or "privat" in result.error
# ---------------------------------------------------------------------------
# Tests: setup_gitcrypt_filters() .gitattributes-Generierung
# ---------------------------------------------------------------------------
class TestSetupGitcryptFilters:
"""Tests für SecretEncryptionManager.setup_gitcrypt_filters()."""
def test_generates_gitattributes_for_privat(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Erzeugt .gitattributes im privat-Kontext mit korrekten Filtern."""
manager.setup_gitcrypt_filters("privat")
gitattr_path = tmp_monorepo / "privat" / ".gitattributes"
assert gitattr_path.exists()
content = gitattr_path.read_text(encoding="utf-8")
assert "filter=git-crypt-privat" in content
assert "diff=git-crypt-privat" in content
def test_generates_gitattributes_for_dhive(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Erzeugt .gitattributes im dhive-Kontext."""
manager.setup_gitcrypt_filters("dhive")
gitattr_path = tmp_monorepo / "dhive" / ".gitattributes"
assert gitattr_path.exists()
content = gitattr_path.read_text(encoding="utf-8")
assert "filter=git-crypt-dhive" in content
assert "diff=git-crypt-dhive" in content
def test_generates_gitattributes_for_bahn(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Erzeugt .gitattributes im bahn-Kontext."""
manager.setup_gitcrypt_filters("bahn")
gitattr_path = tmp_monorepo / "bahn" / ".gitattributes"
assert gitattr_path.exists()
content = gitattr_path.read_text(encoding="utf-8")
assert "filter=git-crypt-bahn" in content
assert "diff=git-crypt-bahn" in content
def test_contains_all_secret_patterns(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Die generierten .gitattributes enthalten alle Secret-Patterns."""
manager.setup_gitcrypt_filters("privat")
content = (tmp_monorepo / "privat" / ".gitattributes").read_text(
encoding="utf-8"
)
# Alle SECRET_PATTERNS müssen als gitattributes-Patterns vorhanden sein
assert ".env" in content
assert "*.pem" in content
assert "*.key" in content
assert "*token*" in content
assert "*secret*" in content
def test_filter_names_are_context_specific(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Jeder Kontext hat seinen eigenen Filter-Namen."""
manager.setup_gitcrypt_filters("privat")
manager.setup_gitcrypt_filters("dhive")
manager.setup_gitcrypt_filters("bahn")
privat_content = (tmp_monorepo / "privat" / ".gitattributes").read_text()
dhive_content = (tmp_monorepo / "dhive" / ".gitattributes").read_text()
bahn_content = (tmp_monorepo / "bahn" / ".gitattributes").read_text()
# Jeder Kontext referenziert nur seinen eigenen Filter
assert "git-crypt-privat" in privat_content
assert "git-crypt-dhive" not in privat_content
assert "git-crypt-dhive" in dhive_content
assert "git-crypt-privat" not in dhive_content
assert "git-crypt-bahn" in bahn_content
assert "git-crypt-privat" not in bahn_content
def test_creates_context_directory_if_missing(
self, tmp_path: Path, full_context: MachineContext
) -> None:
"""Erstellt den Kontextordner falls er nicht existiert."""
mgr = SecretEncryptionManager(tmp_path, full_context)
mgr.setup_gitcrypt_filters("privat")
assert (tmp_path / "privat" / ".gitattributes").exists()
def test_overwrites_existing_gitattributes(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Überschreibt bestehende .gitattributes-Datei."""
gitattr_path = tmp_monorepo / "privat" / ".gitattributes"
gitattr_path.write_text("# alte Konfiguration\n*.old filter=old")
manager.setup_gitcrypt_filters("privat")
content = gitattr_path.read_text(encoding="utf-8")
assert "alte Konfiguration" not in content
assert "filter=git-crypt-privat" in content
def test_gitattributes_has_header_comment(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Die .gitattributes-Datei enthält einen informativen Header."""
manager.setup_gitcrypt_filters("bahn")
content = (tmp_monorepo / "bahn" / ".gitattributes").read_text(encoding="utf-8")
assert "# git-crypt Filter" in content
assert "bahn" in content
# ---------------------------------------------------------------------------
# Tests: Edge Cases shared-Kontext und Dateien außerhalb
# ---------------------------------------------------------------------------
class TestEdgeCases:
"""Edge Cases für die Verschlüsselungs-Operationen."""
def test_shared_context_file_decrypt_unauthorized(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Datei im shared-Kontext: 'shared' ist nicht in authorized_contexts."""
shared_file = tmp_monorepo / "shared" / "config" / "secrets.env"
shared_file.parent.mkdir(parents=True, exist_ok=True)
shared_file.write_text("SHARED_SECRET=123")
# 'shared' ist nicht in der authorized_contexts-Liste des full_context
result = manager.decrypt_file(shared_file)
# Da 'shared' nicht in authorized_contexts ist, schlägt es fehl
assert result.success is False
def test_deeply_nested_file_resolves_context(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Tief verschachtelte Datei wird korrekt dem Kontext zugeordnet."""
deep_file = tmp_monorepo / "dhive" / "proj" / "module" / "sub" / ".env"
deep_file.parent.mkdir(parents=True, exist_ok=True)
deep_file.write_bytes(b"DEEP_SECRET=value")
with patch.object(manager, "_run_gitcrypt"):
result = manager.decrypt_file(deep_file)
assert result.success is True
assert result.content == b"DEEP_SECRET=value"
def test_encrypt_file_with_empty_content(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Leere Datei kann verschlüsselt werden."""
empty_file = tmp_monorepo / "privat" / "empty.key"
empty_file.write_text("")
with patch.object(manager, "_run_gitcrypt"):
result = manager.encrypt_file(empty_file, "privat")
assert result.success is True
def test_encrypt_file_path_outside_monorepo(
self, tmp_path: Path, full_context: MachineContext
) -> None:
"""Datei komplett außerhalb des Monorepo-Roots."""
# Erstelle einen separaten Pfad als "Monorepo-Root"
mono_root = tmp_path / "monorepo"
mono_root.mkdir()
(mono_root / "privat").mkdir()
mgr = SecretEncryptionManager(mono_root, full_context)
# Datei außerhalb
outside_file = tmp_path / "outside" / "secret.env"
outside_file.parent.mkdir(parents=True, exist_ok=True)
outside_file.write_text("OUTSIDE=true")
# encrypt_file prüft nur Existenz und Autorisierung, nicht den Pfad
# aber es sollte nicht abstürzen
with patch.object(mgr, "_run_gitcrypt"):
result = mgr.encrypt_file(outside_file, "privat")
assert result.success is True # encrypt prüft Pfad nicht, nur Kontext-Param
def test_decrypt_file_path_outside_monorepo(
self, tmp_path: Path, full_context: MachineContext
) -> None:
"""Datei außerhalb des Monorepo-Roots kann nicht entschlüsselt werden."""
mono_root = tmp_path / "monorepo"
mono_root.mkdir()
mgr = SecretEncryptionManager(mono_root, full_context)
outside_file = tmp_path / "somewhere-else" / "secret.env"
outside_file.parent.mkdir(parents=True, exist_ok=True)
outside_file.write_text("OUTSIDE=true")
result = mgr.decrypt_file(outside_file)
# Kontext kann nicht aus dem Pfad ermittelt werden
assert result.success is False
assert "kontext" in result.error.lower()
def test_resolve_merge_for_shared_context_file(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""Merge-Auflösung für Datei im shared-Ordner: ours gewinnt (shared nicht autorisiert)."""
shared_file = tmp_monorepo / "shared" / "config.yaml"
shared_file.write_text("config: value")
result = manager.resolve_merge(shared_file, b"ours", b"theirs")
# shared ist nicht in authorized_contexts → ours beibehalten
assert result == b"ours"
def test_setup_gitcrypt_filters_for_shared_context(
self, tmp_monorepo: Path, manager: SecretEncryptionManager
) -> None:
"""setup_gitcrypt_filters funktioniert auch für den shared-Kontext."""
manager.setup_gitcrypt_filters("shared")
gitattr_path = tmp_monorepo / "shared" / ".gitattributes"
assert gitattr_path.exists()
content = gitattr_path.read_text(encoding="utf-8")
assert "filter=git-crypt-shared" in content