"""Unit-Tests für ContextGuard und AuditLogger-Integration. Testet die Zugriffskontrolle zwischen Arbeitskontexten: - Zugriff innerhalb des eigenen Kontexts (erlaubt) - Zugriff auf fremden Kontext (verweigert) - Zugriff auf shared-Bereich (erlaubt gemäß Konfiguration) - Audit-Log-Format und Vollständigkeit - Edge Cases: shared-Kontext, Tool-Ausführung im shared-Kontext Requirements: 2.1, 2.3, 2.5, 2.6, 2.7 """ from __future__ import annotations from datetime import datetime from pathlib import Path import pytest import yaml from monorepo.audit import AuditLogger from monorepo.models import SecurityEvent from monorepo.security import ContextGuard # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def monorepo_root(tmp_path: Path) -> Path: """Erstellt eine Monorepo-Struktur mit access-config.yaml.""" root = tmp_path / "monorepo" root.mkdir() # Kontextordner erstellen (root / "privat").mkdir() (root / "dhive").mkdir() (root / "bahn").mkdir() (root / "shared" / "config").mkdir(parents=True) (root / "shared" / "tools").mkdir(parents=True) (root / "shared" / "powers").mkdir(parents=True) (root / "shared" / "knowledge-store").mkdir(parents=True) (root / "shared" / "mcp-servers").mkdir(parents=True) # access-config.yaml erstellen config = { "contexts": { "privat": { "env_file": "privat/.env", "allowed_shared": [ "shared/tools/", "shared/powers/", "shared/config/", ], }, "dhive": { "env_file": "dhive/.env", "allowed_shared": [ "shared/tools/", "shared/powers/", "shared/config/", ], }, "bahn": { "env_file": "bahn/.env", "allowed_shared": [ "shared/tools/", "shared/powers/", "shared/config/", "shared/knowledge-store/", ], }, "shared": { "env_file": "shared/.env", "allowed_shared": ["*"], }, } } config_path = root / "shared" / "config" / "access-config.yaml" with open(config_path, "w", encoding="utf-8") as f: yaml.dump(config, f) return root @pytest.fixture def guard(monorepo_root: Path) -> ContextGuard: """Erstellt einen ContextGuard mit der Test-Konfiguration.""" return ContextGuard(root_path=monorepo_root) @pytest.fixture def env_files(monorepo_root: Path) -> Path: """Erstellt .env-Dateien für alle Kontexte.""" for ctx in ("privat", "dhive", "bahn", "shared"): env_path = monorepo_root / ctx / ".env" env_path.write_text( f"# {ctx} secrets\n" f"{ctx.upper()}_API_KEY=secret-{ctx}-123\n" f"{ctx.upper()}_DB_URL=postgresql://localhost/{ctx}\n", encoding="utf-8", ) return monorepo_root # --------------------------------------------------------------------------- # Tests: Zugriff innerhalb des eigenen Kontexts (erlaubt) # --------------------------------------------------------------------------- class TestOwnContextAccess: """Teste Zugriff innerhalb des eigenen Kontexts (erlaubt). Requirement 2.1, 2.3: Jeder Kontext darf auf eigene Dateien zugreifen. """ def test_privat_accesses_own_env(self, guard: ContextGuard, monorepo_root: Path) -> None: """privat darf auf privat/.env zugreifen.""" target = monorepo_root / "privat" / ".env" assert guard.check_access("privat", target) is True def test_dhive_accesses_own_env(self, guard: ContextGuard, monorepo_root: Path) -> None: """dhive darf auf dhive/.env zugreifen.""" target = monorepo_root / "dhive" / ".env" assert guard.check_access("dhive", target) is True def test_bahn_accesses_own_env(self, guard: ContextGuard, monorepo_root: Path) -> None: """bahn darf auf bahn/.env zugreifen.""" target = monorepo_root / "bahn" / ".env" assert guard.check_access("bahn", target) is True def test_privat_accesses_own_project_file( self, guard: ContextGuard, monorepo_root: Path ) -> None: """privat darf auf beliebige Dateien im eigenen Kontext zugreifen.""" target = monorepo_root / "privat" / "project-a" / "src" / "main.py" assert guard.check_access("privat", target) is True def test_dhive_accesses_own_nested_file( self, guard: ContextGuard, monorepo_root: Path ) -> None: """dhive darf auf tief verschachtelte Dateien im eigenen Kontext zugreifen.""" target = monorepo_root / "dhive" / "my-project" / "module" / "secret.key" assert guard.check_access("dhive", target) is True def test_shared_accesses_own_files(self, guard: ContextGuard, monorepo_root: Path) -> None: """shared-Kontext darf auf eigene Dateien zugreifen.""" target = monorepo_root / "shared" / ".env" assert guard.check_access("shared", target) is True # --------------------------------------------------------------------------- # Tests: Zugriff auf fremden Kontext (verweigert) # --------------------------------------------------------------------------- class TestForeignContextAccessDenied: """Teste Zugriff auf fremden Kontext (verweigert). Requirement 2.1, 2.3: Secrets eines Arbeitskontexts sind für andere Kontexte nicht lesbar. """ def test_privat_cannot_access_dhive_env( self, guard: ContextGuard, monorepo_root: Path ) -> None: """privat darf NICHT auf dhive/.env zugreifen.""" target = monorepo_root / "dhive" / ".env" assert guard.check_access("privat", target) is False def test_privat_cannot_access_bahn_env( self, guard: ContextGuard, monorepo_root: Path ) -> None: """privat darf NICHT auf bahn/.env zugreifen.""" target = monorepo_root / "bahn" / ".env" assert guard.check_access("privat", target) is False def test_dhive_cannot_access_privat_env( self, guard: ContextGuard, monorepo_root: Path ) -> None: """dhive darf NICHT auf privat/.env zugreifen.""" target = monorepo_root / "privat" / ".env" assert guard.check_access("dhive", target) is False def test_dhive_cannot_access_bahn_secrets( self, guard: ContextGuard, monorepo_root: Path ) -> None: """dhive darf NICHT auf bahn/secret.key zugreifen.""" target = monorepo_root / "bahn" / "secret.key" assert guard.check_access("dhive", target) is False def test_bahn_cannot_access_privat_project( self, guard: ContextGuard, monorepo_root: Path ) -> None: """bahn darf NICHT auf Dateien im privat-Kontext zugreifen.""" target = monorepo_root / "privat" / "project-a" / "config.yaml" assert guard.check_access("bahn", target) is False def test_bahn_cannot_access_dhive_env( self, guard: ContextGuard, monorepo_root: Path ) -> None: """bahn darf NICHT auf dhive/.env zugreifen.""" target = monorepo_root / "dhive" / ".env" assert guard.check_access("bahn", target) is False # --------------------------------------------------------------------------- # Tests: Zugriff auf shared-Bereich (erlaubt gemäß Konfiguration) # --------------------------------------------------------------------------- class TestSharedAreaAccess: """Teste Zugriff auf shared-Bereich (erlaubt gemäß Konfiguration). Requirement 2.6, 2.7: Zugriff auf shared-Ressourcen nur auf explizit freigegebene Pfade. """ def test_privat_accesses_shared_tools( self, guard: ContextGuard, monorepo_root: Path ) -> None: """privat darf auf shared/tools/ zugreifen.""" target = monorepo_root / "shared" / "tools" / "monorepo-cli" / "src" / "main.py" assert guard.check_access("privat", target) is True def test_privat_accesses_shared_powers( self, guard: ContextGuard, monorepo_root: Path ) -> None: """privat darf auf shared/powers/ zugreifen.""" target = monorepo_root / "shared" / "powers" / "db-platform" / "power.md" assert guard.check_access("privat", target) is True def test_privat_accesses_shared_config( self, guard: ContextGuard, monorepo_root: Path ) -> None: """privat darf auf shared/config/ zugreifen.""" target = monorepo_root / "shared" / "config" / "access-config.yaml" assert guard.check_access("privat", target) is True def test_privat_cannot_access_shared_knowledge_store( self, guard: ContextGuard, monorepo_root: Path ) -> None: """privat darf NICHT auf shared/knowledge-store/ zugreifen (nicht in allowed_shared).""" target = monorepo_root / "shared" / "knowledge-store" / "index.yaml" assert guard.check_access("privat", target) is False def test_privat_cannot_access_shared_mcp_servers( self, guard: ContextGuard, monorepo_root: Path ) -> None: """privat darf NICHT auf shared/mcp-servers/ zugreifen (nicht in allowed_shared).""" target = monorepo_root / "shared" / "mcp-servers" / "config.json" assert guard.check_access("privat", target) is False def test_bahn_accesses_shared_knowledge_store( self, guard: ContextGuard, monorepo_root: Path ) -> None: """bahn darf auf shared/knowledge-store/ zugreifen (in bahn's allowed_shared).""" target = monorepo_root / "shared" / "knowledge-store" / "_index.yaml" assert guard.check_access("bahn", target) is True def test_dhive_cannot_access_shared_knowledge_store( self, guard: ContextGuard, monorepo_root: Path ) -> None: """dhive darf NICHT auf shared/knowledge-store/ zugreifen.""" target = monorepo_root / "shared" / "knowledge-store" / "data.md" assert guard.check_access("dhive", target) is False def test_dhive_accesses_shared_tools( self, guard: ContextGuard, monorepo_root: Path ) -> None: """dhive darf auf shared/tools/ zugreifen.""" target = monorepo_root / "shared" / "tools" / "script.sh" assert guard.check_access("dhive", target) is True # --------------------------------------------------------------------------- # Tests: Shared-Kontext darf auf alles zugreifen (Wildcard) # --------------------------------------------------------------------------- class TestSharedContextWildcard: """Edge Case: shared-Kontext mit allowed_shared: ['*'] darf auf alles in shared zugreifen. Requirement 2.7: Tools im shared-Bereich brauchen Zugriff auf shared-Ressourcen. """ def test_shared_accesses_shared_tools( self, guard: ContextGuard, monorepo_root: Path ) -> None: """shared darf auf shared/tools/ zugreifen.""" target = monorepo_root / "shared" / "tools" / "any-tool" / "main.py" assert guard.check_access("shared", target) is True def test_shared_accesses_shared_knowledge_store( self, guard: ContextGuard, monorepo_root: Path ) -> None: """shared darf auf shared/knowledge-store/ zugreifen.""" target = monorepo_root / "shared" / "knowledge-store" / "deep" / "doc.md" assert guard.check_access("shared", target) is True def test_shared_accesses_shared_mcp_servers( self, guard: ContextGuard, monorepo_root: Path ) -> None: """shared darf auf shared/mcp-servers/ zugreifen (Wildcard).""" target = monorepo_root / "shared" / "mcp-servers" / "server.json" assert guard.check_access("shared", target) is True def test_shared_cannot_access_privat( self, guard: ContextGuard, monorepo_root: Path ) -> None: """shared darf NICHT auf privat/ zugreifen (Wildcard gilt nur für shared/).""" target = monorepo_root / "privat" / ".env" assert guard.check_access("shared", target) is False def test_shared_cannot_access_dhive( self, guard: ContextGuard, monorepo_root: Path ) -> None: """shared darf NICHT auf dhive/ zugreifen.""" target = monorepo_root / "dhive" / "project" / "main.py" assert guard.check_access("shared", target) is False def test_shared_cannot_access_bahn( self, guard: ContextGuard, monorepo_root: Path ) -> None: """shared darf NICHT auf bahn/ zugreifen.""" target = monorepo_root / "bahn" / ".env" assert guard.check_access("shared", target) is False # --------------------------------------------------------------------------- # Tests: load_env Funktion # --------------------------------------------------------------------------- class TestLoadEnv: """Teste load_env-Methode: Lädt nur die eigene .env-Datei. Requirement 2.2, 2.3: Jeder Kontext hat eine eigene .env-Datei. """ def test_load_privat_env( self, guard: ContextGuard, env_files: Path, monorepo_root: Path ) -> None: """load_env lädt korrekt die privat/.env.""" env = guard.load_env("privat") assert env["PRIVAT_API_KEY"] == "secret-privat-123" assert env["PRIVAT_DB_URL"] == "postgresql://localhost/privat" def test_load_dhive_env( self, guard: ContextGuard, env_files: Path, monorepo_root: Path ) -> None: """load_env lädt korrekt die dhive/.env.""" env = guard.load_env("dhive") assert env["DHIVE_API_KEY"] == "secret-dhive-123" def test_load_unknown_context_raises(self, guard: ContextGuard) -> None: """load_env wirft ValueError bei unbekanntem Kontext.""" with pytest.raises(ValueError, match="Unbekannter Kontext"): guard.load_env("unknown") def test_load_env_missing_file_raises(self, guard: ContextGuard) -> None: """load_env wirft FileNotFoundError wenn .env-Datei nicht existiert.""" with pytest.raises(FileNotFoundError, match=".env-Datei"): guard.load_env("privat") # --------------------------------------------------------------------------- # Tests: Audit-Log-Format und Vollständigkeit # --------------------------------------------------------------------------- class TestAuditLogFormatAndCompleteness: """Teste Audit-Log-Format und Vollständigkeit. Requirement 2.5: Protokolleintrag muss enthalten: - Zeitstempel - anfragender Kontext - Zielkontext - angefragte Ressource """ def test_audit_log_contains_all_required_fields(self, tmp_path: Path) -> None: """Ein Audit-Eintrag enthält alle Pflichtfelder: Zeitstempel, Kontexte, Ressource.""" log_path = tmp_path / "audit.log" logger = AuditLogger(log_path=log_path) event = SecurityEvent( timestamp=datetime(2025, 7, 1, 9, 15, 30), requesting_context="dhive", target_context="privat", resource="privat/.env", action="read", outcome="denied", ) logger.log_violation(event) content = log_path.read_text(encoding="utf-8") # Zeitstempel vorhanden (ISO-Format) assert "2025-07-01T09:15:30" in content # Anfragender Kontext vorhanden assert "dhive" in content # Zielkontext vorhanden assert "privat" in content # Ressource vorhanden assert "privat/.env" in content # Aktion vorhanden assert "read" in content # Outcome vorhanden assert "DENIED" in content def test_audit_log_format_structure(self, tmp_path: Path) -> None: """Audit-Log-Einträge folgen dem definierten Format-Schema.""" log_path = tmp_path / "audit.log" logger = AuditLogger(log_path=log_path) event = SecurityEvent( timestamp=datetime(2025, 3, 20, 12, 0, 0), requesting_context="bahn", target_context="dhive", resource="dhive/.env", action="write", outcome="denied", ) logger.log_violation(event) line = log_path.read_text(encoding="utf-8").strip() # Format: [ISO-TIMESTAMP] OUTCOME | requesting -> target | action on resource assert line == "[2025-03-20T12:00:00] DENIED | bahn -> dhive | write on dhive/.env" def test_audit_log_allowed_event(self, tmp_path: Path) -> None: """Auch erlaubte Zugriffe können protokolliert werden.""" log_path = tmp_path / "audit.log" logger = AuditLogger(log_path=log_path) event = SecurityEvent( timestamp=datetime(2025, 5, 10, 8, 30, 0), requesting_context="privat", target_context="shared", resource="shared/tools/script.sh", action="execute", outcome="allowed", ) logger.log_violation(event) content = log_path.read_text(encoding="utf-8") assert "ALLOWED" in content assert "privat -> shared" in content assert "execute on shared/tools/script.sh" in content def test_audit_log_completeness_multiple_events(self, tmp_path: Path) -> None: """Mehrere Events werden vollständig und geordnet protokolliert.""" log_path = tmp_path / "audit.log" logger = AuditLogger(log_path=log_path) events = [ SecurityEvent( timestamp=datetime(2025, 1, 1, i, 0, 0), requesting_context=f"ctx{i}", target_context="privat", resource=f"privat/file{i}.txt", action="read", outcome="denied", ) for i in range(3) ] for event in events: logger.log_violation(event) lines = log_path.read_text(encoding="utf-8").splitlines() assert len(lines) == 3 # Jede Zeile enthält die korrekten Daten for i, line in enumerate(lines): assert f"ctx{i}" in line assert f"file{i}.txt" in line assert "DENIED" in line # --------------------------------------------------------------------------- # Tests: ContextGuard Initialisierung und Konfiguration # --------------------------------------------------------------------------- class TestContextGuardInit: """Teste ContextGuard-Initialisierung und Fehlerbehandlung. Requirement 2.6: Zentrale Konfigurationsdatei. """ def test_init_with_valid_config(self, monorepo_root: Path) -> None: """ContextGuard wird mit gültiger Konfiguration korrekt initialisiert.""" guard = ContextGuard(root_path=monorepo_root) assert guard.root_path == monorepo_root.resolve() def test_init_missing_config_raises(self, tmp_path: Path) -> None: """ContextGuard wirft FileNotFoundError bei fehlender Konfiguration.""" with pytest.raises(FileNotFoundError, match="Access-Konfiguration nicht gefunden"): ContextGuard(root_path=tmp_path) def test_init_invalid_yaml_raises(self, tmp_path: Path) -> None: """ContextGuard wirft ValueError bei ungültigem YAML-Format.""" config_dir = tmp_path / "shared" / "config" config_dir.mkdir(parents=True) config_path = config_dir / "access-config.yaml" config_path.write_text("invalid: data\n", encoding="utf-8") with pytest.raises(ValueError, match="Ungültiges access-config.yaml-Format"): ContextGuard(root_path=tmp_path) def test_get_context_config(self, guard: ContextGuard) -> None: """get_context_config gibt die korrekte Konfiguration zurück.""" config = guard.get_context_config("bahn") assert config["env_file"] == "bahn/.env" assert "shared/knowledge-store/" in config["allowed_shared"] def test_get_context_config_unknown_raises(self, guard: ContextGuard) -> None: """get_context_config wirft ValueError bei unbekanntem Kontext.""" with pytest.raises(ValueError, match="Unbekannter Kontext"): guard.get_context_config("nonexistent") # --------------------------------------------------------------------------- # Tests: Edge Cases – relative Pfade # --------------------------------------------------------------------------- class TestRelativePaths: """Teste check_access mit relativen Pfaden.""" def test_relative_path_own_context(self, guard: ContextGuard) -> None: """Relativer Pfad innerhalb eigenen Kontexts wird erkannt.""" target = Path("privat/.env") assert guard.check_access("privat", target) is True def test_relative_path_foreign_context(self, guard: ContextGuard) -> None: """Relativer Pfad zu fremdem Kontext wird verweigert.""" target = Path("dhive/.env") assert guard.check_access("privat", target) is False def test_relative_path_shared(self, guard: ContextGuard) -> None: """Relativer Pfad zu erlaubtem shared-Bereich wird akzeptiert.""" target = Path("shared/tools/monorepo-cli/main.py") assert guard.check_access("privat", target) is True