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,381 @@
"""Unit-Tests für das Wissensartefakt-Modell (knowledge/artifact.py).
Testet:
- YAML-Frontmatter-Parsing und -Generierung
- Content-Hash-Berechnung (SHA-256)
- Scope-basierte Pfadauflösung
- Artefakt-Schreibvorgang mit Ordnerstruktur
"""
from __future__ import annotations
from datetime import date
from pathlib import Path
import pytest
from monorepo.knowledge.artifact import (
Artifact,
ArtifactLink,
ArtifactMetadata,
compute_content_hash,
parse_frontmatter,
resolve_artifact_path,
write_artifact,
)
# ---------------------------------------------------------------------------
# Tests: compute_content_hash
# ---------------------------------------------------------------------------
class TestComputeContentHash:
"""Tests für die SHA-256 Content-Hash-Berechnung."""
def test_hash_format(self) -> None:
"""Hash hat das Format 'sha256:<hex>'."""
result = compute_content_hash("Hello World")
assert result.startswith("sha256:")
hex_part = result.removeprefix("sha256:")
assert len(hex_part) == 64 # SHA-256 = 64 hex chars
def test_deterministic(self) -> None:
"""Gleicher Input ergibt gleichen Hash."""
content = "Testinhalt mit Umlauten: äöü"
assert compute_content_hash(content) == compute_content_hash(content)
def test_different_content_different_hash(self) -> None:
"""Verschiedener Input ergibt verschiedenen Hash."""
assert compute_content_hash("abc") != compute_content_hash("xyz")
def test_empty_string(self) -> None:
"""Leerer String ergibt gültigen Hash."""
result = compute_content_hash("")
assert result.startswith("sha256:")
assert len(result.removeprefix("sha256:")) == 64
def test_known_value(self) -> None:
"""Bekannter SHA-256-Wert für Verifikation."""
# SHA-256 of "test" is well-known
import hashlib
expected = "sha256:" + hashlib.sha256(b"test").hexdigest()
assert compute_content_hash("test") == expected
# ---------------------------------------------------------------------------
# Tests: resolve_artifact_path
# ---------------------------------------------------------------------------
class TestResolveArtifactPath:
"""Tests für die Scope-basierte Pfadauflösung."""
def test_basic_path(self) -> None:
"""Standard-Pfadauflösung: scope/type/name.md."""
result = resolve_artifact_path("bahn", "decision", "api-design")
assert result == Path("bahn/decision/api-design.md")
def test_different_scope(self) -> None:
"""Verschiedene Scopes ergeben verschiedene Pfade."""
path_bahn = resolve_artifact_path("bahn", "note", "test")
path_privat = resolve_artifact_path("privat", "note", "test")
assert path_bahn != path_privat
assert path_bahn.parts[0] == "bahn"
assert path_privat.parts[0] == "privat"
def test_different_types(self) -> None:
"""Verschiedene Typen ergeben verschiedene Pfade."""
path_decision = resolve_artifact_path("bahn", "decision", "x")
path_note = resolve_artifact_path("bahn", "note", "x")
assert path_decision.parts[1] == "decision"
assert path_note.parts[1] == "note"
def test_name_with_md_extension_stripped(self) -> None:
"""Wenn Name bereits .md hat, wird es nicht doppelt angehängt."""
result = resolve_artifact_path("privat", "note", "test.md")
assert result == Path("privat/note/test.md")
assert not str(result).endswith(".md.md")
def test_name_without_extension(self) -> None:
"""Name ohne Erweiterung bekommt .md."""
result = resolve_artifact_path("shared", "pattern", "singleton")
assert result.suffix == ".md"
# ---------------------------------------------------------------------------
# Tests: parse_frontmatter
# ---------------------------------------------------------------------------
class TestParseFrontmatter:
"""Tests für das YAML-Frontmatter-Parsing."""
def test_full_frontmatter(self, tmp_path: Path) -> None:
"""Parst vollständiges Frontmatter mit allen Feldern."""
content = """---
type: decision
title: "API-Designprinzipien"
tags: [api, microservices]
source_context: bahn
created: 2024-12-15
updated: 2025-01-10
shareable: true
links:
- target: "privat/notes/rest.md"
relation: "implements"
content_hash: "sha256:abc123"
---
# Inhalt hier
"""
file = tmp_path / "test.md"
file.write_text(content, encoding="utf-8")
artifact = parse_frontmatter(file)
assert artifact.metadata.type == "decision"
assert artifact.metadata.title == "API-Designprinzipien"
assert artifact.metadata.tags == ["api", "microservices"]
assert artifact.metadata.source_context == "bahn"
assert artifact.metadata.created == date(2024, 12, 15)
assert artifact.metadata.updated == date(2025, 1, 10)
assert artifact.metadata.shareable is True
assert len(artifact.metadata.links) == 1
assert artifact.metadata.links[0].target == "privat/notes/rest.md"
assert artifact.metadata.links[0].relation == "implements"
assert artifact.metadata.content_hash == "sha256:abc123"
assert artifact.content == "# Inhalt hier\n"
assert artifact.file_path == file
def test_minimal_frontmatter(self, tmp_path: Path) -> None:
"""Parst minimales Frontmatter mit nur type und title."""
content = """---
type: note
title: "Kurze Notiz"
---
Einfacher Text.
"""
file = tmp_path / "minimal.md"
file.write_text(content, encoding="utf-8")
artifact = parse_frontmatter(file)
assert artifact.metadata.type == "note"
assert artifact.metadata.title == "Kurze Notiz"
assert artifact.metadata.tags == []
assert artifact.metadata.source_context == ""
assert artifact.metadata.created is None
assert artifact.metadata.shareable is False
assert artifact.metadata.links == []
assert artifact.content == "Einfacher Text.\n"
def test_multiple_links(self, tmp_path: Path) -> None:
"""Parst mehrere Verknüpfungen."""
content = """---
type: reference
title: "Multi-Link"
links:
- target: "a.md"
relation: "references"
- target: "b.md"
relation: "implements"
- target: "c.md"
relation: "extends"
---
Body.
"""
file = tmp_path / "links.md"
file.write_text(content, encoding="utf-8")
artifact = parse_frontmatter(file)
assert len(artifact.metadata.links) == 3
assert artifact.metadata.links[2].target == "c.md"
def test_no_frontmatter_raises_error(self, tmp_path: Path) -> None:
"""Datei ohne Frontmatter wirft ValueError."""
file = tmp_path / "no_fm.md"
file.write_text("# Kein Frontmatter\nNur Text.", encoding="utf-8")
with pytest.raises(ValueError, match="Kein gültiges YAML-Frontmatter"):
parse_frontmatter(file)
def test_file_not_found(self, tmp_path: Path) -> None:
"""Nicht-existierende Datei wirft FileNotFoundError."""
with pytest.raises(FileNotFoundError):
parse_frontmatter(tmp_path / "nonexistent.md")
def test_empty_frontmatter(self, tmp_path: Path) -> None:
"""Leeres Frontmatter ergibt leere Metadaten."""
content = """---
---
Nur Inhalt.
"""
file = tmp_path / "empty_fm.md"
file.write_text(content, encoding="utf-8")
artifact = parse_frontmatter(file)
assert artifact.metadata.type == ""
assert artifact.metadata.title == ""
assert artifact.content == "Nur Inhalt.\n"
def test_content_with_dashes(self, tmp_path: Path) -> None:
"""Inhalt mit --- wird nicht als Frontmatter-Ende interpretiert."""
content = """---
type: note
title: "Dashes Test"
---
Hier kommt ein Trenner:
---
Und noch mehr Text.
"""
file = tmp_path / "dashes.md"
file.write_text(content, encoding="utf-8")
artifact = parse_frontmatter(file)
assert "---" in artifact.content
assert "Und noch mehr Text." in artifact.content
# ---------------------------------------------------------------------------
# Tests: write_artifact
# ---------------------------------------------------------------------------
class TestWriteArtifact:
"""Tests für das Schreiben von Artefakten."""
def test_creates_file_with_frontmatter(self, tmp_path: Path) -> None:
"""Schreibt eine Datei mit korrektem YAML-Frontmatter."""
metadata = ArtifactMetadata(
type="decision",
title="Test Decision",
tags=["test", "example"],
source_context="bahn",
created=date(2024, 12, 15),
shareable=True,
)
artifact = Artifact(
metadata=metadata,
content="# Entscheidung\n\nDetails hier.\n",
file_path=Path("test-decision.md"),
)
result_path = write_artifact(artifact, tmp_path)
assert result_path.exists()
assert result_path == tmp_path / "bahn" / "decision" / "test-decision.md"
# Roundtrip: Datei wieder einlesen
parsed = parse_frontmatter(result_path)
assert parsed.metadata.type == "decision"
assert parsed.metadata.title == "Test Decision"
assert parsed.metadata.tags == ["test", "example"]
assert parsed.metadata.source_context == "bahn"
assert parsed.metadata.shareable is True
assert "# Entscheidung" in parsed.content
def test_creates_directory_structure(self, tmp_path: Path) -> None:
"""Erstellt fehlende Verzeichnisse automatisch."""
metadata = ArtifactMetadata(
type="pattern",
title="Singleton",
source_context="shared",
)
artifact = Artifact(
metadata=metadata,
content="Pattern-Inhalt\n",
file_path=Path("singleton.md"),
)
result_path = write_artifact(artifact, tmp_path)
assert (tmp_path / "shared" / "pattern").is_dir()
assert result_path.exists()
def test_updates_content_hash(self, tmp_path: Path) -> None:
"""Aktualisiert den Content-Hash automatisch beim Schreiben."""
content = "Neuer Inhalt für den Hash-Test.\n"
metadata = ArtifactMetadata(
type="note",
title="Hash Test",
source_context="privat",
content_hash="sha256:old_hash", # alter Hash
)
artifact = Artifact(
metadata=metadata,
content=content,
file_path=Path("hash-test.md"),
)
write_artifact(artifact, tmp_path)
# Hash muss aktualisiert sein
expected_hash = compute_content_hash(content)
assert artifact.metadata.content_hash == expected_hash
assert artifact.metadata.content_hash != "sha256:old_hash"
def test_writes_links_in_frontmatter(self, tmp_path: Path) -> None:
"""Schreibt Verknüpfungen korrekt ins Frontmatter."""
metadata = ArtifactMetadata(
type="reference",
title="Linked Artifact",
source_context="dhive",
links=[
ArtifactLink(target="other/doc.md", relation="references"),
ArtifactLink(target="third/file.md", relation="implements"),
],
)
artifact = Artifact(
metadata=metadata,
content="Verknüpfter Inhalt.\n",
file_path=Path("linked.md"),
)
result_path = write_artifact(artifact, tmp_path)
# Roundtrip-Verifikation
parsed = parse_frontmatter(result_path)
assert len(parsed.metadata.links) == 2
assert parsed.metadata.links[0].target == "other/doc.md"
assert parsed.metadata.links[0].relation == "references"
assert parsed.metadata.links[1].target == "third/file.md"
def test_overwrites_existing_file(self, tmp_path: Path) -> None:
"""Überschreibt eine bereits existierende Datei."""
metadata = ArtifactMetadata(
type="note",
title="Overwrite Test",
source_context="privat",
)
artifact = Artifact(
metadata=metadata,
content="Version 1\n",
file_path=Path("overwrite.md"),
)
path1 = write_artifact(artifact, tmp_path)
artifact.content = "Version 2\n"
path2 = write_artifact(artifact, tmp_path)
assert path1 == path2
parsed = parse_frontmatter(path2)
assert "Version 2" in parsed.content
def test_updates_artifact_file_path(self, tmp_path: Path) -> None:
"""Aktualisiert artifact.file_path nach dem Schreiben."""
metadata = ArtifactMetadata(
type="meeting",
title="Standup",
source_context="dhive",
)
artifact = Artifact(
metadata=metadata,
content="Meeting-Notizen.\n",
file_path=Path("standup.md"),
)
result_path = write_artifact(artifact, tmp_path)
assert artifact.file_path == result_path
@@ -0,0 +1,170 @@
"""Tests für den AuditLogger."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import pytest
from monorepo.audit import AuditLogger, _DEFAULT_AUDIT_LOG_PATH
from monorepo.models import SecurityEvent
class TestAuditLoggerInit:
"""Tests für die Initialisierung des AuditLoggers."""
def test_default_log_path(self, tmp_path: Path) -> None:
"""AuditLogger mit Standard-Pfad nutzt _DEFAULT_AUDIT_LOG_PATH."""
# Wir übergeben explizit einen tmp-Pfad, da der Default relativ ist
log_path = tmp_path / ".audit" / "access.log"
logger = AuditLogger(log_path=log_path)
assert logger.log_path == log_path
def test_creates_log_directory(self, tmp_path: Path) -> None:
"""AuditLogger erstellt das Verzeichnis, falls es nicht existiert."""
log_path = tmp_path / "nested" / "deep" / "audit.log"
assert not log_path.parent.exists()
AuditLogger(log_path=log_path)
assert log_path.parent.exists()
def test_existing_directory_no_error(self, tmp_path: Path) -> None:
"""AuditLogger wirft keinen Fehler bei bereits existierendem Verzeichnis."""
log_dir = tmp_path / ".audit"
log_dir.mkdir()
log_path = log_dir / "access.log"
logger = AuditLogger(log_path=log_path)
assert logger.log_path == log_path
class TestLogViolation:
"""Tests für die log_violation-Methode."""
def _make_event(
self,
requesting: str = "dhive",
target: str = "privat",
resource: str = "privat/.env",
action: str = "read",
outcome: str = "denied",
ts: datetime | None = None,
) -> SecurityEvent:
"""Erstellt ein Test-SecurityEvent."""
return SecurityEvent(
timestamp=ts or datetime(2025, 1, 15, 10, 30, 0),
requesting_context=requesting,
target_context=target,
resource=resource,
action=action,
outcome=outcome,
)
def test_writes_single_entry(self, tmp_path: Path) -> None:
"""log_violation schreibt einen Eintrag in die Logdatei."""
log_path = tmp_path / ".audit" / "access.log"
logger = AuditLogger(log_path=log_path)
event = self._make_event()
logger.log_violation(event)
content = log_path.read_text(encoding="utf-8")
assert "[2025-01-15T10:30:00] DENIED | dhive -> privat | read on privat/.env\n" == content
def test_appends_multiple_entries(self, tmp_path: Path) -> None:
"""log_violation fügt Einträge an, ohne bestehende zu überschreiben."""
log_path = tmp_path / ".audit" / "access.log"
logger = AuditLogger(log_path=log_path)
event1 = self._make_event(
requesting="bahn", target="privat", resource="privat/.env",
action="read", outcome="denied",
ts=datetime(2025, 1, 15, 10, 0, 0),
)
event2 = self._make_event(
requesting="dhive", target="bahn", resource="bahn/.env",
action="write", outcome="denied",
ts=datetime(2025, 1, 15, 11, 0, 0),
)
logger.log_violation(event1)
logger.log_violation(event2)
lines = log_path.read_text(encoding="utf-8").splitlines()
assert len(lines) == 2
assert "bahn -> privat" in lines[0]
assert "dhive -> bahn" in lines[1]
def test_format_contains_iso_timestamp(self, tmp_path: Path) -> None:
"""Der Zeitstempel ist im ISO-8601-Format."""
log_path = tmp_path / ".audit" / "access.log"
logger = AuditLogger(log_path=log_path)
ts = datetime(2025, 6, 20, 14, 45, 30)
event = self._make_event(ts=ts)
logger.log_violation(event)
content = log_path.read_text(encoding="utf-8")
assert "[2025-06-20T14:45:30]" in content
def test_format_outcome_uppercase(self, tmp_path: Path) -> None:
"""Das Outcome wird in Großbuchstaben geschrieben."""
log_path = tmp_path / ".audit" / "access.log"
logger = AuditLogger(log_path=log_path)
event = self._make_event(outcome="allowed")
logger.log_violation(event)
content = log_path.read_text(encoding="utf-8")
assert "ALLOWED" in content
def test_format_contains_action_and_resource(self, tmp_path: Path) -> None:
"""Die Aktion und Ressource werden korrekt formatiert."""
log_path = tmp_path / ".audit" / "access.log"
logger = AuditLogger(log_path=log_path)
event = self._make_event(action="execute", resource="shared/tools/script.sh")
logger.log_violation(event)
content = log_path.read_text(encoding="utf-8")
assert "execute on shared/tools/script.sh" in content
def test_thread_safety(self, tmp_path: Path) -> None:
"""Parallele Aufrufe erzeugen keine korrupten Einträge."""
import threading
log_path = tmp_path / ".audit" / "access.log"
logger = AuditLogger(log_path=log_path)
events = [
self._make_event(
requesting=f"ctx{i}",
target="privat",
resource="privat/.env",
ts=datetime(2025, 1, 15, 10, i, 0),
)
for i in range(20)
]
threads = [
threading.Thread(target=logger.log_violation, args=(e,))
for e in events
]
for t in threads:
t.start()
for t in threads:
t.join()
lines = log_path.read_text(encoding="utf-8").splitlines()
assert len(lines) == 20
# Jede Zeile muss vollständig sein (beginnt mit [ und endet mit resource)
for line in lines:
assert line.startswith("[")
assert "privat/.env" in line
class TestDefaultPath:
"""Tests für den Standard-Audit-Log-Pfad."""
def test_default_path_value(self) -> None:
"""Der Standard-Pfad entspricht der monorepo.yaml-Konfiguration."""
assert _DEFAULT_AUDIT_LOG_PATH == Path(".audit/access.log")
@@ -0,0 +1,463 @@
"""Unit-Tests für die ContextBridge (Kontextbrücke).
Testet:
- Sensitive-Content-Erkennung (Credentials, Endpoints, PII)
- Freigabe-Ablauf mit Nutzerbestätigung
- Verweigerung bei sensitivem Inhalt
- Widerruf von Freigaben
- Update-Propagierung
- Registry-Persistierung
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import pytest
from monorepo.bridge import ContextBridge, SensitiveMatch, ShareResult, SharedArtifactEntry
from monorepo.knowledge.index import IndexEntry, YAMLIndex
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_knowledge_base(tmp_path: Path) -> Path:
"""Erstellt einen temporären Wissensspeicher mit Test-Artefakten."""
kb_path = tmp_path / "knowledge-store"
kb_path.mkdir()
return kb_path
@pytest.fixture
def sample_artifact_file(tmp_knowledge_base: Path) -> Path:
"""Erstellt eine Test-Artefakt-Datei ohne sensitive Inhalte."""
artifact_dir = tmp_knowledge_base / "bahn" / "decision"
artifact_dir.mkdir(parents=True)
artifact_file = artifact_dir / "api-design.md"
artifact_file.write_text(
"---\n"
"type: decision\n"
"title: API Design Entscheidung\n"
"tags: [api, design]\n"
"source_context: bahn\n"
"shareable: true\n"
"content_hash: sha256:abc123\n"
"---\n"
"# API Design\n\n"
"Wir verwenden REST für die externe API.\n",
encoding="utf-8",
)
return artifact_file
@pytest.fixture
def sensitive_artifact_file(tmp_knowledge_base: Path) -> Path:
"""Erstellt eine Test-Artefakt-Datei MIT sensitivem Inhalt."""
artifact_dir = tmp_knowledge_base / "dhive" / "note"
artifact_dir.mkdir(parents=True)
artifact_file = artifact_dir / "service-config.md"
artifact_file.write_text(
"---\n"
"type: note\n"
"title: Service Konfiguration\n"
"tags: [config]\n"
"source_context: dhive\n"
"shareable: false\n"
"content_hash: sha256:def456\n"
"---\n"
"# Service Config\n\n"
"api_key: sk-12345abcdef\n"
"endpoint: https://internal.dhive.example.com/api\n"
"Kontakt: admin@dhive-internal.de\n",
encoding="utf-8",
)
return artifact_file
@pytest.fixture
def yaml_index(tmp_knowledge_base: Path, sample_artifact_file: Path) -> YAMLIndex:
"""Erstellt einen YAML-Index mit einem Test-Eintrag."""
index = YAMLIndex(tmp_knowledge_base / "_index.yaml")
index.load()
entry = IndexEntry(
id="bahn/decision/api-design",
title="API Design Entscheidung",
type="decision",
tags=["api", "design"],
scope="bahn",
summary="REST API Entscheidung",
path="bahn/decision/api-design.md",
content_hash="sha256:abc123",
)
index.update_entry(entry)
return index
@pytest.fixture
def yaml_index_with_sensitive(
yaml_index: YAMLIndex, sensitive_artifact_file: Path
) -> YAMLIndex:
"""YAML-Index mit einem sensitiven Artefakt."""
entry = IndexEntry(
id="dhive/note/service-config",
title="Service Konfiguration",
type="note",
tags=["config"],
scope="dhive",
summary="Service-Konfiguration mit Secrets",
path="dhive/note/service-config.md",
content_hash="sha256:def456",
)
yaml_index.update_entry(entry)
return yaml_index
@pytest.fixture
def bridge(tmp_knowledge_base: Path, yaml_index: YAMLIndex, tmp_path: Path) -> ContextBridge:
"""Erstellt eine ContextBridge-Instanz mit Registry-Pfad."""
registry_path = tmp_path / "config" / "shared-artifacts.yaml"
return ContextBridge(
knowledge_base_path=tmp_knowledge_base,
index=yaml_index,
registry_path=registry_path,
)
@pytest.fixture
def bridge_with_sensitive(
tmp_knowledge_base: Path, yaml_index_with_sensitive: YAMLIndex, tmp_path: Path
) -> ContextBridge:
"""ContextBridge mit sensitiven Artefakten im Index."""
registry_path = tmp_path / "config" / "shared-artifacts.yaml"
return ContextBridge(
knowledge_base_path=tmp_knowledge_base,
index=yaml_index_with_sensitive,
registry_path=registry_path,
)
# ---------------------------------------------------------------------------
# Tests: check_sensitive_content (Req 5.1, 5.3)
# ---------------------------------------------------------------------------
class TestCheckSensitiveContent:
"""Tests für die Sensitive-Content-Erkennung."""
def test_no_sensitive_content(self, bridge: ContextBridge) -> None:
"""Kein sensitiver Inhalt wird als leere Liste erkannt."""
content = "# API Design\n\nWir verwenden REST.\nDetails folgen."
result = bridge.check_sensitive_content(content)
assert result == []
def test_detects_api_key(self, bridge: ContextBridge) -> None:
"""Erkennt API-Key-Muster."""
content = "config:\n api_key: sk-12345abcdef\n"
result = bridge.check_sensitive_content(content)
assert len(result) >= 1
assert any(m.pattern_name == "credentials" for m in result)
def test_detects_token(self, bridge: ContextBridge) -> None:
"""Erkennt Token-Muster."""
content = "Authorization:\n token= ghp_abc123xyz\n"
result = bridge.check_sensitive_content(content)
assert len(result) >= 1
assert any(m.pattern_name == "credentials" for m in result)
def test_detects_password(self, bridge: ContextBridge) -> None:
"""Erkennt Passwort-Muster."""
content = "db_password: supersecret123\n"
result = bridge.check_sensitive_content(content)
assert len(result) >= 1
assert any(m.pattern_name == "credentials" for m in result)
def test_detects_secret(self, bridge: ContextBridge) -> None:
"""Erkennt Secret-Muster."""
content = "client_secret= abc123\n"
result = bridge.check_sensitive_content(content)
assert len(result) >= 1
assert any(m.pattern_name == "credentials" for m in result)
def test_detects_endpoint_url(self, bridge: ContextBridge) -> None:
"""Erkennt Endpoint-URL-Muster."""
content = "Konfiguration:\n endpoint: https://api.internal.example.com/v2\n"
result = bridge.check_sensitive_content(content)
assert len(result) >= 1
assert any(m.pattern_name == "endpoint" for m in result)
def test_detects_email_pii(self, bridge: ContextBridge) -> None:
"""Erkennt E-Mail-Adressen als PII."""
content = "Ansprechpartner: max.mustermann@example.de\n"
result = bridge.check_sensitive_content(content)
assert len(result) >= 1
assert any(m.pattern_name == "pii" for m in result)
def test_multiple_sensitive_items(self, bridge: ContextBridge) -> None:
"""Erkennt mehrere sensitive Elemente."""
content = (
"# Config\n"
"api_key: abc123\n"
"endpoint: https://internal.example.com/api\n"
"admin: admin@example.org\n"
)
result = bridge.check_sensitive_content(content)
assert len(result) >= 3
pattern_names = {m.pattern_name for m in result}
assert "credentials" in pattern_names
assert "endpoint" in pattern_names
assert "pii" in pattern_names
def test_line_numbers_correct(self, bridge: ContextBridge) -> None:
"""Zeilennummern sind korrekt (1-basiert)."""
content = "Zeile 1\nZeile 2\napi_key: geheim\nZeile 4\n"
result = bridge.check_sensitive_content(content)
assert len(result) >= 1
assert result[0].line_number == 3
def test_empty_content(self, bridge: ContextBridge) -> None:
"""Leerer Inhalt ergibt keine Treffer."""
result = bridge.check_sensitive_content("")
assert result == []
def test_case_insensitive_credentials(self, bridge: ContextBridge) -> None:
"""Credential-Erkennung ist case-insensitive."""
content = "API_KEY: value123\nToken: xyz\nPASSWORD= abc\n"
result = bridge.check_sensitive_content(content)
assert len(result) >= 3
# ---------------------------------------------------------------------------
# Tests: share_artifact (Req 5.1, 5.2, 5.3, 5.4)
# ---------------------------------------------------------------------------
class TestShareArtifact:
"""Tests für die Artefakt-Freigabe."""
def test_share_requires_user_confirmation(self, bridge: ContextBridge) -> None:
"""Req 5.4: Freigabe ohne Nutzerbestätigung wird verweigert."""
result = bridge.share_artifact("bahn/decision/api-design", user_confirmed=False)
assert result.success is False
assert "Nutzerbestätigung" in result.errors[0]
def test_share_nonexistent_artifact(self, bridge: ContextBridge) -> None:
"""Freigabe eines nicht existierenden Artefakts schlägt fehl."""
result = bridge.share_artifact("nonexistent/artifact", user_confirmed=True)
assert result.success is False
assert "nicht im Index" in result.errors[0]
def test_share_clean_artifact_succeeds(self, bridge: ContextBridge) -> None:
"""Req 5.2: Freigabe eines sauberen Artefakts gelingt."""
result = bridge.share_artifact("bahn/decision/api-design", user_confirmed=True)
assert result.success is True
assert result.artifact_id == "bahn/decision/api-design"
assert len(result.shared_to) > 0
# Bahn-Artefakt sollte in privat, dhive, shared sichtbar sein
assert "bahn" not in result.shared_to
assert "privat" in result.shared_to
assert "dhive" in result.shared_to
assert "shared" in result.shared_to
def test_share_sensitive_artifact_refused(
self, bridge_with_sensitive: ContextBridge
) -> None:
"""Req 5.3: Freigabe eines Artefakts mit sensitivem Inhalt wird verweigert."""
result = bridge_with_sensitive.share_artifact(
"dhive/note/service-config", user_confirmed=True
)
assert result.success is False
assert any("sensible Inhalte" in e for e in result.errors)
def test_share_marks_as_shared(self, bridge: ContextBridge) -> None:
"""Nach Freigabe ist das Artefakt als geteilt markiert."""
bridge.share_artifact("bahn/decision/api-design", user_confirmed=True)
assert bridge.is_shared("bahn/decision/api-design") is True
def test_unshared_artifact_not_marked(self, bridge: ContextBridge) -> None:
"""Nicht freigegebene Artefakte sind nicht als geteilt markiert."""
assert bridge.is_shared("bahn/decision/api-design") is False
# ---------------------------------------------------------------------------
# Tests: revoke_share (Req 5.6)
# ---------------------------------------------------------------------------
class TestRevokeShare:
"""Tests für den Widerruf der Freigabe."""
def test_revoke_removes_from_registry(self, bridge: ContextBridge) -> None:
"""Req 5.6: Widerruf entfernt Artefakt aus der Registry."""
bridge.share_artifact("bahn/decision/api-design", user_confirmed=True)
assert bridge.is_shared("bahn/decision/api-design") is True
bridge.revoke_share("bahn/decision/api-design")
assert bridge.is_shared("bahn/decision/api-design") is False
def test_revoke_nonexistent_raises(self, bridge: ContextBridge) -> None:
"""Widerruf eines nicht freigegebenen Artefakts wirft ValueError."""
with pytest.raises(ValueError, match="nicht freigegeben"):
bridge.revoke_share("nonexistent/artifact")
def test_revoke_removes_from_target_contexts(self, bridge: ContextBridge) -> None:
"""Nach Widerruf ist das Artefakt in keinem Zielkontext mehr sichtbar."""
bridge.share_artifact("bahn/decision/api-design", user_confirmed=True)
bridge.revoke_share("bahn/decision/api-design")
assert bridge.get_shared_artifacts("privat") == []
assert bridge.get_shared_artifacts("dhive") == []
assert bridge.get_shared_artifacts("shared") == []
# ---------------------------------------------------------------------------
# Tests: get_shared_content (Update-Propagierung, Req 5.5)
# ---------------------------------------------------------------------------
class TestGetSharedContent:
"""Tests für Lesezugriff und Update-Propagierung."""
def test_read_shared_artifact(self, bridge: ContextBridge) -> None:
"""Req 5.2: Freigegebenes Artefakt ist in Zielkontext lesbar."""
bridge.share_artifact("bahn/decision/api-design", user_confirmed=True)
content = bridge.get_shared_content("bahn/decision/api-design", "privat")
assert content is not None
assert "REST" in content
def test_read_not_shared_returns_none(self, bridge: ContextBridge) -> None:
"""Nicht freigegebenes Artefakt liefert None."""
content = bridge.get_shared_content("bahn/decision/api-design", "privat")
assert content is None
def test_read_from_unauthorized_context_returns_none(
self, bridge: ContextBridge
) -> None:
"""Lesezugriff aus Quellkontext (nicht Zielkontext) liefert None."""
bridge.share_artifact("bahn/decision/api-design", user_confirmed=True)
# Bahn ist der Quellkontext, nicht ein Zielkontext
content = bridge.get_shared_content("bahn/decision/api-design", "bahn")
assert content is None
def test_update_propagation(
self, bridge: ContextBridge, tmp_knowledge_base: Path
) -> None:
"""Req 5.5: Änderungen im Quellkontext sind beim nächsten Lesen sichtbar."""
bridge.share_artifact("bahn/decision/api-design", user_confirmed=True)
# Inhalt aktualisieren
artifact_path = tmp_knowledge_base / "bahn" / "decision" / "api-design.md"
artifact_path.write_text(
"---\n"
"type: decision\n"
"title: API Design Entscheidung\n"
"tags: [api, design]\n"
"source_context: bahn\n"
"shareable: true\n"
"content_hash: sha256:updated\n"
"---\n"
"# API Design v2\n\n"
"Wir verwenden GraphQL für die externe API.\n",
encoding="utf-8",
)
# Nächster Lesezugriff sieht den neuen Inhalt
content = bridge.get_shared_content("bahn/decision/api-design", "privat")
assert content is not None
assert "GraphQL" in content
# ---------------------------------------------------------------------------
# Tests: get_shared_artifacts
# ---------------------------------------------------------------------------
class TestGetSharedArtifacts:
"""Tests für die Abfrage sichtbarer Artefakte pro Kontext."""
def test_returns_shared_artifacts_for_context(self, bridge: ContextBridge) -> None:
"""Listet freigegebene Artefakte für einen Zielkontext."""
bridge.share_artifact("bahn/decision/api-design", user_confirmed=True)
artifacts = bridge.get_shared_artifacts("privat")
assert "bahn/decision/api-design" in artifacts
def test_empty_for_source_context(self, bridge: ContextBridge) -> None:
"""Quellkontext sieht sein eigenes Artefakt nicht in der Share-Liste."""
bridge.share_artifact("bahn/decision/api-design", user_confirmed=True)
artifacts = bridge.get_shared_artifacts("bahn")
assert "bahn/decision/api-design" not in artifacts
# ---------------------------------------------------------------------------
# Tests: Registry-Persistierung
# ---------------------------------------------------------------------------
class TestRegistryPersistence:
"""Tests für die YAML-Persistierung der Shared-Artifacts-Registry."""
def test_registry_persisted_after_share(
self, bridge: ContextBridge, tmp_path: Path
) -> None:
"""Registry wird nach Freigabe auf Festplatte geschrieben."""
bridge.share_artifact("bahn/decision/api-design", user_confirmed=True)
registry_path = tmp_path / "config" / "shared-artifacts.yaml"
assert registry_path.exists()
def test_registry_loaded_on_init(
self,
tmp_knowledge_base: Path,
yaml_index: YAMLIndex,
tmp_path: Path,
) -> None:
"""Registry wird beim Instanzieren aus der Datei geladen."""
registry_path = tmp_path / "config" / "shared-artifacts.yaml"
# Erste Bridge: Artefakt freigeben
bridge1 = ContextBridge(
knowledge_base_path=tmp_knowledge_base,
index=yaml_index,
registry_path=registry_path,
)
bridge1.share_artifact("bahn/decision/api-design", user_confirmed=True)
# Zweite Bridge: Registry sollte geladen sein
bridge2 = ContextBridge(
knowledge_base_path=tmp_knowledge_base,
index=yaml_index,
registry_path=registry_path,
)
assert bridge2.is_shared("bahn/decision/api-design") is True
def test_registry_updated_after_revoke(
self,
tmp_knowledge_base: Path,
yaml_index: YAMLIndex,
tmp_path: Path,
) -> None:
"""Registry wird nach Widerruf aktualisiert."""
registry_path = tmp_path / "config" / "shared-artifacts.yaml"
bridge1 = ContextBridge(
knowledge_base_path=tmp_knowledge_base,
index=yaml_index,
registry_path=registry_path,
)
bridge1.share_artifact("bahn/decision/api-design", user_confirmed=True)
bridge1.revoke_share("bahn/decision/api-design")
# Neue Bridge: Artefakt sollte nicht mehr geteilt sein
bridge2 = ContextBridge(
knowledge_base_path=tmp_knowledge_base,
index=yaml_index,
registry_path=registry_path,
)
assert bridge2.is_shared("bahn/decision/api-design") is False
@@ -0,0 +1,249 @@
"""Property-Based Tests für explizite Nutzerbestätigung bei Freigabe.
**Validates: Requirements 5.4**
Property 16: Freigabe erfordert explizite Nutzerbestätigung
- Für jeden Freigabeversuch ohne explizite Nutzerbestätigung (user_confirmed=False)
muss die Freigabe verweigert werden und der Zustand des Artefakts unverändert
bleiben (nicht geteilt, nicht in der Registry).
- Umgekehrt: Wenn user_confirmed=True und das Artefakt valide und frei von
sensitiven Inhalten ist, soll die Freigabe gelingen.
"""
from __future__ import annotations
from pathlib import Path
from hypothesis import given, settings, HealthCheck
from hypothesis import strategies as st
from monorepo.bridge import ContextBridge, ShareResult
from monorepo.knowledge.index import IndexEntry, YAMLIndex
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _make_bridge(tmp_path: Path) -> ContextBridge:
"""Erstellt eine minimale ContextBridge-Instanz für Tests."""
kb_path = tmp_path / "kb"
kb_path.mkdir(parents=True, exist_ok=True)
index = YAMLIndex(kb_path / "_index.yaml")
index.load()
return ContextBridge(knowledge_base_path=kb_path, index=index)
def _make_bridge_with_artifact(tmp_path: Path, artifact_id: str) -> ContextBridge:
"""Erstellt eine ContextBridge mit einem sauberen Artefakt im Index und Dateisystem."""
kb_path = tmp_path / "kb"
kb_path.mkdir(parents=True, exist_ok=True)
# Artefakt-Datei erstellen (sauber, ohne sensitive Inhalte)
parts = artifact_id.split("/")
artifact_dir = kb_path / "/".join(parts[:-1])
artifact_dir.mkdir(parents=True, exist_ok=True)
artifact_file = artifact_dir / f"{parts[-1]}.md"
artifact_file.write_text(
"---\n"
f"type: note\n"
f"title: Test Artefakt {parts[-1]}\n"
f"tags: [test]\n"
f"source_context: {parts[0]}\n"
f"content_hash: sha256:test123\n"
"---\n"
"# Test Artefakt\n\n"
"Dies ist ein sauberer Inhalt ohne sensitive Daten.\n"
"Architektur-Design und allgemeine Dokumentation.\n",
encoding="utf-8",
)
# Index erstellen und Eintrag hinzufügen
index = YAMLIndex(kb_path / "_index.yaml")
index.load()
rel_path = str(artifact_file.relative_to(kb_path))
entry = IndexEntry(
id=artifact_id,
title=f"Test Artefakt {parts[-1]}",
type="note",
tags=["test"],
scope=parts[0],
summary="Ein sauberes Test-Artefakt",
path=rel_path,
content_hash="sha256:test123",
)
index.update_entry(entry)
index.save()
return ContextBridge(knowledge_base_path=kb_path, index=index)
# ---------------------------------------------------------------------------
# Strategien
# ---------------------------------------------------------------------------
# Zufällige Artefakt-IDs im Format kontext/typ/name
artifact_ids = st.from_regex(r"[a-z]{3,8}/[a-z]{4,10}/[a-z\-]{3,15}", fullmatch=True)
# Kontexte für die Prüfung geteilter Artefakte
contexts = st.sampled_from(["privat", "dhive", "bahn", "shared"])
# ---------------------------------------------------------------------------
# Property 16: Freigabe erfordert explizite Nutzerbestätigung
# ---------------------------------------------------------------------------
class TestUserConfirmationProperties:
"""Property-Tests für explizite Nutzerbestätigung.
**Validates: Requirements 5.4**
"""
@given(artifact_id=artifact_ids)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_without_confirmation_always_rejected(
self, tmp_path: Path, artifact_id: str
) -> None:
"""Ohne user_confirmed=True wird jede Freigabe stets verweigert.
**Validates: Requirements 5.4**
Unabhängig davon, ob das Artefakt existiert oder nicht, muss
share_artifact ohne Bestätigung immer fehlschlagen.
"""
bridge = _make_bridge(tmp_path)
result = bridge.share_artifact(artifact_id, user_confirmed=False)
# success muss False sein
assert result.success is False, (
f"Freigabe ohne Bestätigung sollte fehlschlagen für: {artifact_id}"
)
@given(artifact_id=artifact_ids)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_without_confirmation_error_message_present(
self, tmp_path: Path, artifact_id: str
) -> None:
"""Die Fehlermeldung muss auf fehlende Nutzerbestätigung hinweisen.
**Validates: Requirements 5.4**
"""
bridge = _make_bridge(tmp_path)
result = bridge.share_artifact(artifact_id, user_confirmed=False)
# Fehlerliste darf nicht leer sein
assert len(result.errors) > 0, (
f"Fehlermeldung fehlt bei verweigerter Freigabe für: {artifact_id}"
)
# Mindestens eine Fehlermeldung muss "Bestätigung" oder "confirmed" erwähnen
confirmation_mentioned = any(
"bestätigung" in err.lower() or "confirmed" in err.lower()
for err in result.errors
)
assert confirmation_mentioned, (
f"Fehlermeldung erwähnt nicht die fehlende Bestätigung. "
f"Errors: {result.errors}"
)
@given(artifact_id=artifact_ids, context=contexts)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_without_confirmation_artifact_stays_unshared(
self, tmp_path: Path, artifact_id: str, context: str
) -> None:
"""Nach verweigerter Freigabe bleibt das Artefakt nicht geteilt.
**Validates: Requirements 5.4**
is_shared() muss False bleiben und das Artefakt darf in keinem
Kontext als geteiltes Artefakt auftauchen.
"""
bridge = _make_bridge(tmp_path)
# Freigabe ohne Bestätigung versuchen
bridge.share_artifact(artifact_id, user_confirmed=False)
# Artefakt darf nicht als geteilt gelten
assert bridge.is_shared(artifact_id) is False, (
f"Artefakt '{artifact_id}' wurde fälschlicherweise als geteilt markiert"
)
# Artefakt darf in keinem Kontext als geteiltes Artefakt auftauchen
shared_in_context = bridge.get_shared_artifacts(context)
assert artifact_id not in shared_in_context, (
f"Artefakt '{artifact_id}' taucht fälschlicherweise in Kontext "
f"'{context}' auf nach verweigerter Freigabe"
)
@given(artifact_id=artifact_ids)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_without_confirmation_registry_unchanged(
self, tmp_path: Path, artifact_id: str
) -> None:
"""Die Registry bleibt bei verweigerter Freigabe unverändert (leer).
**Validates: Requirements 5.4**
"""
bridge = _make_bridge(tmp_path)
# Registry sollte initial leer sein
initial_shared_all = (
bridge.get_shared_artifacts("privat")
+ bridge.get_shared_artifacts("dhive")
+ bridge.get_shared_artifacts("bahn")
+ bridge.get_shared_artifacts("shared")
)
assert initial_shared_all == [], "Registry sollte initial leer sein"
# Freigabe ohne Bestätigung
bridge.share_artifact(artifact_id, user_confirmed=False)
# Registry muss weiterhin leer sein
after_shared_all = (
bridge.get_shared_artifacts("privat")
+ bridge.get_shared_artifacts("dhive")
+ bridge.get_shared_artifacts("bahn")
+ bridge.get_shared_artifacts("shared")
)
assert after_shared_all == [], (
f"Registry wurde verändert nach verweigerter Freigabe für: {artifact_id}. "
f"Enthält: {after_shared_all}"
)
@given(data=st.data())
@settings(max_examples=50, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_with_confirmation_valid_artifact_succeeds(
self, tmp_path: Path, data: st.DataObject
) -> None:
"""Mit user_confirmed=True und validem, sauberem Artefakt gelingt die Freigabe.
**Validates: Requirements 5.4**
Dies testet die Umkehrung: Wenn die Bestätigung vorliegt und das
Artefakt existiert und sauber ist, darf die Freigabe nicht allein
wegen fehlender Bestätigung scheitern.
"""
# Feste, valide Artefakt-IDs verwenden (müssen im Dateisystem existieren)
scope = data.draw(st.sampled_from(["privat", "dhive", "bahn"]))
artifact_type = data.draw(st.sampled_from(["note", "decision", "meeting"]))
name = data.draw(
st.from_regex(r"[a-z]{3,10}", fullmatch=True)
)
artifact_id = f"{scope}/{artifact_type}/{name}"
bridge = _make_bridge_with_artifact(tmp_path, artifact_id)
result = bridge.share_artifact(artifact_id, user_confirmed=True)
# Mit Bestätigung und sauberem Artefakt muss die Freigabe gelingen
assert result.success is True, (
f"Freigabe mit Bestätigung scheiterte für valides Artefakt '{artifact_id}'. "
f"Errors: {result.errors}"
)
# Das Artefakt muss nun als geteilt gelten
assert bridge.is_shared(artifact_id) is True, (
f"Artefakt '{artifact_id}' ist nach erfolgreicher Freigabe nicht als geteilt markiert"
)
@@ -0,0 +1,384 @@
"""Property-Based Tests für den Freigabe-Lebenszyklus der ContextBridge.
**Validates: Requirements 5.2, 5.5, 5.6**
Property 15: Freigabe-Lebenszyklus (Share → Update → Revoke)
- Für jedes Wissensartefakt, das den Freigabe-Lebenszyklus durchläuft
(Erstellen → Teilen → Lesen → Aktualisieren → Erneut lesen → Widerrufen → Lesen fehlschlägt),
müssen die folgenden Invarianten gelten:
1. Nach share_artifact: is_shared == True, get_shared_content liefert Inhalt für berechtigte Kontexte
2. Nach Update der Quelldatei: get_shared_content liefert den neuen Inhalt (Update-Propagierung)
3. Nach revoke_share: is_shared == False, get_shared_content liefert None
"""
from __future__ import annotations
from pathlib import Path
from hypothesis import given, settings, HealthCheck
from hypothesis import strategies as st
from monorepo.bridge import ContextBridge, ShareResult
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.models import Context
# ---------------------------------------------------------------------------
# Strategien
# ---------------------------------------------------------------------------
# Safe titles that won't be blocked by the sensitive filter
safe_titles = st.sampled_from([
"Design Entscheidung",
"Architektur Übersicht",
"Sprint Planung",
"Code Review Richtlinien",
"Modulare Struktur",
"Team Vereinbarung",
"Prozess Dokumentation",
"Technische Bewertung",
])
# Safe content lines (no credentials, endpoints, or email patterns)
safe_content_lines = st.lists(
st.sampled_from([
"REST nutzen",
"Microservices",
"Clean Architecture",
"Modulare Struktur",
"Domain Driven Design",
"Event Sourcing verwenden",
"CQRS Pattern anwenden",
"Hexagonale Architektur",
"Agile Methoden einsetzen",
"Continuous Integration",
]),
min_size=2,
max_size=5,
)
# Scopes for the artifact (source context)
artifact_scopes = st.sampled_from(["privat", "dhive", "bahn"])
# Artifact types
artifact_types = st.sampled_from(["decision", "note", "meeting", "reference"])
# Updated content lines (different from initial content for update propagation)
updated_content_lines = st.lists(
st.sampled_from([
"GraphQL verwenden",
"Serverless Architektur",
"Monolith aufbrechen",
"API Gateway einsetzen",
"Container Orchestrierung",
"Feature Flags nutzen",
"Blue Green Deployment",
"Canary Release",
]),
min_size=2,
max_size=5,
)
# Safe tags (no sensitive patterns)
safe_tags = st.lists(
st.sampled_from(["architektur", "design", "planung", "review", "sprint", "technik"]),
min_size=1,
max_size=3,
unique=True,
)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _build_artifact_content(title: str, artifact_type: str, scope: str,
tags: list[str], content_lines: list[str]) -> str:
"""Builds a valid artifact file content with YAML frontmatter."""
tags_str = ", ".join(tags)
content_body = "\n".join(f"- {line}" for line in content_lines)
return (
f"---\n"
f"type: {artifact_type}\n"
f"title: \"{title}\"\n"
f"tags: [{tags_str}]\n"
f"source_context: {scope}\n"
f"shareable: true\n"
f"content_hash: sha256:initial\n"
f"---\n"
f"# {title}\n\n"
f"{content_body}\n"
)
def _create_bridge_with_artifact(
tmp_path: Path,
artifact_id: str,
scope: str,
artifact_type: str,
title: str,
tags: list[str],
content_lines: list[str],
) -> tuple[ContextBridge, Path]:
"""Creates a ContextBridge with a single artifact file and index entry.
Returns the bridge and the path to the artifact file.
"""
kb_path = tmp_path / "knowledge-store"
kb_path.mkdir(parents=True, exist_ok=True)
# Create the artifact file on disk
artifact_dir = kb_path / scope / artifact_type
artifact_dir.mkdir(parents=True, exist_ok=True)
artifact_file = artifact_dir / f"{artifact_id.split('/')[-1]}.md"
file_content = _build_artifact_content(title, artifact_type, scope, tags, content_lines)
artifact_file.write_text(file_content, encoding="utf-8")
# Create the YAML index
index = YAMLIndex(kb_path / "_index.yaml")
index.load()
entry = IndexEntry(
id=artifact_id,
title=title,
type=artifact_type,
tags=tags,
scope=scope,
summary=f"Test-Artefakt: {title}",
path=f"{scope}/{artifact_type}/{artifact_id.split('/')[-1]}.md",
content_hash="sha256:initial",
)
index.update_entry(entry)
# Create bridge with registry
registry_path = tmp_path / "config" / "shared-artifacts.yaml"
bridge = ContextBridge(
knowledge_base_path=kb_path,
index=index,
registry_path=registry_path,
)
return bridge, artifact_file
def _get_target_contexts(source_scope: str) -> list[str]:
"""Returns all contexts that are NOT the source context (target contexts)."""
return [ctx.value for ctx in Context if ctx.value != source_scope]
# ---------------------------------------------------------------------------
# Property 15: Freigabe-Lebenszyklus (Share → Update → Revoke)
# ---------------------------------------------------------------------------
class TestFreigabeLebenszyklus:
"""Property-Tests für den vollständigen Freigabe-Lebenszyklus.
**Validates: Requirements 5.2, 5.5, 5.6**
"""
@given(
title=safe_titles,
scope=artifact_scopes,
artifact_type=artifact_types,
tags=safe_tags,
initial_content=safe_content_lines,
updated_content=updated_content_lines,
)
@settings(
max_examples=50,
suppress_health_check=[HealthCheck.function_scoped_fixture],
deadline=None,
)
def test_full_lifecycle_invariants(
self,
tmp_path: Path,
title: str,
scope: str,
artifact_type: str,
tags: list[str],
initial_content: list[str],
updated_content: list[str],
) -> None:
"""Der vollständige Lebenszyklus (Share → Read → Update → Read → Revoke → Read fails)
muss für beliebige Artefakt-Konfigurationen alle Invarianten einhalten.
**Validates: Requirements 5.2, 5.5, 5.6**
"""
# Artifact-ID aus scope/type/name ableiten
name_slug = title.lower().replace(" ", "-").replace("ü", "ue")
artifact_id = f"{scope}/{artifact_type}/{name_slug}"
# Setup: Bridge und Artefakt erstellen
bridge, artifact_file = _create_bridge_with_artifact(
tmp_path, artifact_id, scope, artifact_type, title, tags, initial_content
)
target_contexts = _get_target_contexts(scope)
# --- Phase 1: Vor der Freigabe ---
assert bridge.is_shared(artifact_id) is False, (
"Artefakt darf vor Freigabe nicht als geteilt markiert sein"
)
for ctx in target_contexts:
assert bridge.get_shared_content(artifact_id, ctx) is None, (
f"Vor Freigabe darf kein Inhalt für Kontext '{ctx}' geliefert werden"
)
# --- Phase 2: Freigabe (Share) ---
result = bridge.share_artifact(artifact_id, user_confirmed=True)
assert result.success is True, (
f"Freigabe hätte erfolgreich sein müssen, Fehler: {result.errors}"
)
# Invariante 1: Nach share_artifact → is_shared == True
assert bridge.is_shared(artifact_id) is True, (
"Nach Freigabe muss is_shared True zurückgeben"
)
# Invariante 1: get_shared_content liefert Inhalt für berechtigte Kontexte
for ctx in target_contexts:
content = bridge.get_shared_content(artifact_id, ctx)
assert content is not None, (
f"Nach Freigabe muss get_shared_content für Kontext '{ctx}' Inhalt liefern"
)
# Prüfe, dass der Inhalt mindestens eine der initial_content Zeilen enthält
assert any(line in content for line in initial_content), (
f"Der gelieferte Inhalt muss den initialen Inhalt enthalten. "
f"Erwartet mindestens eins von {initial_content}, erhalten: {content!r}"
)
# Quellkontext darf KEINEN Zugriff haben (nicht in shared_to)
source_content = bridge.get_shared_content(artifact_id, scope)
assert source_content is None, (
"Quellkontext darf keinen Lesezugriff über get_shared_content haben"
)
# --- Phase 3: Update der Quelldatei ---
updated_file_content = _build_artifact_content(
title, artifact_type, scope, tags, updated_content
)
artifact_file.write_text(updated_file_content, encoding="utf-8")
# Invariante 2: get_shared_content liefert den neuen Inhalt (Update-Propagierung)
for ctx in target_contexts:
content = bridge.get_shared_content(artifact_id, ctx)
assert content is not None, (
f"Nach Update muss get_shared_content für Kontext '{ctx}' Inhalt liefern"
)
assert any(line in content for line in updated_content), (
f"Nach Update muss der neue Inhalt geliefert werden. "
f"Erwartet mindestens eins von {updated_content}, erhalten: {content!r}"
)
# --- Phase 4: Widerruf (Revoke) ---
bridge.revoke_share(artifact_id)
# Invariante 3: Nach revoke → is_shared == False
assert bridge.is_shared(artifact_id) is False, (
"Nach Widerruf muss is_shared False zurückgeben"
)
# Invariante 3: get_shared_content liefert None
for ctx in target_contexts:
content = bridge.get_shared_content(artifact_id, ctx)
assert content is None, (
f"Nach Widerruf muss get_shared_content für Kontext '{ctx}' None liefern"
)
@given(
title=safe_titles,
scope=artifact_scopes,
artifact_type=artifact_types,
tags=safe_tags,
content=safe_content_lines,
)
@settings(
max_examples=50,
suppress_health_check=[HealthCheck.function_scoped_fixture],
deadline=None,
)
def test_share_makes_visible_in_all_target_contexts(
self,
tmp_path: Path,
title: str,
scope: str,
artifact_type: str,
tags: list[str],
content: list[str],
) -> None:
"""Nach Freigabe muss das Artefakt in ALLEN Zielkontexten als Lesereferenz sichtbar sein.
**Validates: Requirements 5.2**
"""
name_slug = title.lower().replace(" ", "-").replace("ü", "ue")
artifact_id = f"{scope}/{artifact_type}/{name_slug}"
bridge, _ = _create_bridge_with_artifact(
tmp_path, artifact_id, scope, artifact_type, title, tags, content
)
target_contexts = _get_target_contexts(scope)
bridge.share_artifact(artifact_id, user_confirmed=True)
# Artefakt muss in der Liste aller Zielkontexte auftauchen
for ctx in target_contexts:
shared_list = bridge.get_shared_artifacts(ctx)
assert artifact_id in shared_list, (
f"Artefakt '{artifact_id}' muss in get_shared_artifacts('{ctx}') enthalten sein"
)
# Artefakt darf NICHT im Quellkontext auftauchen
source_list = bridge.get_shared_artifacts(scope)
assert artifact_id not in source_list, (
f"Artefakt '{artifact_id}' darf NICHT in get_shared_artifacts('{scope}') enthalten sein"
)
@given(
title=safe_titles,
scope=artifact_scopes,
artifact_type=artifact_types,
tags=safe_tags,
content=safe_content_lines,
)
@settings(
max_examples=50,
suppress_health_check=[HealthCheck.function_scoped_fixture],
deadline=None,
)
def test_revoke_removes_from_all_contexts(
self,
tmp_path: Path,
title: str,
scope: str,
artifact_type: str,
tags: list[str],
content: list[str],
) -> None:
"""Nach Widerruf darf das Artefakt in KEINEM Kontext mehr sichtbar sein.
**Validates: Requirements 5.6**
"""
name_slug = title.lower().replace(" ", "-").replace("ü", "ue")
artifact_id = f"{scope}/{artifact_type}/{name_slug}"
bridge, _ = _create_bridge_with_artifact(
tmp_path, artifact_id, scope, artifact_type, title, tags, content
)
# Share then revoke
bridge.share_artifact(artifact_id, user_confirmed=True)
bridge.revoke_share(artifact_id)
# Must not appear in any context
for ctx in [c.value for c in Context]:
shared_list = bridge.get_shared_artifacts(ctx)
assert artifact_id not in shared_list, (
f"Nach Widerruf darf Artefakt '{artifact_id}' "
f"nicht in get_shared_artifacts('{ctx}') enthalten sein"
)
content_result = bridge.get_shared_content(artifact_id, ctx)
assert content_result is None, (
f"Nach Widerruf muss get_shared_content('{artifact_id}', '{ctx}') None liefern"
)
@@ -0,0 +1,273 @@
"""Property-Based Tests für den Sensitive-Content-Filter der ContextBridge.
**Validates: Requirements 5.1, 5.3**
Property 14: Sensitive-Content-Filter blockiert Freigabe
- Für jeden Text, der kontextspezifische Geheimnisse (API-Keys, Tokens,
Passwörter), Endpoint-URLs oder personenbezogene Daten (E-Mail-Adressen)
enthält, muss der Sensitive-Content-Filter mindestens einen Treffer
zurückgeben und die Freigabe verweigern.
- Für jeden Text ohne sensitive Muster muss die Liste leer sein.
- Zeilennummern in Treffern müssen korrekt sein.
"""
from __future__ import annotations
from pathlib import Path
from hypothesis import given, settings, assume, HealthCheck
from hypothesis import strategies as st
from monorepo.bridge import ContextBridge, SensitiveMatch
from monorepo.knowledge.index import YAMLIndex
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _make_bridge(tmp_path: Path) -> ContextBridge:
"""Erstellt eine minimale ContextBridge-Instanz für Tests."""
kb_path = tmp_path / "kb"
kb_path.mkdir(parents=True, exist_ok=True)
index = YAMLIndex(kb_path / "_index.yaml")
index.load()
return ContextBridge(knowledge_base_path=kb_path, index=index)
# ---------------------------------------------------------------------------
# Strategien
# ---------------------------------------------------------------------------
# Credential-Keywords (case-insensitiv erkannt)
credential_keywords = st.sampled_from([
"api_key", "API_KEY", "Api_Key",
"token", "Token", "TOKEN",
"password", "Password", "PASSWORD",
"secret", "Secret", "SECRET",
"api-key", "API-KEY",
])
# Separatoren zwischen Keyword und Wert
separators = st.sampled_from([":", "=", ": ", "= "])
# Zufällige Werte für Credentials (mindestens 5 Zeichen, alphanumerisch)
credential_values = st.text(
st.characters(whitelist_categories=("L", "N")),
min_size=5,
max_size=30,
)
# Strategie für eine Zeile mit Credential-Muster
credential_lines = st.builds(
lambda kw, sep, val: f"{kw}{sep}{val}",
credential_keywords,
separators,
credential_values,
)
# Strategie für Endpoint-URLs
endpoint_hosts = st.text(
st.sampled_from("abcdefghijklmnopqrstuvwxyz"),
min_size=3,
max_size=10,
)
endpoint_paths = st.text(
st.sampled_from("abcdefghijklmnopqrstuvwxyz"),
min_size=2,
max_size=8,
)
endpoint_lines = st.builds(
lambda host, path: f"endpoint: https://{host}.example.com/{path}",
endpoint_hosts,
endpoint_paths,
)
# Alternative: url= pattern
url_lines = st.builds(
lambda host, path: f"url= https://{host}.internal.dev/{path}",
endpoint_hosts,
endpoint_paths,
)
# Strategie für E-Mail-Adressen (PII)
# Regex erkennt: [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}
# Wir generieren daher nur ASCII-konforme E-Mail-Teile.
ascii_alnum = st.sampled_from(
"abcdefghijklmnopqrstuvwxyz" "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "0123456789"
)
email_users = st.text(ascii_alnum, min_size=3, max_size=12)
email_domains = st.text(
st.sampled_from("abcdefghijklmnopqrstuvwxyz"),
min_size=3,
max_size=10,
)
email_tlds = st.sampled_from(["de", "com", "org", "net", "io"])
email_lines = st.builds(
lambda user, domain, tld: f"{user}@{domain}.{tld}",
email_users,
email_domains,
email_tlds,
)
# Strategie für "sauberen" Text ohne sensitive Muster
safe_words = st.sampled_from([
"Architektur", "Design", "Implementierung", "Modul", "Klasse",
"Methode", "Test", "Dokumentation", "Refactoring", "Sprint",
"Analyse", "Bewertung", "Ergebnis", "Zusammenfassung", "Planung",
])
clean_text = st.lists(safe_words, min_size=3, max_size=10).map(" ".join)
# Umgebungstext (Zeilen ohne sensitive Inhalte)
filler_lines = st.lists(
st.sampled_from([
"# Überschrift",
"Hier ist ein normaler Satz.",
"## Abschnitt",
"Notizen zum Design.",
"- Punkt eins",
"- Punkt zwei",
"Weitere Details folgen.",
"",
]),
min_size=0,
max_size=5,
)
# ---------------------------------------------------------------------------
# Property 14: Sensitive-Content-Filter blockiert Freigabe
# ---------------------------------------------------------------------------
class TestSensitiveContentFilterProperties:
"""Property-Tests für den Sensitive-Content-Filter.
**Validates: Requirements 5.1, 5.3**
"""
@given(
prefix_lines=filler_lines,
credential_line=credential_lines,
suffix_lines=filler_lines,
)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_credentials_always_detected(
self, tmp_path: Path, prefix_lines: list[str], credential_line: str, suffix_lines: list[str]
) -> None:
"""Jeder Text mit Credential-Mustern muss mindestens einen Treffer liefern.
**Validates: Requirements 5.1, 5.3**
"""
bridge = _make_bridge(tmp_path)
content = "\n".join(prefix_lines + [credential_line] + suffix_lines)
matches = bridge.check_sensitive_content(content)
assert len(matches) >= 1, (
f"Credential-Muster nicht erkannt in: {credential_line!r}"
)
assert any(m.pattern_name == "credentials" for m in matches), (
f"Kein Match mit pattern_name='credentials' für: {credential_line!r}"
)
@given(
prefix_lines=filler_lines,
endpoint_line=st.one_of(endpoint_lines, url_lines),
suffix_lines=filler_lines,
)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_endpoints_always_detected(
self, tmp_path: Path, prefix_lines: list[str], endpoint_line: str, suffix_lines: list[str]
) -> None:
"""Jeder Text mit Endpoint-URL-Mustern muss mindestens einen Treffer liefern.
**Validates: Requirements 5.1, 5.3**
"""
bridge = _make_bridge(tmp_path)
content = "\n".join(prefix_lines + [endpoint_line] + suffix_lines)
matches = bridge.check_sensitive_content(content)
assert len(matches) >= 1, (
f"Endpoint-Muster nicht erkannt in: {endpoint_line!r}"
)
assert any(m.pattern_name == "endpoint" for m in matches), (
f"Kein Match mit pattern_name='endpoint' für: {endpoint_line!r}"
)
@given(
prefix_lines=filler_lines,
email_line=email_lines,
suffix_lines=filler_lines,
)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_email_pii_always_detected(
self, tmp_path: Path, prefix_lines: list[str], email_line: str, suffix_lines: list[str]
) -> None:
"""Jeder Text mit E-Mail-Adressen muss als PII erkannt werden.
**Validates: Requirements 5.1, 5.3**
"""
bridge = _make_bridge(tmp_path)
content = "\n".join(prefix_lines + [email_line] + suffix_lines)
matches = bridge.check_sensitive_content(content)
assert len(matches) >= 1, (
f"E-Mail-PII nicht erkannt in: {email_line!r}"
)
assert any(m.pattern_name == "pii" for m in matches), (
f"Kein Match mit pattern_name='pii' für: {email_line!r}"
)
@given(text=clean_text)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_clean_text_no_matches(self, tmp_path: Path, text: str) -> None:
"""Text ohne sensitive Muster darf keine Treffer liefern.
**Validates: Requirements 5.1, 5.3**
"""
bridge = _make_bridge(tmp_path)
matches = bridge.check_sensitive_content(text)
assert matches == [], (
f"Falscher Treffer in sauberem Text: {text!r} -> {matches}"
)
@given(
prefix_lines=filler_lines,
sensitive_line=st.one_of(credential_lines, endpoint_lines, email_lines),
suffix_lines=filler_lines,
)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_line_numbers_are_correct(
self, tmp_path: Path, prefix_lines: list[str], sensitive_line: str, suffix_lines: list[str]
) -> None:
"""Zeilennummern in Treffern müssen der tatsächlichen Position entsprechen.
**Validates: Requirements 5.1, 5.3**
"""
bridge = _make_bridge(tmp_path)
content = "\n".join(prefix_lines + [sensitive_line] + suffix_lines)
expected_line_number = len(prefix_lines) + 1 # 1-basiert
matches = bridge.check_sensitive_content(content)
assert len(matches) >= 1, (
f"Kein Treffer für: {sensitive_line!r}"
)
# Mindestens ein Treffer muss auf der erwarteten Zeile liegen
match_lines = {m.line_number for m in matches}
assert expected_line_number in match_lines, (
f"Erwartete Zeile {expected_line_number}, aber Treffer auf Zeilen: {match_lines}. "
f"Content:\n{content}"
)
@@ -0,0 +1,834 @@
"""End-to-End Integration-Tests für den gesamten Komponentenstack.
Testet die folgenden Workflows über mehrere Komponenten hinweg:
1. Projekt erstellen → Env verschlüsseln → Env laden → Secret-Zugriff prüfen
2. Artefakt erstellen → Indexieren → Suchen → Freigeben → Widerrufen
3. Repo einbinden → Sync → Read-Only-Schutz
4. Migration → Validierung → Rollback
5. Secret verschlüsseln → Maschinenkontext wechseln → Entschlüsselung fehlschlägt
6. Team_Repo vorbereiten → Isolation prüfen → Sync → Konflikt-Auflösung
7. Shared-Mirror in Team_Repo → Read-Only-Prüfung
Requirements: 1.1, 2.1, 3.2, 4.1, 5.2, 6.1, 9.1, 9.4, 10.3, 10.5, 10.12
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import yaml
from monorepo.bridge import ContextBridge
from monorepo.encryption import DecryptionResult, EncryptionResult, SecretEncryptionManager
from monorepo.federation import FederationManager
from monorepo.knowledge.etl import ETLPipeline
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource
from monorepo.knowledge.store import KnowledgeStore
from monorepo.migration import MigrationEngine
from monorepo.models import (
ConflictInfo,
MachineContext,
MigrationPlan,
RepoEntry,
ScopeConfig,
SyncResult,
)
from monorepo.repos import RepoManager
from monorepo.security import ContextGuard
from monorepo.structure import StructureManager
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def monorepo_root(tmp_path: Path) -> Path:
"""Creates a full monorepo structure for E2E integration tests."""
root = tmp_path / "monorepo"
root.mkdir()
# Context directories
for ctx in ("privat", "dhive", "bahn", "shared"):
(root / ctx).mkdir()
# Shared subdirectories
for sub in ("tools", "powers", "knowledge-store", "config", "mcp-servers"):
(root / "shared" / sub).mkdir(parents=True, exist_ok=True)
# .env files per context
(root / "privat" / ".env").write_text(
"PRIVAT_SECRET=privat_val_123\nPRIVAT_API=http://localhost:3000\n",
encoding="utf-8",
)
(root / "dhive" / ".env").write_text(
"DHIVE_SECRET=dhive_val_456\nDHIVE_TOKEN=tok_dhive\n",
encoding="utf-8",
)
(root / "bahn" / ".env").write_text(
"BAHN_SECRET=bahn_val_789\nBAHN_ENDPOINT=https://bahn.api\n",
encoding="utf-8",
)
(root / "shared" / ".env").write_text(
"SHARED_KEY=shared_val\n", encoding="utf-8"
)
# access-config.yaml
config_dir = root / "shared" / "config"
access_config = {
"contexts": {
"privat": {
"env_file": "privat/.env",
"allowed_shared": ["shared/tools/", "shared/config/"],
},
"dhive": {
"env_file": "dhive/.env",
"allowed_shared": ["shared/tools/", "shared/config/"],
},
"bahn": {
"env_file": "bahn/.env",
"allowed_shared": [
"shared/tools/",
"shared/config/",
"shared/knowledge-store/",
],
},
"shared": {
"env_file": "shared/.env",
"allowed_shared": ["*"],
},
}
}
(config_dir / "access-config.yaml").write_text(
yaml.dump(access_config), encoding="utf-8"
)
# machine-context.yaml (full access)
machine_config = {
"machine": {
"name": "test-hauptrechner",
"description": "Test machine with full access",
"authorized_contexts": ["privat", "dhive", "bahn"],
"key_source": "keyring",
}
}
(config_dir / "machine-context.yaml").write_text(
yaml.dump(machine_config), encoding="utf-8"
)
# team-repos.yaml
team_repos_config = {
"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": "manual",
"shared_mirror": {
"enabled": True,
"paths": ["shared/tools/common-scripts/"],
"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/"],
"mode": "read-only",
},
},
{
"context": "bahn",
"url": "https://gitlab.2700.2db.it/team/bahn-workspace.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "daily",
"shared_mirror": {
"enabled": True,
"paths": ["shared/tools/"],
"mode": "read-only",
},
},
],
}
(config_dir / "team-repos.yaml").write_text(
yaml.dump(team_repos_config), encoding="utf-8"
)
# repos.yaml (empty registry)
repos_config = {"repos": []}
(config_dir / "repos.yaml").write_text(
yaml.dump(repos_config), encoding="utf-8"
)
# Knowledge store index
ks_path = root / "shared" / "knowledge-store"
(ks_path / "_index.yaml").write_text(
yaml.dump({"version": "1.0", "last_updated": "", "artifacts": []}),
encoding="utf-8",
)
return root
@pytest.fixture()
def full_machine_context() -> MachineContext:
"""MachineContext authorized for all contexts."""
return MachineContext(
name="test-hauptrechner",
description="Full access",
authorized_contexts=["privat", "dhive", "bahn"],
key_source="keyring",
)
@pytest.fixture()
def limited_machine_context() -> MachineContext:
"""MachineContext authorized only for dhive (simulates dhive-laptop)."""
return MachineContext(
name="dhive-laptop",
description="Only dhive access",
authorized_contexts=["dhive"],
key_source="keyring",
)
@pytest.fixture()
def scope_config() -> ScopeConfig:
"""ScopeConfig for knowledge store."""
return ScopeConfig(
scopes={
"privat": {"scope": "privat", "paths": ["privat/"]},
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
"shared": {"scope": "shared", "paths": ["shared/"]},
}
)
# ---------------------------------------------------------------------------
# Test 1: Projekt erstellen → Env verschlüsseln → Env laden → Secret-Zugriff
# Requirements: 1.1, 2.1, 9.1, 9.4
# ---------------------------------------------------------------------------
class TestProjectEncryptionSecretAccess:
"""E2E: Project creation → Encrypt env → Load env → Cross-context denial."""
def test_create_project_encrypt_env_load_and_deny_cross_context(
self, monorepo_root: Path, full_machine_context: MachineContext
) -> None:
"""Full flow: create project, encrypt its env, load it, deny cross-access."""
# Step 1: Create a project in privat context
structure_mgr = StructureManager(monorepo_root)
project_path = structure_mgr.create_project("privat", "my-new-app")
assert project_path.exists()
assert project_path == monorepo_root / "privat" / "my-new-app"
# Step 2: Create a project-level .env for the context
env_content = "MY_APP_SECRET=super-secret-123\nDB_URL=postgres://localhost/mydb\n"
env_file = monorepo_root / "privat" / ".env"
env_file.write_text(env_content, encoding="utf-8")
# Step 3: Create encryption manager and encrypt the file
encryption_mgr = SecretEncryptionManager(monorepo_root, full_machine_context)
result = encryption_mgr.encrypt_file(env_file, "privat")
# Encryption may or may not succeed (git-crypt might not be installed)
# but the flow should be exercised regardless
# Step 4: Load env via ContextGuard (with encryption integration)
guard = ContextGuard(
root_path=monorepo_root,
encryption_manager=encryption_mgr,
)
# Loading env for own context should succeed
env_vars = guard.load_env("privat")
assert "MY_APP_SECRET" in env_vars
assert env_vars["MY_APP_SECRET"] == "super-secret-123"
# Step 5: Cross-context access should be denied
access_allowed = guard.check_access("dhive", monorepo_root / "privat" / ".env")
assert access_allowed is False
# dhive should not be able to access privat's .env content via its own context
access_allowed_bahn = guard.check_access("bahn", monorepo_root / "privat" / ".env")
assert access_allowed_bahn is False
def test_shared_tools_accessible_from_any_context(
self, monorepo_root: Path, full_machine_context: MachineContext
) -> None:
"""Shared tools are accessible from all contexts."""
encryption_mgr = SecretEncryptionManager(monorepo_root, full_machine_context)
guard = ContextGuard(root_path=monorepo_root, encryption_manager=encryption_mgr)
# Create a shared tool file
tool_file = monorepo_root / "shared" / "tools" / "helper.py"
tool_file.write_text("# shared helper", encoding="utf-8")
# All contexts can access shared tools
for ctx in ("privat", "dhive", "bahn"):
assert guard.check_access(ctx, tool_file) is True
# ---------------------------------------------------------------------------
# Test 2: Artefakt erstellen → Indexieren → Suchen → Freigeben → Widerrufen
# Requirements: 3.2, 5.2
# ---------------------------------------------------------------------------
class TestArtifactIngestSearchShareRevoke:
"""E2E: Create artifact → Ingest via ETL → Search → Share → Revoke."""
def test_full_knowledge_lifecycle(
self, monorepo_root: Path, scope_config: ScopeConfig
) -> None:
"""Ingest markdown, search it, share it, then revoke the share."""
ks_path = monorepo_root / "shared" / "knowledge-store"
# Step 1: Create a markdown artifact in bahn context source
source_dir = monorepo_root / "bahn" / "docs"
source_dir.mkdir(parents=True)
artifact_md = source_dir / "api-patterns.md"
artifact_md.write_text(
"---\n"
"type: decision\n"
"title: REST API Patterns für InfraGO\n"
"tags: [api, rest, patterns]\n"
"---\n\n"
"# REST API Patterns\n\n"
"Wir verwenden RESTful API-Konventionen für alle neuen Services.\n",
encoding="utf-8",
)
# Step 2: Ingest via KnowledgeStore
store = KnowledgeStore(ks_path, scope_config)
source = MarkdownSource(directory=source_dir)
ingest_result = store.ingest(source, "bahn")
assert ingest_result.processed >= 1
assert ingest_result.updated >= 1
# Step 3: Search the index
results = store.search("REST API", allowed_scopes=["bahn", "shared"])
assert len(results) >= 1
found_titles = [r.entry.title for r in results]
assert any("REST API" in t or "api" in t.lower() for t in found_titles)
# Step 4: Verify scope filtering - privat should not see bahn artifacts
privat_results = store.search("REST API", allowed_scopes=["privat"])
bahn_artifact_in_privat = [
r for r in privat_results if r.entry.scope == "bahn"
]
assert len(bahn_artifact_in_privat) == 0
# Step 5: Share the artifact via ContextBridge
bridge = ContextBridge(
knowledge_base_path=ks_path,
index=store.index,
)
# Get the artifact ID from the index
index_entries = store.get_index(scope="bahn")
assert len(index_entries) >= 1
artifact_id = index_entries[0].id
# Share with user confirmation
share_result = bridge.share_artifact(artifact_id, user_confirmed=True)
assert share_result.success is True
assert bridge.is_shared(artifact_id) is True
# Other contexts can now access the shared content
content = bridge.get_shared_content(artifact_id, "privat")
assert content is not None
assert "REST" in content or "api" in content.lower()
# Step 6: Revoke the share
bridge.revoke_share(artifact_id)
assert bridge.is_shared(artifact_id) is False
# After revocation, other contexts cannot access
content_after = bridge.get_shared_content(artifact_id, "privat")
assert content_after is None
def test_share_blocked_for_sensitive_content(
self, monorepo_root: Path, scope_config: ScopeConfig
) -> None:
"""Sensitive artifacts cannot be shared across contexts."""
ks_path = monorepo_root / "shared" / "knowledge-store"
# Create artifact with sensitive content
source_dir = monorepo_root / "dhive" / "secrets-docs"
source_dir.mkdir(parents=True)
sensitive_md = source_dir / "credentials.md"
sensitive_md.write_text(
"---\n"
"type: note\n"
"title: API Credentials\n"
"tags: [credentials]\n"
"---\n\n"
"# Credentials\n\n"
"api_key: sk-12345-abcdef\n",
encoding="utf-8",
)
# Ingest the sensitive artifact
store = KnowledgeStore(ks_path, scope_config)
source = MarkdownSource(directory=source_dir)
store.ingest(source, "dhive")
# Try to share - should be blocked
bridge = ContextBridge(knowledge_base_path=ks_path, index=store.index)
index_entries = store.get_index(scope="dhive")
assert len(index_entries) >= 1
artifact_id = index_entries[0].id
share_result = bridge.share_artifact(artifact_id, user_confirmed=True)
assert share_result.success is False
assert any("sensib" in e.lower() or "sensitive" in e.lower() for e in share_result.errors)
# ---------------------------------------------------------------------------
# Test 3: Repo einbinden → Sync → Read-Only-Schutz
# Requirements: 4.1
# ---------------------------------------------------------------------------
class TestRepoAddSyncReadonly:
"""E2E: Add repo → Sync → Protect read-only."""
def test_add_repo_sync_and_readonly_protection(
self, monorepo_root: Path
) -> None:
"""Add an external repo, sync it, and verify read-only protection."""
config_path = monorepo_root / "shared" / "config" / "repos.yaml"
# Initialize a git repo in monorepo_root for hook installation
git_dir = monorepo_root / ".git"
git_dir.mkdir()
hooks_dir = git_dir / "hooks"
hooks_dir.mkdir()
repo_mgr = RepoManager(config_path=config_path, monorepo_root=monorepo_root)
# Step 1: Add a read-only repo entry (mock git operations)
entry = RepoEntry(
name="symphony-spec",
url="https://github.com/openai/symphony",
mode="read-only",
target="shared/references/symphony",
pinned="v1.0.0",
mechanism="subtree",
)
# Mock git subprocess to avoid network calls
with patch("monorepo.repos.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0, stdout="", stderr=""
)
repo_mgr.add_repo(entry)
# Verify repo was registered
assert repo_mgr._find_repo("symphony-spec") is not None
# Step 2: Sync the repo (mocked)
# Create the target directory to simulate existing subtree
target_path = monorepo_root / "shared" / "references" / "symphony"
target_path.mkdir(parents=True)
(target_path / "README.md").write_text("# Symphony", encoding="utf-8")
with patch("monorepo.repos.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0, stdout="1 commit pulled", stderr=""
)
sync_result = repo_mgr.sync("symphony-spec")
# Sync should complete (success or graceful failure)
assert sync_result is not None
# Step 3: Protect read-only
repo_mgr.protect_readonly(target_path)
# Verify pre-commit hook was created/updated
hook_path = hooks_dir / "pre-commit"
assert hook_path.exists()
hook_content = hook_path.read_text(encoding="utf-8")
# The hook should reference the protected path
assert "symphony" in hook_content or "references" in hook_content
# ---------------------------------------------------------------------------
# Test 4: Migration → Validierung → Rollback
# Requirements: 6.1
# ---------------------------------------------------------------------------
class TestMigrationValidateRollback:
"""E2E: Migrate repo → Validate → Rollback."""
def test_migrate_validate_and_rollback(self, monorepo_root: Path) -> None:
"""Migrate a repo, validate it, then rollback."""
# Initialize git in monorepo_root
git_dir = monorepo_root / ".git"
git_dir.mkdir(exist_ok=True)
engine = MigrationEngine(monorepo_root=monorepo_root)
plan = MigrationPlan(
source_repo="https://github.com/test/old-project.git",
target_context="dhive",
target_name="old-project",
mode="subtree",
dependencies=[],
order=1,
)
# Step 1: Execute migration (mock git calls)
with patch.object(engine, "_run_git") as mock_git:
mock_git.return_value = MagicMock(
returncode=0, stdout="", stderr=""
)
# Also mock specific detection methods
with patch.object(engine, "_get_remote_branches", return_value=["main"]):
with patch.object(engine, "_get_local_branches", return_value=["main"]):
result = engine.migrate(plan)
# Migration should succeed or detect conflicts gracefully
assert result is not None
# Step 2: Validate the migration
if result.success:
# Create target path to simulate successful migration
target = monorepo_root / "dhive" / "old-project"
target.mkdir(parents=True, exist_ok=True)
(target / "README.md").write_text("# Old Project", encoding="utf-8")
# Mock git validation commands
with patch.object(engine, "_run_git") as mock_git:
mock_git.return_value = MagicMock(
returncode=0, stdout="5", stderr=""
)
validation = engine.validate("old-project")
assert validation is not None
# Step 3: Rollback
with patch.object(engine, "_run_git") as mock_git:
mock_git.return_value = MagicMock(
returncode=0, stdout="", stderr=""
)
engine.rollback("old-project")
# After rollback, the project should no longer be in registry
assert engine.is_migrated("old-project") is False
# ---------------------------------------------------------------------------
# Test 5: Secret verschlüsseln → Maschinenkontext wechseln → Entschlüsselung
# fehlschlägt
# Requirements: 9.1, 9.4
# ---------------------------------------------------------------------------
class TestSecretEncryptionMachineContextSwitch:
"""E2E: Encrypt secret → Switch machine context → Decryption fails."""
def test_encrypt_then_switch_context_decrypt_fails(
self,
monorepo_root: Path,
full_machine_context: MachineContext,
limited_machine_context: MachineContext,
) -> None:
"""Encrypt with full access, then try to decrypt with limited access."""
# Step 1: Encrypt a secret file with full machine context
full_mgr = SecretEncryptionManager(monorepo_root, full_machine_context)
secret_file = monorepo_root / "privat" / ".env"
encrypt_result = full_mgr.encrypt_file(secret_file, "privat")
# The encryption manager should at least attempt to encrypt
# (git-crypt may not be available but the flow is tested)
# Step 2: Verify full context can still decrypt
assert full_mgr.is_authorized("privat") is True
assert full_mgr.is_authorized("dhive") is True
assert full_mgr.is_authorized("bahn") is True
# Step 3: Switch to limited machine context (only dhive)
limited_mgr = SecretEncryptionManager(monorepo_root, limited_machine_context)
# Step 4: Limited context should NOT be authorized for privat
assert limited_mgr.is_authorized("privat") is False
assert limited_mgr.is_authorized("bahn") is False
assert limited_mgr.is_authorized("dhive") is True
# Step 5: Decryption attempt for privat should fail/be denied
decrypt_result = limited_mgr.decrypt_file(secret_file)
# Either returns failure or raises PermissionError
if decrypt_result is not None:
assert decrypt_result.success is False
def test_context_guard_denies_load_env_on_unauthorized_machine(
self,
monorepo_root: Path,
limited_machine_context: MachineContext,
) -> None:
"""ContextGuard denies env loading for unauthorized context on limited machine."""
limited_mgr = SecretEncryptionManager(monorepo_root, limited_machine_context)
guard = ContextGuard(root_path=monorepo_root, encryption_manager=limited_mgr)
# dhive env should load fine (authorized)
env = guard.load_env("dhive")
assert "DHIVE_SECRET" in env
# privat env should fail (not authorized on this machine)
with pytest.raises(PermissionError):
guard.load_env("privat")
# ---------------------------------------------------------------------------
# Test 6: Team_Repo vorbereiten → Isolation prüfen → Sync → Konflikt-Auflösung
# Requirements: 10.3, 10.5
# ---------------------------------------------------------------------------
class TestFederationPrepareIsolateSyncConflict:
"""E2E: Prepare team repo → Verify isolation → Sync → Resolve conflicts."""
def test_prepare_team_repo_verify_isolation(
self, monorepo_root: Path, full_machine_context: MachineContext
) -> None:
"""Prepare team repo and verify it contains only its own context."""
encryption_mgr = SecretEncryptionManager(monorepo_root, full_machine_context)
config_path = monorepo_root / "shared" / "config" / "team-repos.yaml"
fed_mgr = FederationManager(
config_path=config_path,
monorepo_root=monorepo_root,
encryption_manager=encryption_mgr,
)
# Create some content in privat context
(monorepo_root / "privat" / "my-project").mkdir(parents=True)
(monorepo_root / "privat" / "my-project" / "main.py").write_text(
"# My private project\nprint('hello')\n", encoding="utf-8"
)
# Create content in other contexts (should NOT appear in privat team repo)
(monorepo_root / "dhive" / "dhive-project").mkdir(parents=True)
(monorepo_root / "dhive" / "dhive-project" / "app.py").write_text(
"# dhive project", encoding="utf-8"
)
# Step 1: Prepare the team repo for privat
team_repo_path = fed_mgr.prepare_team_repo("privat")
assert team_repo_path.exists()
# Step 2: Verify isolation - no references to other contexts
isolation_report = fed_mgr.verify_isolation(team_repo_path, "privat")
assert isolation_report.is_isolated is True
assert len(isolation_report.leaks) == 0
# Verify privat content is present
privat_files = list(team_repo_path.rglob("*.py"))
assert len(privat_files) >= 1
# Verify dhive/bahn content is NOT present
dhive_files = list(team_repo_path.rglob("*dhive*"))
# Filter out any path references in filenames only
for f in dhive_files:
assert "dhive-project" not in str(f) or not f.exists()
def test_sync_and_conflict_resolution(
self, monorepo_root: Path, full_machine_context: MachineContext
) -> None:
"""Sync from team repo and resolve conflicts with team-wins strategy."""
encryption_mgr = SecretEncryptionManager(monorepo_root, full_machine_context)
config_path = monorepo_root / "shared" / "config" / "team-repos.yaml"
fed_mgr = FederationManager(
config_path=config_path,
monorepo_root=monorepo_root,
encryption_manager=encryption_mgr,
)
# Mock the git subtree operations (no actual network calls)
with patch.object(
fed_mgr.sync_engine, "subtree_pull"
) as mock_pull:
mock_pull.return_value = SyncResult(
success=True,
context="privat",
direction="pull",
commits_synced=3,
conflicts=[],
)
result = fed_mgr.sync_from_team("privat")
assert result.success is True
assert result.commits_synced == 3
# Test conflict resolution with team-wins strategy
with patch.object(
fed_mgr.sync_engine, "subtree_pull"
) as mock_pull:
mock_pull.return_value = SyncResult(
success=False,
context="dhive",
direction="pull",
commits_synced=0,
conflicts=[
ConflictInfo(
file_path="dhive/config.yaml",
conflict_type="content",
source="team-repo",
details="Conflicting changes in config.yaml",
)
],
)
conflict_result = fed_mgr.sync_from_team("dhive")
assert conflict_result.success is False
assert len(conflict_result.conflicts) >= 1
# Resolve conflict with team-wins strategy (mock git operations)
with patch.object(fed_mgr.sync_engine, "_run_git") as mock_git:
# First call: git diff to find conflicted files
mock_git.return_value = MagicMock(
stdout="dhive/config.yaml\n", stderr="", returncode=0
)
resolved = fed_mgr.resolve_conflict("dhive", strategy="team-wins")
assert resolved is not None
assert resolved.context == "dhive"
assert resolved.strategy == "team-wins"
# ---------------------------------------------------------------------------
# Test 7: Shared-Mirror in Team_Repo → Read-Only-Prüfung
# Requirements: 10.12
# ---------------------------------------------------------------------------
class TestSharedMirrorReadOnly:
"""E2E: Mirror shared files into team repo → Verify read-only."""
def test_mirror_shared_to_team_repo_readonly(
self, monorepo_root: Path, full_machine_context: MachineContext
) -> None:
"""Shared files mirrored to team repo should be read-only."""
encryption_mgr = SecretEncryptionManager(monorepo_root, full_machine_context)
config_path = monorepo_root / "shared" / "config" / "team-repos.yaml"
fed_mgr = FederationManager(
config_path=config_path,
monorepo_root=monorepo_root,
encryption_manager=encryption_mgr,
)
# Create shared content to be mirrored
shared_tools = monorepo_root / "shared" / "tools" / "common-scripts"
shared_tools.mkdir(parents=True)
(shared_tools / "deploy.sh").write_text(
"#!/bin/bash\necho 'deploying...'\n", encoding="utf-8"
)
(shared_tools / "lint.sh").write_text(
"#!/bin/bash\necho 'linting...'\n", encoding="utf-8"
)
# Step 1: Mirror shared files to privat team repo
mirror_result = fed_mgr.mirror_shared(
"privat", ["shared/tools/common-scripts/"]
)
assert mirror_result is not None
assert mirror_result.success is True
# Step 2: Verify mirrored paths are recorded
assert len(mirror_result.mirrored_paths) >= 1
# Step 3: Verify context is correct
assert mirror_result.context == "privat"
def test_mirror_shared_with_multiple_paths(
self, monorepo_root: Path, full_machine_context: MachineContext
) -> None:
"""Multiple shared paths can be mirrored to team repo."""
encryption_mgr = SecretEncryptionManager(monorepo_root, full_machine_context)
config_path = monorepo_root / "shared" / "config" / "team-repos.yaml"
fed_mgr = FederationManager(
config_path=config_path,
monorepo_root=monorepo_root,
encryption_manager=encryption_mgr,
)
# Create multiple shared resources
(monorepo_root / "shared" / "tools" / "script-a.py").write_text(
"# script A", encoding="utf-8"
)
(monorepo_root / "shared" / "tools" / "script-b.py").write_text(
"# script B", encoding="utf-8"
)
mirror_result = fed_mgr.mirror_shared("dhive", ["shared/tools/"])
assert mirror_result is not None
assert mirror_result.success is True
assert len(mirror_result.mirrored_paths) >= 1
# ---------------------------------------------------------------------------
# Test: Integration Stack Factory (create_integrated_stack)
# Requirements: 2.2, 9.3, 9.4, 10.10
# ---------------------------------------------------------------------------
class TestIntegratedStackFactory:
"""E2E: Verify create_integrated_stack wires all components correctly."""
def test_create_integrated_stack_full(self, monorepo_root: Path) -> None:
"""Full stack creation with all components wired."""
from monorepo.integration import create_integrated_stack
stack = create_integrated_stack(monorepo_root)
# All components should be created
assert stack["context_guard"] is not None
assert stack["audit_logger"] is not None
assert stack["orchestrator_adapter"] is not None
assert stack["encryption_manager"] is not None
assert stack["federation_manager"] is not None
assert stack["machine_context_manager"] is not None
# Verify the ContextGuard has encryption wired
guard = stack["context_guard"]
assert guard.encryption_manager is not None
# Verify the guard can load env with encryption
env = guard.load_env("privat")
assert "PRIVAT_SECRET" in env
def test_create_integrated_stack_cross_context_denied(
self, monorepo_root: Path
) -> None:
"""Integrated stack correctly denies cross-context access."""
from monorepo.integration import create_integrated_stack
stack = create_integrated_stack(monorepo_root)
guard = stack["context_guard"]
# Cross-context access should be denied
assert guard.check_access("privat", monorepo_root / "dhive" / ".env") is False
assert guard.check_access("dhive", monorepo_root / "bahn" / ".env") is False
assert guard.check_access("bahn", monorepo_root / "privat" / ".env") is False
# Same-context access should be allowed
assert guard.check_access("privat", monorepo_root / "privat" / ".env") is True
assert guard.check_access("dhive", monorepo_root / "dhive" / ".env") is True
@@ -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
@@ -0,0 +1,332 @@
"""Property-basierte Tests für SecretEncryptionManager: Maschinenkontext-Beschränkung.
**Validates: Requirements 9.4, 9.5, 9.8, 9.10**
Property 24: Maschinenkontext beschränkt Entschlüsselung auf autorisierte Kontexte
For any Maschinenkontext-Konfiguration mit einer definierten Menge autorisierter
Arbeitskontexte, muss gelten:
(a) Secrets der autorisierten Kontexte sind entschlüsselbar,
(b) Secrets aller nicht-autorisierten Kontexte verbleiben als verschlüsselte
Binärdaten im Working Tree,
(c) die Fehlermeldung bei fehlgeschlagener Entschlüsselung gibt keinen Hinweis
auf den Dateiinhalt,
(d) das Repository bleibt vollständig funktionsfähig (Code, Konfiguration,
Dokumentation zugänglich).
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from unittest.mock import patch
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.encryption import SecretEncryptionManager
from monorepo.models import MachineContext, PasswordManagerConfig
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
ALL_WORK_CONTEXTS = ["privat", "dhive", "bahn"]
SECRET_FILE_NAMES = [".env", "cert.pem", "private.key", "api-token.txt", "db-secret.yaml"]
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
@st.composite
def machine_context_with_subset(draw: st.DrawFn) -> tuple[MachineContext, list[str], list[str]]:
"""Generates a MachineContext with a random non-empty subset of authorized contexts.
Returns:
Tuple of (MachineContext, authorized_contexts, unauthorized_contexts)
"""
# Draw a non-empty subset of contexts as authorized
authorized = draw(
st.lists(
st.sampled_from(ALL_WORK_CONTEXTS),
min_size=1,
max_size=len(ALL_WORK_CONTEXTS),
unique=True,
)
)
unauthorized = [c for c in ALL_WORK_CONTEXTS if c not in authorized]
machine_name = draw(st.sampled_from([
"andre-hauptrechner", "dhive-laptop", "bahn-workstation", "test-machine"
]))
ctx = MachineContext(
name=machine_name,
description=f"Test-Maschine mit Zugriff auf: {', '.join(authorized)}",
authorized_contexts=authorized,
key_source="keyring",
)
return ctx, authorized, unauthorized
@st.composite
def machine_context_strict_subset(draw: st.DrawFn) -> tuple[MachineContext, list[str], list[str]]:
"""Generates a MachineContext where authorized is a STRICT subset (not all contexts).
Ensures there is at least one unauthorized context.
Returns:
Tuple of (MachineContext, authorized_contexts, unauthorized_contexts)
"""
# Draw a strict subset (1 or 2 of 3 contexts)
authorized = draw(
st.lists(
st.sampled_from(ALL_WORK_CONTEXTS),
min_size=1,
max_size=len(ALL_WORK_CONTEXTS) - 1,
unique=True,
)
)
unauthorized = [c for c in ALL_WORK_CONTEXTS if c not in authorized]
assume(len(unauthorized) >= 1)
machine_name = draw(st.sampled_from([
"eingeschraenkt-rechner", "dhive-only", "bahn-only", "privat-dhive"
]))
ctx = MachineContext(
name=machine_name,
description=f"Eingeschränkt: nur {', '.join(authorized)}",
authorized_contexts=authorized,
key_source="keyring",
)
return ctx, authorized, unauthorized
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_monorepo_with_secrets(
authorized_contexts: list[str],
) -> tuple[Path, dict[str, Path]]:
"""Creates a temporary monorepo structure with secret files in each context.
Returns:
Tuple of (root_path, dict mapping context -> secret_file_path)
"""
tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_enc_test_"))
# Create context directories and secret files
secret_files: dict[str, Path] = {}
for ctx in ALL_WORK_CONTEXTS:
ctx_dir = tmp_dir / ctx
ctx_dir.mkdir(parents=True, exist_ok=True)
# Create a .env secret file with recognizable content
env_file = ctx_dir / ".env"
env_file.write_bytes(f"SECRET_{ctx.upper()}=super-secret-value-{ctx}\n".encode())
secret_files[ctx] = env_file
# Create shared directory (non-secret, always accessible)
shared_dir = tmp_dir / "shared"
shared_dir.mkdir(parents=True, exist_ok=True)
(shared_dir / "config").mkdir(parents=True, exist_ok=True)
(shared_dir / "tools").mkdir(parents=True, exist_ok=True)
# Create .git/git-crypt/keys structure for authorized contexts
keys_dir = tmp_dir / ".git" / "git-crypt" / "keys"
keys_dir.mkdir(parents=True, exist_ok=True)
(keys_dir / "default").mkdir(exist_ok=True)
for ctx in authorized_contexts:
(keys_dir / ctx).mkdir(exist_ok=True)
return tmp_dir, secret_files
# ---------------------------------------------------------------------------
# Property Tests
# ---------------------------------------------------------------------------
class TestProperty24MachineContextRestriction:
"""Property 24: Maschinenkontext beschränkt Entschlüsselung auf autorisierte Kontexte.
**Validates: Requirements 9.4, 9.5, 9.8, 9.10**
"""
@given(data=st.data())
@settings(max_examples=100)
def test_decryption_succeeds_for_authorized_contexts(
self, data: st.DataObject
) -> None:
"""(a) Secrets der autorisierten Kontexte sind entschlüsselbar.
For any machine context with authorized contexts, decryption of
secret files in those contexts must succeed.
"""
machine_ctx, authorized, _ = data.draw(machine_context_with_subset())
target_ctx = data.draw(st.sampled_from(authorized))
root_path, secret_files = _create_monorepo_with_secrets(authorized)
manager = SecretEncryptionManager(root_path, machine_ctx)
# Verify authorization check passes
assert manager.is_authorized(target_ctx) is True
# Verify decryption attempt does not fail due to authorization
# (We mock _run_gitcrypt since git-crypt binary isn't available in tests)
with patch.object(manager, "_run_gitcrypt"):
result = manager.decrypt_file(secret_files[target_ctx])
# Should succeed (file exists and context is authorized)
assert result.success is True, (
f"Decryption should succeed for authorized context '{target_ctx}' "
f"on machine '{machine_ctx.name}' with authorized={authorized}. "
f"Error: {result.error}"
)
@given(data=st.data())
@settings(max_examples=100)
def test_decryption_denied_for_unauthorized_contexts(
self, data: st.DataObject
) -> None:
"""(b) Secrets aller nicht-autorisierten Kontexte verbleiben als
verschlüsselte Binärdaten im Working Tree (inaccessible).
For any machine context with a strict subset of authorized contexts,
decryption of secrets from unauthorized contexts must fail.
"""
machine_ctx, authorized, unauthorized = data.draw(machine_context_strict_subset())
target_ctx = data.draw(st.sampled_from(unauthorized))
root_path, secret_files = _create_monorepo_with_secrets(authorized)
manager = SecretEncryptionManager(root_path, machine_ctx)
# Verify authorization check fails
assert manager.is_authorized(target_ctx) is False
# Attempt decryption - must fail without revealing content
result = manager.decrypt_file(secret_files[target_ctx])
assert result.success is False, (
f"Decryption should be DENIED for unauthorized context '{target_ctx}' "
f"on machine '{machine_ctx.name}' with authorized={authorized}"
)
# Content must NOT be returned
assert result.content is None, (
f"Content MUST be None for unauthorized context '{target_ctx}'. "
f"No content should be revealed for unauthorized decryption attempts."
)
@given(data=st.data())
@settings(max_examples=100)
def test_error_message_does_not_reveal_content(
self, data: st.DataObject
) -> None:
"""(c) Die Fehlermeldung bei fehlgeschlagener Entschlüsselung gibt
keinen Hinweis auf den Dateiinhalt.
For any failed decryption due to unauthorized machine context,
the error message must not contain any portion of the file content.
"""
machine_ctx, authorized, unauthorized = data.draw(machine_context_strict_subset())
target_ctx = data.draw(st.sampled_from(unauthorized))
root_path, secret_files = _create_monorepo_with_secrets(authorized)
manager = SecretEncryptionManager(root_path, machine_ctx)
# Read the actual secret content to check it's not in the error
secret_content = secret_files[target_ctx].read_text(encoding="utf-8")
secret_lines = [line.strip() for line in secret_content.splitlines() if line.strip()]
result = manager.decrypt_file(secret_files[target_ctx])
assert result.success is False
# Error message must not contain any secret content
error_msg = result.error or ""
for secret_line in secret_lines:
# Check the value part (after =) is not in the error
if "=" in secret_line:
secret_value = secret_line.split("=", 1)[1]
assert secret_value not in error_msg, (
f"Error message reveals secret content! "
f"Found '{secret_value}' in error: '{error_msg}'"
)
# Also check the full line is not in the error
assert secret_line not in error_msg, (
f"Error message reveals secret content! "
f"Found '{secret_line}' in error: '{error_msg}'"
)
@given(data=st.data())
@settings(max_examples=100)
def test_get_context_key_returns_none_for_unauthorized(
self, data: st.DataObject
) -> None:
"""get_context_key() returns None for unauthorized contexts.
This ensures that the key retrieval mechanism respects the machine
context boundaries and doesn't provide keys for unauthorized contexts.
"""
machine_ctx, authorized, unauthorized = data.draw(machine_context_strict_subset())
target_ctx = data.draw(st.sampled_from(unauthorized))
root_path, _ = _create_monorepo_with_secrets(authorized)
manager = SecretEncryptionManager(root_path, machine_ctx)
key = manager.get_context_key(target_ctx)
assert key is None, (
f"get_context_key() must return None for unauthorized context '{target_ctx}' "
f"on machine '{machine_ctx.name}' with authorized={authorized}"
)
@given(data=st.data())
@settings(max_examples=100)
def test_onboard_machine_only_installs_valid_context_keys(
self, data: st.DataObject
) -> None:
"""onboard_machine() only installs keys for valid contexts.
When onboarding a machine, only keys for contexts in the valid set
(privat, dhive, bahn) should be installed. Invalid contexts must
produce errors and not be included in the result.
"""
machine_ctx, authorized, _ = data.draw(machine_context_with_subset())
# Mix valid and invalid contexts for onboarding request
invalid_contexts = data.draw(
st.lists(
st.text(
alphabet=st.characters(whitelist_categories=("Ll",)),
min_size=3,
max_size=10,
).filter(lambda x: x not in ALL_WORK_CONTEXTS),
min_size=0,
max_size=2,
unique=True,
)
)
requested_contexts = authorized + invalid_contexts
root_path, _ = _create_monorepo_with_secrets(authorized)
manager = SecretEncryptionManager(root_path, machine_ctx)
result = manager.onboard_machine(machine_ctx.name, requested_contexts)
# Invalid contexts must appear in errors
for invalid_ctx in invalid_contexts:
assert any(invalid_ctx in err for err in result.errors), (
f"Invalid context '{invalid_ctx}' should produce an error in onboarding. "
f"Errors: {result.errors}"
)
# Authorized contexts in result must be subset of valid contexts
for ctx in result.authorized_contexts:
assert ctx in ALL_WORK_CONTEXTS, (
f"Onboarding result contains invalid context '{ctx}'. "
f"Only valid contexts {ALL_WORK_CONTEXTS} should be in authorized_contexts."
)
@@ -0,0 +1,292 @@
"""Property-basierte Tests für SecretEncryptionManager: Verschlüsselung nur mit Kontextschlüssel.
**Validates: Requirements 9.3, 9.8**
Property 23: Verschlüsselte Secrets können nur mit autorisiertem Kontextschlüssel entschlüsselt werden
For any verschlüsselte Secret-Datei eines Arbeitskontexts X und für jeden
Entschlüsselungsversuch mit einem Schlüssel des Kontexts Y (wobei X ≠ Y),
muss die Entschlüsselung fehlschlagen und die Datei im verschlüsselten Zustand
verbleiben. Nur der Schlüssel des zugehörigen Kontexts X darf die Datei
erfolgreich entschlüsseln.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from unittest.mock import patch
from hypothesis import assume, given, settings
from hypothesis import strategies as st
from monorepo.encryption import SecretEncryptionManager
from monorepo.models import MachineContext
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# Valid non-shared work contexts for encryption
WORK_CONTEXTS = ["privat", "dhive", "bahn"]
# Secret file names that match the SECRET_PATTERNS
SECRET_FILE_NAMES = [".env", "private.pem", "server.key", "api-token.txt", "my-secret.yaml"]
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
@st.composite
def machine_context_with_authorized(draw: st.DrawFn) -> tuple[MachineContext, list[str]]:
"""Generates a MachineContext with a random subset of authorized contexts.
Returns (MachineContext, authorized_contexts_list).
Ensures at least one context is authorized.
"""
# Draw a non-empty subset of contexts
authorized = draw(
st.lists(
st.sampled_from(WORK_CONTEXTS),
min_size=1,
max_size=3,
unique=True,
)
)
name = draw(st.sampled_from(["test-rechner", "dhive-laptop", "bahn-pc", "home-pc"]))
ctx = MachineContext(
name=name,
description=f"Test machine: {name}",
authorized_contexts=authorized,
key_source="keyring",
)
return ctx, authorized
@st.composite
def unauthorized_file_context(
draw: st.DrawFn, authorized_contexts: list[str]
) -> str:
"""Generates a context that is NOT in the authorized list."""
unauthorized = [c for c in WORK_CONTEXTS if c not in authorized_contexts]
assume(len(unauthorized) > 0)
return draw(st.sampled_from(unauthorized))
@st.composite
def secret_file_name(draw: st.DrawFn) -> str:
"""Generates a secret file name matching common patterns."""
return draw(st.sampled_from(SECRET_FILE_NAMES))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_monorepo_with_secret(
tmp_dir: Path, context: str, filename: str, content: bytes
) -> Path:
"""Creates a minimal monorepo structure with a secret file in the given context.
Returns the path to the created secret file.
"""
# Create context directories
for ctx in WORK_CONTEXTS + ["shared"]:
(tmp_dir / ctx).mkdir(parents=True, exist_ok=True)
# Create the secret file
secret_path = tmp_dir / context / filename
secret_path.write_bytes(content)
return secret_path
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestProperty23EncryptionOnlyWithContextKey:
"""Property 23: Verschlüsselte Secrets können nur mit autorisiertem Kontextschlüssel
entschlüsselt werden.
**Validates: Requirements 9.3, 9.8**
For any encrypted file belonging to a context, decryption must succeed ONLY when
the machine context includes the authorized key for that context. Without the
correct key, decryption must fail without revealing file content.
"""
@given(data=st.data())
@settings(max_examples=200)
def test_decrypt_fails_for_unauthorized_context(self, data: st.DataObject) -> None:
"""Decryption must fail when the machine context does NOT authorize the
file's context.
For any file in context X, if the machine only authorizes contexts Y
(where X not in Y), decrypt_file must return success=False.
"""
# Generate a machine context with a subset of authorized contexts
machine_ctx, authorized = data.draw(machine_context_with_authorized())
# Pick a file context that is NOT authorized
unauthorized_ctxs = [c for c in WORK_CONTEXTS if c not in authorized]
assume(len(unauthorized_ctxs) > 0)
file_context = data.draw(st.sampled_from(unauthorized_ctxs))
# Generate a secret file name
filename = data.draw(secret_file_name())
# Setup temporary monorepo
tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_enc_test_"))
secret_content = b"SUPER_SECRET_VALUE=mysecret123\nDB_PASSWORD=hunter2"
secret_path = _create_monorepo_with_secret(tmp_dir, file_context, filename, secret_content)
# Create manager with the generated machine context
manager = SecretEncryptionManager(tmp_dir, machine_ctx)
# Patch _run_gitcrypt so we don't need actual git-crypt installed
with patch.object(manager, "_run_gitcrypt"):
result = manager.decrypt_file(secret_path)
# Decryption MUST fail for unauthorized context
assert result.success is False, (
f"Decryption should fail: machine '{machine_ctx.name}' "
f"(authorized: {authorized}) tried to decrypt file in "
f"context '{file_context}'"
)
# Content must NOT be returned on failure
assert result.content is None, (
f"Failed decryption must not return file content. "
f"Got content for file in context '{file_context}'"
)
@given(data=st.data())
@settings(max_examples=200)
def test_decrypt_succeeds_for_authorized_context(self, data: st.DataObject) -> None:
"""Decryption must succeed when the machine context DOES authorize the
file's context.
For any file in context X, if the machine authorizes X, decrypt_file
must return success=True with the file content.
"""
# Generate a machine context
machine_ctx, authorized = data.draw(machine_context_with_authorized())
# Pick a file context that IS authorized
file_context = data.draw(st.sampled_from(authorized))
# Generate a secret file name
filename = data.draw(secret_file_name())
# Setup temporary monorepo
tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_enc_test_"))
secret_content = b"AUTHORIZED_SECRET=value\nKEY=data"
secret_path = _create_monorepo_with_secret(tmp_dir, file_context, filename, secret_content)
# Create manager with the generated machine context
manager = SecretEncryptionManager(tmp_dir, machine_ctx)
# Patch _run_gitcrypt to simulate successful unlock
with patch.object(manager, "_run_gitcrypt"):
result = manager.decrypt_file(secret_path)
# Decryption MUST succeed for authorized context
assert result.success is True, (
f"Decryption should succeed: machine '{machine_ctx.name}' "
f"(authorized: {authorized}) should decrypt file in "
f"context '{file_context}'. Error: {result.error}"
)
# Content must be returned on success
assert result.content is not None, (
f"Successful decryption must return file content for "
f"context '{file_context}'"
)
assert result.content == secret_content
@given(data=st.data())
@settings(max_examples=200)
def test_failed_decryption_does_not_reveal_content(self, data: st.DataObject) -> None:
"""When decryption fails, the error message must NOT contain
the file's actual contents.
This ensures that the encryption boundary does not leak sensitive
information through error messages (Requirement 9.8).
"""
# Generate a machine context with limited access
machine_ctx, authorized = data.draw(machine_context_with_authorized())
# Pick a file context that is NOT authorized
unauthorized_ctxs = [c for c in WORK_CONTEXTS if c not in authorized]
assume(len(unauthorized_ctxs) > 0)
file_context = data.draw(st.sampled_from(unauthorized_ctxs))
filename = data.draw(secret_file_name())
# Use recognizable secret content to check for leakage
secret_content = b"TOP_SECRET_API_KEY=sk-abc123xyz789\nDATABASE_URL=postgres://admin:pass@host/db"
secret_strings = [
"TOP_SECRET_API_KEY",
"sk-abc123xyz789",
"DATABASE_URL",
"postgres://admin:pass@host/db",
]
tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_enc_test_"))
secret_path = _create_monorepo_with_secret(tmp_dir, file_context, filename, secret_content)
manager = SecretEncryptionManager(tmp_dir, machine_ctx)
with patch.object(manager, "_run_gitcrypt"):
result = manager.decrypt_file(secret_path)
# Must fail
assert result.success is False
# Error message must NOT contain any of the secret content
error_msg = result.error or ""
for secret_str in secret_strings:
assert secret_str not in error_msg, (
f"Error message leaks file content! "
f"Found '{secret_str}' in error: '{error_msg}'"
)
@given(data=st.data())
@settings(max_examples=200)
def test_is_authorized_returns_true_only_for_authorized_contexts(
self, data: st.DataObject
) -> None:
"""is_authorized() must return True ONLY for contexts in the machine's
authorized_contexts list, and False for all others.
This is the fundamental gate that controls decryption access.
"""
# Generate a machine context
machine_ctx, authorized = data.draw(machine_context_with_authorized())
# Pick any context to check
check_context = data.draw(st.sampled_from(WORK_CONTEXTS))
tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_enc_test_"))
for ctx in WORK_CONTEXTS + ["shared"]:
(tmp_dir / ctx).mkdir(parents=True, exist_ok=True)
manager = SecretEncryptionManager(tmp_dir, machine_ctx)
result = manager.is_authorized(check_context)
if check_context in authorized:
assert result is True, (
f"is_authorized('{check_context}') should be True "
f"for machine with authorized={authorized}"
)
else:
assert result is False, (
f"is_authorized('{check_context}') should be False "
f"for machine with authorized={authorized}"
)
@@ -0,0 +1,568 @@
"""Tests für Task 17.3: Encryption-Security-Integration Verdrahtung.
Testet die korrekte Integration zwischen:
- SecretEncryptionManager ↔ ContextGuard (load_env nutzt Entschlüsselung)
- SecretEncryptionManager ↔ FederationManager (Team_Repos erhalten nur eigene Secrets)
- SecretEncryptionManager ↔ OrchestratorAdapter (Env-Loading via Encryption)
- create_integrated_stack Factory-Funktion
Requirements: 2.2, 9.3, 9.4, 10.10
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import yaml
from monorepo.encryption import DecryptionResult, SecretEncryptionManager
from monorepo.federation import FederationManager
from monorepo.integration import create_integrated_stack
from monorepo.models import MachineContext
from monorepo.orchestrator import OrchestratorAdapter
from monorepo.security import ContextGuard
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def monorepo_root(tmp_path: Path) -> Path:
"""Creates a minimal monorepo structure for integration tests."""
root = tmp_path / "monorepo"
root.mkdir()
# Context directories
for ctx in ("privat", "dhive", "bahn", "shared"):
(root / ctx).mkdir()
# .env files per context
(root / "privat" / ".env").write_text(
"PRIVAT_SECRET=privat_val_123\nPRIVAT_API=http://localhost:3000\n",
encoding="utf-8",
)
(root / "dhive" / ".env").write_text(
"DHIVE_SECRET=dhive_val_456\nDHIVE_TOKEN=tok_dhive\n",
encoding="utf-8",
)
(root / "bahn" / ".env").write_text(
"BAHN_SECRET=bahn_val_789\nBAHN_ENDPOINT=https://bahn.api\n",
encoding="utf-8",
)
(root / "shared" / ".env").write_text(
"SHARED_KEY=shared_val\n", encoding="utf-8"
)
# access-config.yaml
config_dir = root / "shared" / "config"
config_dir.mkdir(parents=True)
access_config = {
"contexts": {
"privat": {
"env_file": "privat/.env",
"allowed_shared": ["shared/tools/", "shared/config/"],
},
"dhive": {
"env_file": "dhive/.env",
"allowed_shared": ["shared/tools/", "shared/config/"],
},
"bahn": {
"env_file": "bahn/.env",
"allowed_shared": ["shared/tools/", "shared/config/"],
},
"shared": {
"env_file": "shared/.env",
"allowed_shared": ["*"],
},
}
}
(config_dir / "access-config.yaml").write_text(
yaml.dump(access_config), encoding="utf-8"
)
# machine-context.yaml
machine_config = {
"machine": {
"name": "test-machine",
"description": "Test Machine",
"authorized_contexts": ["privat", "dhive", "bahn"],
"key_source": "keyring",
}
}
(config_dir / "machine-context.yaml").write_text(
yaml.dump(machine_config), encoding="utf-8"
)
# team-repos.yaml
team_repos_config = {
"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.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "manual",
"shared_mirror": {
"enabled": True,
"paths": ["shared/tools/"],
"mode": "read-only",
},
},
{
"context": "dhive",
"url": "https://github.com/test/dhive.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
},
],
}
(config_dir / "team-repos.yaml").write_text(
yaml.dump(team_repos_config), encoding="utf-8"
)
return root
@pytest.fixture()
def machine_context() -> MachineContext:
"""Creates a MachineContext authorized for all contexts."""
return MachineContext(
name="test-machine",
description="Test",
authorized_contexts=["privat", "dhive", "bahn"],
key_source="keyring",
)
@pytest.fixture()
def limited_machine_context() -> MachineContext:
"""Creates a MachineContext authorized only for dhive."""
return MachineContext(
name="dhive-laptop",
description="Only dhive",
authorized_contexts=["dhive"],
key_source="keyring",
)
@pytest.fixture()
def encryption_manager(
monorepo_root: Path, machine_context: MachineContext
) -> SecretEncryptionManager:
"""Creates a SecretEncryptionManager with full authorization."""
return SecretEncryptionManager(monorepo_root, machine_context)
@pytest.fixture()
def limited_encryption_manager(
monorepo_root: Path, limited_machine_context: MachineContext
) -> SecretEncryptionManager:
"""Creates a SecretEncryptionManager authorized only for dhive."""
return SecretEncryptionManager(monorepo_root, limited_machine_context)
# ---------------------------------------------------------------------------
# Tests: ContextGuard + SecretEncryptionManager Integration
# ---------------------------------------------------------------------------
class TestContextGuardEncryptionIntegration:
"""Tests für die Integration von ContextGuard.load_env mit Entschlüsselung."""
def test_load_env_without_encryption_reads_file_directly(
self, monorepo_root: Path
) -> None:
"""Ohne encryption_manager: .env wird direkt gelesen (Req 2.2)."""
guard = ContextGuard(root_path=monorepo_root)
env = guard.load_env("dhive")
assert env == {"DHIVE_SECRET": "dhive_val_456", "DHIVE_TOKEN": "tok_dhive"}
def test_load_env_with_encryption_uses_decrypt_file(
self, monorepo_root: Path
) -> None:
"""Mit encryption_manager: load_env nutzt decrypt_file (Req 2.2, 9.3)."""
mock_enc = MagicMock()
mock_enc.is_authorized.return_value = True
mock_enc.machine_context.name = "test-machine"
mock_enc.decrypt_file.return_value = DecryptionResult(
success=True,
file_path=monorepo_root / "dhive" / ".env",
content=b"DECRYPTED_KEY=secret_decrypted_value\n",
)
guard = ContextGuard(
root_path=monorepo_root, encryption_manager=mock_enc
)
env = guard.load_env("dhive")
mock_enc.is_authorized.assert_called_once_with("dhive")
mock_enc.decrypt_file.assert_called_once_with(monorepo_root / "dhive" / ".env")
assert env == {"DECRYPTED_KEY": "secret_decrypted_value"}
def test_load_env_decryption_fails_falls_back_to_direct_read(
self, monorepo_root: Path
) -> None:
"""Bei fehlgeschlagener Entschlüsselung: Fallback auf direktes Lesen."""
mock_enc = MagicMock()
mock_enc.is_authorized.return_value = True
mock_enc.machine_context.name = "test-machine"
mock_enc.decrypt_file.return_value = DecryptionResult(
success=False,
file_path=monorepo_root / "privat" / ".env",
error="git-crypt not available",
)
guard = ContextGuard(
root_path=monorepo_root, encryption_manager=mock_enc
)
env = guard.load_env("privat")
# Fallback: liest die Datei direkt
assert env == {"PRIVAT_SECRET": "privat_val_123", "PRIVAT_API": "http://localhost:3000"}
def test_load_env_not_authorized_raises_permission_error(
self, monorepo_root: Path
) -> None:
"""Nicht autorisierter Maschinenkontext: PermissionError (Req 9.4)."""
mock_enc = MagicMock()
mock_enc.is_authorized.return_value = False
mock_enc.machine_context.name = "dhive-laptop"
guard = ContextGuard(
root_path=monorepo_root, encryption_manager=mock_enc
)
with pytest.raises(PermissionError, match="nicht.*autorisiert"):
guard.load_env("privat")
def test_load_env_respects_context_isolation(
self, monorepo_root: Path
) -> None:
"""Jeder Kontext erhält nur seine eigenen Umgebungsvariablen (Req 2.2)."""
guard = ContextGuard(root_path=monorepo_root)
privat_env = guard.load_env("privat")
dhive_env = guard.load_env("dhive")
bahn_env = guard.load_env("bahn")
assert "PRIVAT_SECRET" in privat_env
assert "DHIVE_SECRET" not in privat_env
assert "DHIVE_SECRET" in dhive_env
assert "BAHN_SECRET" not in dhive_env
assert "BAHN_SECRET" in bahn_env
assert "PRIVAT_SECRET" not in bahn_env
# ---------------------------------------------------------------------------
# Tests: OrchestratorAdapter + SecretEncryptionManager Integration
# ---------------------------------------------------------------------------
class TestOrchestratorEncryptionIntegration:
"""Tests für die Integration von OrchestratorAdapter.load_context_env mit Entschlüsselung."""
def test_load_context_env_with_encryption_uses_decrypt(
self, monorepo_root: Path
) -> None:
"""Mit encryption_manager: load_context_env entschlüsselt (Req 7.3, 9.3)."""
mock_enc = MagicMock()
mock_enc.is_authorized.return_value = True
mock_enc.machine_context.name = "test-machine"
mock_enc.decrypt_file.return_value = DecryptionResult(
success=True,
file_path=monorepo_root / "bahn" / ".env",
content=b"BAHN_DECRYPTED=value_from_decryption\n",
)
guard = ContextGuard(root_path=monorepo_root)
adapter = OrchestratorAdapter(
root_path=monorepo_root,
context_guard=guard,
encryption_manager=mock_enc,
)
env = adapter.load_context_env("bahn")
mock_enc.decrypt_file.assert_called_once()
assert env == {"BAHN_DECRYPTED": "value_from_decryption"}
def test_load_context_env_without_encryption_delegates_to_guard(
self, monorepo_root: Path
) -> None:
"""Ohne encryption_manager: Delegation an ContextGuard.load_env."""
guard = ContextGuard(root_path=monorepo_root)
adapter = OrchestratorAdapter(
root_path=monorepo_root,
context_guard=guard,
encryption_manager=None,
)
env = adapter.load_context_env("dhive")
assert env == {"DHIVE_SECRET": "dhive_val_456", "DHIVE_TOKEN": "tok_dhive"}
def test_load_context_env_not_authorized_raises_permission_error(
self, monorepo_root: Path
) -> None:
"""Nicht autorisiert: PermissionError bei Env-Zugriff (Req 9.4)."""
mock_enc = MagicMock()
mock_enc.is_authorized.return_value = False
mock_enc.machine_context.name = "bahn-only-machine"
guard = ContextGuard(root_path=monorepo_root)
adapter = OrchestratorAdapter(
root_path=monorepo_root,
context_guard=guard,
encryption_manager=mock_enc,
)
with pytest.raises(PermissionError, match="nicht.*autorisiert"):
adapter.load_context_env("privat")
def test_load_context_env_decryption_fails_falls_back(
self, monorepo_root: Path
) -> None:
"""Bei fehlgeschlagener Entschlüsselung: Fallback auf guard.load_env."""
mock_enc = MagicMock()
mock_enc.is_authorized.return_value = True
mock_enc.machine_context.name = "test-machine"
mock_enc.decrypt_file.return_value = DecryptionResult(
success=False,
file_path=monorepo_root / "dhive" / ".env",
error="git-crypt Fehler",
)
guard = ContextGuard(root_path=monorepo_root)
adapter = OrchestratorAdapter(
root_path=monorepo_root,
context_guard=guard,
encryption_manager=mock_enc,
)
env = adapter.load_context_env("dhive")
# Fallback auf direkte Leseoperation
assert env == {"DHIVE_SECRET": "dhive_val_456", "DHIVE_TOKEN": "tok_dhive"}
# ---------------------------------------------------------------------------
# Tests: FederationManager + SecretEncryptionManager Integration
# ---------------------------------------------------------------------------
class TestFederationEncryptionIntegration:
"""Tests für die Integration von FederationManager._handle_team_secrets."""
def test_prepare_team_repo_decrypts_secrets_when_authorized(
self, monorepo_root: Path
) -> None:
"""Team-Repo erhält entschlüsselte Secrets wenn autorisiert (Req 10.10)."""
# Create secret file in privat context
(monorepo_root / "privat" / "src").mkdir(parents=True, exist_ok=True)
(monorepo_root / "privat" / "src" / "app.py").write_text("pass\n")
env_content = b"PRIVAT_API_KEY=decrypted_secret_123\n"
mock_enc = MagicMock()
mock_enc.is_authorized.return_value = True
mock_enc.decrypt_file.return_value = DecryptionResult(
success=True,
file_path=monorepo_root / "privat" / ".env",
content=env_content,
)
config_path = monorepo_root / "shared" / "config" / "team-repos.yaml"
fm = FederationManager(
config_path=config_path,
monorepo_root=monorepo_root,
encryption_manager=mock_enc,
)
team_path = fm.prepare_team_repo("privat")
# Verify decrypt_file was called
assert mock_enc.decrypt_file.called
# Verify team repo was created
assert team_path.exists()
def test_prepare_team_repo_secrets_stay_encrypted_when_not_authorized(
self, monorepo_root: Path
) -> None:
"""Team-Repo Secrets bleiben verschlüsselt ohne Autorisierung."""
(monorepo_root / "dhive" / "src").mkdir(parents=True, exist_ok=True)
(monorepo_root / "dhive" / "src" / "main.py").write_text("pass\n")
mock_enc = MagicMock()
mock_enc.is_authorized.return_value = False
config_path = monorepo_root / "shared" / "config" / "team-repos.yaml"
fm = FederationManager(
config_path=config_path,
monorepo_root=monorepo_root,
encryption_manager=mock_enc,
)
team_path = fm.prepare_team_repo("dhive")
# decrypt_file should NOT be called when not authorized
mock_enc.decrypt_file.assert_not_called()
assert team_path.exists()
def test_prepare_team_repo_without_encryption_manager(
self, monorepo_root: Path
) -> None:
"""Ohne encryption_manager: Secrets werden nicht verarbeitet."""
(monorepo_root / "privat" / "src").mkdir(parents=True, exist_ok=True)
(monorepo_root / "privat" / "src" / "app.py").write_text("pass\n")
config_path = monorepo_root / "shared" / "config" / "team-repos.yaml"
fm = FederationManager(
config_path=config_path,
monorepo_root=monorepo_root,
encryption_manager=None,
)
team_path = fm.prepare_team_repo("privat")
assert team_path.exists()
def test_prepare_team_repo_filters_gitattributes_for_own_context(
self, monorepo_root: Path
) -> None:
"""Team-Repo .gitattributes behält nur Filter des eigenen Kontexts."""
(monorepo_root / "privat" / "src").mkdir(parents=True, exist_ok=True)
(monorepo_root / "privat" / "src" / "app.py").write_text("pass\n")
# Create .gitattributes with filters for multiple contexts
gitattributes_content = (
".env filter=git-crypt-privat diff=git-crypt-privat\n"
"*.pem filter=git-crypt-privat diff=git-crypt-privat\n"
"# Should be removed:\n"
".env filter=git-crypt-dhive diff=git-crypt-dhive\n"
".env filter=git-crypt-bahn diff=git-crypt-bahn\n"
)
(monorepo_root / "privat" / ".gitattributes").write_text(
gitattributes_content, encoding="utf-8"
)
mock_enc = MagicMock()
mock_enc.is_authorized.return_value = True
mock_enc.decrypt_file.return_value = DecryptionResult(
success=False, file_path=Path("/"), error="skip"
)
config_path = monorepo_root / "shared" / "config" / "team-repos.yaml"
fm = FederationManager(
config_path=config_path,
monorepo_root=monorepo_root,
encryption_manager=mock_enc,
)
team_path = fm.prepare_team_repo("privat")
# Check filtered .gitattributes
ga_path = team_path / ".gitattributes"
if ga_path.exists():
content = ga_path.read_text(encoding="utf-8")
assert "git-crypt-privat" in content
assert "git-crypt-dhive" not in content
assert "git-crypt-bahn" not in content
# ---------------------------------------------------------------------------
# Tests: create_integrated_stack Factory
# ---------------------------------------------------------------------------
class TestCreateIntegratedStack:
"""Tests für die create_integrated_stack Factory-Funktion."""
def test_creates_all_components(self, monorepo_root: Path) -> None:
"""Factory erstellt alle Komponenten korrekt."""
with patch(
"monorepo.integration.SecretEncryptionManager._run_gitcrypt"
):
stack = create_integrated_stack(monorepo_root)
assert "encryption_manager" in stack
assert "context_guard" in stack
assert "audit_logger" in stack
assert "orchestrator_adapter" in stack
assert "federation_manager" in stack
assert "machine_context_manager" in stack
def test_components_are_correctly_wired(self, monorepo_root: Path) -> None:
"""Alle Komponenten sind korrekt verdrahtet."""
with patch(
"monorepo.integration.SecretEncryptionManager._run_gitcrypt"
):
stack = create_integrated_stack(monorepo_root)
# ContextGuard hat encryption_manager
guard = stack["context_guard"]
assert guard.encryption_manager is stack["encryption_manager"]
# OrchestratorAdapter hat encryption_manager
adapter = stack["orchestrator_adapter"]
assert adapter.encryption_manager is stack["encryption_manager"]
assert adapter.context_guard is guard
# FederationManager hat encryption_manager
fed_mgr = stack["federation_manager"]
assert fed_mgr is not None
assert fed_mgr.encryption_manager is stack["encryption_manager"]
def test_handles_missing_machine_context_yaml(self, tmp_path: Path) -> None:
"""Ohne machine-context.yaml: Stack ohne Verschlüsselung."""
root = tmp_path / "monorepo"
root.mkdir()
for ctx in ("privat", "dhive", "bahn", "shared"):
(root / ctx).mkdir()
# access-config.yaml is required
config_dir = root / "shared" / "config"
config_dir.mkdir(parents=True)
access_config = {
"contexts": {
"privat": {"env_file": "privat/.env", "allowed_shared": []},
"dhive": {"env_file": "dhive/.env", "allowed_shared": []},
"bahn": {"env_file": "bahn/.env", "allowed_shared": []},
"shared": {"env_file": "shared/.env", "allowed_shared": ["*"]},
}
}
(config_dir / "access-config.yaml").write_text(
yaml.dump(access_config), encoding="utf-8"
)
# No machine-context.yaml → stack works but without encryption
stack = create_integrated_stack(root)
assert stack["encryption_manager"] is None
assert stack["context_guard"] is not None
assert stack["context_guard"].encryption_manager is None
assert stack["orchestrator_adapter"] is not None
assert stack["orchestrator_adapter"].encryption_manager is None
def test_handles_missing_team_repos_yaml(self, monorepo_root: Path) -> None:
"""Ohne team-repos.yaml: FederationManager ist None."""
# Remove team-repos.yaml
team_repos_path = monorepo_root / "shared" / "config" / "team-repos.yaml"
team_repos_path.unlink()
with patch(
"monorepo.integration.SecretEncryptionManager._run_gitcrypt"
):
stack = create_integrated_stack(monorepo_root)
assert stack["federation_manager"] is None
# Other components still work
assert stack["context_guard"] is not None
assert stack["orchestrator_adapter"] is not None
@@ -0,0 +1,626 @@
"""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
@@ -0,0 +1,506 @@
"""Tests für ETLPipeline und MarkdownSource.
Validiert:
- MarkdownSource: Rekursives Scannen, Fehlerbehandlung, Frontmatter-Parsing
- ETLPipeline: Inkrementelle Verarbeitung, Fehlerresilienz, Index-Update
- IngestResult: Korrekte Zähler und Fehlerlisten
- KnowledgeStore.ingest(): Integration mit ETLPipeline
Requirements: 3.2, 3.6, 3.10, 3.12, 3.20, 3.21
"""
from pathlib import Path
import pytest
import yaml
from monorepo.knowledge.etl import ETLPipeline, IngestError, IngestResult
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource, SourceError
from monorepo.knowledge.store import KnowledgeStore
from monorepo.models import ScopeConfig
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def scope_config() -> ScopeConfig:
"""Beispiel-ScopeConfig."""
return ScopeConfig(scopes={
"privat": {"scope": "privat", "paths": ["privat/"]},
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
"shared": {"scope": "shared", "paths": ["shared/"]},
})
@pytest.fixture
def source_dir_with_artifacts(tmp_path: Path) -> Path:
"""Verzeichnis mit gültigen Markdown-Dateien mit YAML-Frontmatter."""
source_dir = tmp_path / "source"
source_dir.mkdir()
# Datei 1: gültiges Artefakt
(source_dir / "api-design.md").write_text(
"---\n"
"type: decision\n"
"title: API-Designprinzipien\n"
"tags: [api, rest]\n"
"---\n"
"\n# API-Designprinzipien\n\nREST-Konventionen für Services.\n",
encoding="utf-8",
)
# Datei 2: gültiges Artefakt
(source_dir / "cloud-patterns.md").write_text(
"---\n"
"type: pattern\n"
"title: Cloud-Native Patterns\n"
"tags: [cloud, kubernetes]\n"
"---\n"
"\n# Cloud-Native Patterns\n\nBest Practices für cloud-native Apps.\n",
encoding="utf-8",
)
return source_dir
@pytest.fixture
def source_dir_with_subdirs(tmp_path: Path) -> Path:
"""Verzeichnis mit verschachtelten Markdown-Dateien."""
source_dir = tmp_path / "nested_source"
source_dir.mkdir()
# Top-level Datei
(source_dir / "top-level.md").write_text(
"---\ntype: note\ntitle: Top Level\ntags: []\n---\n\nTop level content.\n",
encoding="utf-8",
)
# Unterverzeichnis
sub_dir = source_dir / "decisions"
sub_dir.mkdir()
(sub_dir / "decision-one.md").write_text(
"---\ntype: decision\ntitle: Decision One\ntags: [arch]\n---\n\nDecision content.\n",
encoding="utf-8",
)
return source_dir
@pytest.fixture
def source_dir_with_invalid(tmp_path: Path) -> Path:
"""Verzeichnis mit gemischten gültigen und ungültigen Dateien."""
source_dir = tmp_path / "mixed"
source_dir.mkdir()
# Gültige Datei
(source_dir / "valid.md").write_text(
"---\ntype: note\ntitle: Valid Note\ntags: [test]\n---\n\nValid content.\n",
encoding="utf-8",
)
# Ungültige Datei (kein Frontmatter)
(source_dir / "no-frontmatter.md").write_text(
"# Just a Heading\n\nNo YAML frontmatter here.\n",
encoding="utf-8",
)
# Ungültige Datei (leeres Frontmatter)
(source_dir / "empty-frontmatter.md").write_text(
"---\n---\n\nEmpty frontmatter.\n",
encoding="utf-8",
)
return source_dir
# ---------------------------------------------------------------------------
# MarkdownSource Tests
# ---------------------------------------------------------------------------
class TestMarkdownSource:
"""Tests für MarkdownSource."""
def test_extract_finds_all_md_files(
self, source_dir_with_artifacts: Path
) -> None:
"""extract() findet alle .md-Dateien im Verzeichnis."""
source = MarkdownSource(directory=source_dir_with_artifacts)
artifacts = source.extract()
assert len(artifacts) == 2
titles = {a.metadata.title for a in artifacts}
assert "API-Designprinzipien" in titles
assert "Cloud-Native Patterns" in titles
def test_extract_recursive(self, source_dir_with_subdirs: Path) -> None:
"""extract() findet Dateien in Unterverzeichnissen."""
source = MarkdownSource(directory=source_dir_with_subdirs)
artifacts = source.extract()
assert len(artifacts) == 2
titles = {a.metadata.title for a in artifacts}
assert "Top Level" in titles
assert "Decision One" in titles
def test_extract_nonexistent_directory(self, tmp_path: Path) -> None:
"""extract() bei nicht-existierendem Verzeichnis gibt leere Liste + Fehler."""
source = MarkdownSource(directory=tmp_path / "nonexistent")
artifacts = source.extract()
assert artifacts == []
assert len(source.errors) == 1
assert source.errors[0].retry is True
def test_extract_handles_invalid_frontmatter_gracefully(
self, source_dir_with_invalid: Path
) -> None:
"""extract() überspringt Dateien ohne gültiges Frontmatter und loggt Fehler."""
source = MarkdownSource(directory=source_dir_with_invalid)
artifacts = source.extract()
# valid.md und empty-frontmatter.md parse successfully
# (empty-frontmatter has valid --- delimiters, just empty metadata)
# Only no-frontmatter.md is rejected (no --- at start)
assert len(artifacts) == 2
# Fehler für die ungültige Datei ohne Frontmatter
assert len(source.errors) == 1
assert any("no-frontmatter" in e.file_path for e in source.errors)
def test_extract_empty_directory(self, tmp_path: Path) -> None:
"""extract() bei leerem Verzeichnis gibt leere Liste ohne Fehler."""
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
source = MarkdownSource(directory=empty_dir)
artifacts = source.extract()
assert artifacts == []
assert source.errors == []
def test_extract_file_instead_of_directory(self, tmp_path: Path) -> None:
"""extract() bei Datei statt Verzeichnis gibt Fehler."""
file_path = tmp_path / "not-a-dir.md"
file_path.write_text("content", encoding="utf-8")
source = MarkdownSource(directory=file_path)
artifacts = source.extract()
assert artifacts == []
assert len(source.errors) == 1
assert source.errors[0].retry is False
def test_extract_resets_errors(self, source_dir_with_invalid: Path) -> None:
"""extract() setzt vorherige Fehler zurück bei erneutem Aufruf."""
source = MarkdownSource(directory=source_dir_with_invalid)
source.extract()
first_errors = len(source.errors)
source.extract()
# Fehler sollten gleich sein (kein Akkumulieren)
assert len(source.errors) == first_errors
# ---------------------------------------------------------------------------
# ETLPipeline Tests
# ---------------------------------------------------------------------------
class TestETLPipeline:
"""Tests für ETLPipeline."""
def test_ingest_processes_all_artifacts(
self, tmp_path: Path, source_dir_with_artifacts: Path
) -> None:
"""ingest() verarbeitet alle gültigen Artefakte aus der Quelle."""
store_path = tmp_path / "store"
store_path.mkdir()
index = YAMLIndex(store_path / "_index.yaml")
index.load()
pipeline = ETLPipeline(index=index, store_path=store_path)
source = MarkdownSource(directory=source_dir_with_artifacts)
result = pipeline.ingest(source, "bahn")
assert result.processed == 2
assert result.updated == 2
assert result.skipped == 0
assert result.errors == []
def test_ingest_creates_index_entries(
self, tmp_path: Path, source_dir_with_artifacts: Path
) -> None:
"""ingest() erstellt Index-Einträge für alle verarbeiteten Artefakte."""
store_path = tmp_path / "store"
store_path.mkdir()
index = YAMLIndex(store_path / "_index.yaml")
index.load()
pipeline = ETLPipeline(index=index, store_path=store_path)
source = MarkdownSource(directory=source_dir_with_artifacts)
pipeline.ingest(source, "bahn")
# Index enthält Einträge
assert len(index.entries) == 2
api_entry = index.get_entry("bahn/decision/api-design")
assert api_entry is not None
assert api_entry.title == "API-Designprinzipien"
assert api_entry.scope == "bahn"
assert api_entry.type == "decision"
assert "api" in api_entry.tags
assert api_entry.content_hash.startswith("sha256:")
def test_ingest_incremental_skips_unchanged(
self, tmp_path: Path, source_dir_with_artifacts: Path
) -> None:
"""ingest() überspringt Artefakte mit unverändertem Content-Hash."""
store_path = tmp_path / "store"
store_path.mkdir()
index = YAMLIndex(store_path / "_index.yaml")
index.load()
pipeline = ETLPipeline(index=index, store_path=store_path)
source = MarkdownSource(directory=source_dir_with_artifacts)
# Erster Durchlauf: alle werden verarbeitet
result1 = pipeline.ingest(source, "bahn")
assert result1.updated == 2
# Zweiter Durchlauf: alle werden übersprungen (unverändert)
source2 = MarkdownSource(directory=source_dir_with_artifacts)
result2 = pipeline.ingest(source2, "bahn")
assert result2.processed == 2
assert result2.updated == 0
assert result2.skipped == 2
def test_ingest_incremental_detects_changes(
self, tmp_path: Path, source_dir_with_artifacts: Path
) -> None:
"""ingest() erkennt geänderte Inhalte über Content-Hash."""
store_path = tmp_path / "store"
store_path.mkdir()
index = YAMLIndex(store_path / "_index.yaml")
index.load()
pipeline = ETLPipeline(index=index, store_path=store_path)
source = MarkdownSource(directory=source_dir_with_artifacts)
pipeline.ingest(source, "bahn")
# Inhalt einer Datei ändern
api_file = source_dir_with_artifacts / "api-design.md"
api_file.write_text(
"---\n"
"type: decision\n"
"title: API-Designprinzipien\n"
"tags: [api, rest, updated]\n"
"---\n"
"\n# API-Designprinzipien V2\n\nAktualisierte Konventionen.\n",
encoding="utf-8",
)
# Zweiter Durchlauf: nur die geänderte Datei wird aktualisiert
source2 = MarkdownSource(directory=source_dir_with_artifacts)
result2 = pipeline.ingest(source2, "bahn")
assert result2.updated == 1
assert result2.skipped == 1
def test_ingest_error_resilience(
self, tmp_path: Path, source_dir_with_invalid: Path
) -> None:
"""ingest() bewahrt erfolgreiche Artefakte bei Quellfehlern."""
store_path = tmp_path / "store"
store_path.mkdir()
index = YAMLIndex(store_path / "_index.yaml")
index.load()
pipeline = ETLPipeline(index=index, store_path=store_path)
source = MarkdownSource(directory=source_dir_with_invalid)
result = pipeline.ingest(source, "privat")
# valid.md and empty-frontmatter.md are parseable (2 artifacts)
# no-frontmatter.md is rejected by source
assert result.processed == 2
assert result.updated == 2
# Fehler für ungültige Dateien wurden protokolliert
assert len(result.errors) == 1
# Erfolgreiche Artefakte im Index
assert len(index.entries) == 2
def test_ingest_saves_index(
self, tmp_path: Path, source_dir_with_artifacts: Path
) -> None:
"""ingest() speichert den Index im selben Durchlauf."""
store_path = tmp_path / "store"
store_path.mkdir()
index_path = store_path / "_index.yaml"
index = YAMLIndex(index_path)
index.load()
pipeline = ETLPipeline(index=index, store_path=store_path)
source = MarkdownSource(directory=source_dir_with_artifacts)
pipeline.ingest(source, "bahn")
# Index-Datei wurde geschrieben
assert index_path.exists()
with open(index_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
assert len(data["artifacts"]) == 2
assert data["version"] == "1.0"
assert "last_updated" in data
def test_ingest_sets_metadata(
self, tmp_path: Path, source_dir_with_artifacts: Path
) -> None:
"""ingest() setzt source_context und Datum in den Metadaten."""
store_path = tmp_path / "store"
store_path.mkdir()
index = YAMLIndex(store_path / "_index.yaml")
index.load()
pipeline = ETLPipeline(index=index, store_path=store_path)
source = MarkdownSource(directory=source_dir_with_artifacts)
pipeline.ingest(source, "bahn")
entry = index.get_entry("bahn/decision/api-design")
assert entry is not None
assert entry.scope == "bahn"
# Content-Hash ist gesetzt
assert entry.content_hash.startswith("sha256:")
def test_ingest_writes_artifact_files(
self, tmp_path: Path, source_dir_with_artifacts: Path
) -> None:
"""ingest() schreibt Artefakt-Dateien in die Scope-basierte Ordnerstruktur."""
store_path = tmp_path / "store"
store_path.mkdir()
index = YAMLIndex(store_path / "_index.yaml")
index.load()
pipeline = ETLPipeline(index=index, store_path=store_path)
source = MarkdownSource(directory=source_dir_with_artifacts)
pipeline.ingest(source, "bahn")
# Artefakt-Datei existiert in der Scope-Struktur
expected_path = store_path / "bahn" / "decision" / "api-design.md"
assert expected_path.exists()
content = expected_path.read_text(encoding="utf-8")
assert "---" in content
assert "API-Designprinzipien" in content
def test_ingest_nonexistent_source_directory(self, tmp_path: Path) -> None:
"""ingest() bei nicht-existierendem Quellverzeichnis gibt Fehler zurück."""
store_path = tmp_path / "store"
store_path.mkdir()
index = YAMLIndex(store_path / "_index.yaml")
index.load()
pipeline = ETLPipeline(index=index, store_path=store_path)
source = MarkdownSource(directory=tmp_path / "nonexistent")
result = pipeline.ingest(source, "bahn")
assert result.processed == 0
assert len(result.errors) >= 1
def test_ingest_empty_source(self, tmp_path: Path) -> None:
"""ingest() bei leerem Quellverzeichnis verarbeitet null Artefakte."""
store_path = tmp_path / "store"
store_path.mkdir()
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
index = YAMLIndex(store_path / "_index.yaml")
index.load()
pipeline = ETLPipeline(index=index, store_path=store_path)
source = MarkdownSource(directory=empty_dir)
result = pipeline.ingest(source, "bahn")
assert result.processed == 0
assert result.updated == 0
assert result.skipped == 0
assert result.errors == []
# ---------------------------------------------------------------------------
# KnowledgeStore.ingest() Integration Tests
# ---------------------------------------------------------------------------
class TestKnowledgeStoreIngest:
"""Tests für KnowledgeStore.ingest() Integration mit ETLPipeline."""
def test_ingest_via_store(
self, tmp_path: Path, scope_config: ScopeConfig, source_dir_with_artifacts: Path
) -> None:
"""KnowledgeStore.ingest() delegiert an ETLPipeline."""
store = KnowledgeStore(tmp_path, scope_config)
source = MarkdownSource(directory=source_dir_with_artifacts)
result = store.ingest(source, "bahn")
assert result.processed == 2
assert result.updated == 2
# Artefakte sind im Index
assert len(store.index.entries) == 2
def test_ingest_via_store_incremental(
self, tmp_path: Path, scope_config: ScopeConfig, source_dir_with_artifacts: Path
) -> None:
"""KnowledgeStore.ingest() unterstützt inkrementelle Verarbeitung."""
store = KnowledgeStore(tmp_path, scope_config)
source1 = MarkdownSource(directory=source_dir_with_artifacts)
store.ingest(source1, "bahn")
# Zweiter Durchlauf: keine Änderungen
source2 = MarkdownSource(directory=source_dir_with_artifacts)
result2 = store.ingest(source2, "bahn")
assert result2.skipped == 2
assert result2.updated == 0
def test_ingest_via_store_search_after_ingest(
self, tmp_path: Path, scope_config: ScopeConfig, source_dir_with_artifacts: Path
) -> None:
"""Nach ingest() sind Artefakte über search() auffindbar."""
store = KnowledgeStore(tmp_path, scope_config)
source = MarkdownSource(directory=source_dir_with_artifacts)
store.ingest(source, "bahn")
results = store.search("API", ["bahn"])
assert len(results) >= 1
assert any(r.entry.title == "API-Designprinzipien" for r in results)
# ---------------------------------------------------------------------------
# IngestResult Tests
# ---------------------------------------------------------------------------
class TestIngestResult:
"""Tests für IngestResult Dataclass."""
def test_success_with_processed(self) -> None:
"""success ist True wenn Artefakte verarbeitet wurden."""
result = IngestResult(processed=3, updated=2, skipped=1)
assert result.success is True
def test_success_empty_no_errors(self) -> None:
"""success ist True bei leerem Ergebnis ohne Fehler."""
result = IngestResult()
assert result.success is True
def test_defaults(self) -> None:
"""Standardwerte sind korrekt."""
result = IngestResult()
assert result.processed == 0
assert result.updated == 0
assert result.skipped == 0
assert result.errors == []
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,574 @@
"""Unit-Tests für Team-Isolation und Cross-Context-Leakage-Prüfung.
Testet die Implementierung von Task 15.2:
- verify_isolation: Erkennung von Pfad-Referenzen, Env-Variablen,
Config-Referenzen und Kommentaren zu anderen Kontexten
- prepare_team_repo: Erstellung eines Team-Repos mit nur eigenem Kontext
- Integration mit SecretEncryptionManager für kontextspezifische Secret-Filterung
Validates: Requirements 10.5, 10.9, 10.10
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import yaml
from monorepo.federation import FederationManager
from monorepo.models import IsolationLeak, IsolationReport, MachineContext
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def team_repos_config(tmp_path: Path) -> Path:
"""Erstellt eine temporäre team-repos.yaml mit Testdaten."""
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",
},
{
"context": "dhive",
"url": "https://gitlab.dhive.io/team/dhive-mono.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
},
{
"context": "bahn",
"url": "https://gitlab.example.com/bahn-workspace.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "daily",
},
],
}
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 Kontextordnern."""
root = tmp_path / "monorepo"
root.mkdir()
(root / "privat").mkdir()
(root / "dhive").mkdir()
(root / "bahn").mkdir()
(root / "shared").mkdir()
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 mock_encryption_manager() -> MagicMock:
"""Erstellt einen gemockten SecretEncryptionManager."""
mock = MagicMock()
mock.is_authorized.return_value = True
return mock
@pytest.fixture
def federation_manager_with_encryption(
team_repos_config: Path, monorepo_root: Path, mock_encryption_manager: MagicMock
) -> FederationManager:
"""Erstellt einen FederationManager mit SecretEncryptionManager."""
return FederationManager(
config_path=team_repos_config,
monorepo_root=monorepo_root,
encryption_manager=mock_encryption_manager,
)
# ---------------------------------------------------------------------------
# Tests: verify_isolation - Pfad-Referenzen
# ---------------------------------------------------------------------------
class TestVerifyIsolationPathReferences:
"""Tests für die Erkennung von Pfad-Referenzen auf andere Kontexte."""
def test_clean_repo_is_isolated(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft dass ein sauberes Repo als isoliert erkannt wird."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / "src").mkdir()
(team_repo / "src" / "main.py").write_text(
"# My privat project\nprint('hello')\n", encoding="utf-8"
)
report = federation_manager.verify_isolation(team_repo, "privat")
assert report.is_isolated is True
assert report.leaks == []
assert report.context == "privat"
def test_detects_path_reference_to_dhive(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft Erkennung einer Pfad-Referenz auf dhive/."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / "config.yaml").write_text(
"path: dhive/project-x/src\n", encoding="utf-8"
)
report = federation_manager.verify_isolation(team_repo, "privat")
assert report.is_isolated is False
assert len(report.leaks) >= 1
leak = report.leaks[0]
assert leak.leaked_context == "dhive"
assert leak.leak_type == "path"
assert leak.line_number == 1
def test_detects_path_reference_to_bahn(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft Erkennung einer Pfad-Referenz auf bahn/."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / "script.sh").write_text(
"#!/bin/bash\ncp bahn/data.csv .\n", encoding="utf-8"
)
report = federation_manager.verify_isolation(team_repo, "privat")
assert report.is_isolated is False
path_leaks = [l for l in report.leaks if l.leak_type == "path"]
assert len(path_leaks) >= 1
assert path_leaks[0].leaked_context == "bahn"
def test_own_context_path_not_detected(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft dass Pfade des eigenen Kontexts NICHT als Leak erkannt werden."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / "readme.md").write_text(
"## Project\nSee privat/notes for details.\n", encoding="utf-8"
)
report = federation_manager.verify_isolation(team_repo, "privat")
# privat/ should NOT be a leak when context is "privat"
assert report.is_isolated is True
def test_nonexistent_path_returns_isolated(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft dass nicht-existierende Pfade als isoliert behandelt werden."""
report = federation_manager.verify_isolation(
tmp_path / "nonexistent", "privat"
)
assert report.is_isolated is True
assert report.leaks == []
# ---------------------------------------------------------------------------
# Tests: verify_isolation - Env-Variablen
# ---------------------------------------------------------------------------
class TestVerifyIsolationEnvVars:
"""Tests für die Erkennung von Env-Variablen-Referenzen anderer Kontexte."""
def test_detects_dhive_env_var(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft Erkennung von DHIVE_* Umgebungsvariablen."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / "app.py").write_text(
'import os\napi_key = os.environ["DHIVE_API_KEY"]\n',
encoding="utf-8",
)
report = federation_manager.verify_isolation(team_repo, "privat")
assert report.is_isolated is False
env_leaks = [l for l in report.leaks if l.leak_type == "env_var"]
assert len(env_leaks) >= 1
assert env_leaks[0].leaked_context == "dhive"
def test_detects_bahn_env_var(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft Erkennung von BAHN_* Umgebungsvariablen."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / ".env.example").write_text(
"BAHN_TOKEN=your-token-here\n", encoding="utf-8"
)
report = federation_manager.verify_isolation(team_repo, "privat")
assert report.is_isolated is False
env_leaks = [l for l in report.leaks if l.leak_type == "env_var"]
assert len(env_leaks) >= 1
assert env_leaks[0].leaked_context == "bahn"
def test_own_context_env_var_not_detected(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft dass PRIVAT_* Env-Vars im privat-Kontext NICHT als Leak gelten."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / "config.py").write_text(
'PRIVAT_SECRET = "xxx"\n', encoding="utf-8"
)
report = federation_manager.verify_isolation(team_repo, "privat")
# PRIVAT_ env vars should not be leaks when context is "privat"
assert report.is_isolated is True
# ---------------------------------------------------------------------------
# Tests: verify_isolation - Config-Referenzen
# ---------------------------------------------------------------------------
class TestVerifyIsolationConfigRefs:
"""Tests für die Erkennung von Config-Referenzen auf andere Kontexte."""
def test_detects_git_crypt_filter_reference(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft Erkennung von git-crypt-Filter-Referenzen anderer Kontexte."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / ".gitattributes").write_text(
".env filter=git-crypt-dhive diff=git-crypt-dhive\n",
encoding="utf-8",
)
report = federation_manager.verify_isolation(team_repo, "privat")
assert report.is_isolated is False
config_leaks = [l for l in report.leaks if l.leak_type == "config_ref"]
assert len(config_leaks) >= 1
assert config_leaks[0].leaked_context == "dhive"
def test_detects_context_yaml_reference(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft Erkennung von context: <other> in YAML-Dateien."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / "settings.yaml").write_text(
"name: project\ncontext: bahn\n", encoding="utf-8"
)
report = federation_manager.verify_isolation(team_repo, "privat")
assert report.is_isolated is False
config_leaks = [l for l in report.leaks if l.leak_type == "config_ref"]
assert len(config_leaks) >= 1
assert config_leaks[0].leaked_context == "bahn"
def test_own_git_crypt_filter_not_detected(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft dass der eigene git-crypt-Filter NICHT als Leak erkannt wird."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / ".gitattributes").write_text(
".env filter=git-crypt-privat diff=git-crypt-privat\n",
encoding="utf-8",
)
report = federation_manager.verify_isolation(team_repo, "privat")
assert report.is_isolated is True
# ---------------------------------------------------------------------------
# Tests: verify_isolation - Kommentare
# ---------------------------------------------------------------------------
class TestVerifyIsolationComments:
"""Tests für die Erkennung von Kommentaren zu anderen Kontexten."""
def test_detects_comment_mentioning_other_context(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft Erkennung von Kommentaren die andere Kontexte erwähnen."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / "main.py").write_text(
"# Dieses Skript wurde vom dhive-Team erstellt\n"
"print('hello')\n",
encoding="utf-8",
)
report = federation_manager.verify_isolation(team_repo, "privat")
assert report.is_isolated is False
comment_leaks = [l for l in report.leaks if l.leak_type == "comment"]
assert len(comment_leaks) >= 1
assert comment_leaks[0].leaked_context == "dhive"
def test_non_comment_context_word_not_detected_as_comment(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft dass Kontextnamen in nicht-Kommentar-Zeilen nicht als comment-Leak gemeldet werden."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / "data.txt").write_text(
"Some text that mentions dhive in a regular line\n",
encoding="utf-8",
)
report = federation_manager.verify_isolation(team_repo, "privat")
# Should not have comment-type leaks (may have other types)
comment_leaks = [l for l in report.leaks if l.leak_type == "comment"]
assert len(comment_leaks) == 0
def test_js_comment_detected(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft Erkennung in JS-Style-Kommentaren (//)."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / "index.js").write_text(
"// TODO: Check bahn integration\nconst x = 1;\n",
encoding="utf-8",
)
report = federation_manager.verify_isolation(team_repo, "privat")
comment_leaks = [l for l in report.leaks if l.leak_type == "comment"]
assert len(comment_leaks) >= 1
assert comment_leaks[0].leaked_context == "bahn"
# ---------------------------------------------------------------------------
# Tests: verify_isolation - Mehrfache Leaks
# ---------------------------------------------------------------------------
class TestVerifyIsolationMultipleLeaks:
"""Tests für die Erkennung mehrerer Leaks in einem Repo."""
def test_detects_multiple_leak_types(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft Erkennung verschiedener Leak-Typen in derselben Datei."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / "config.py").write_text(
"# Für das bahn-Team\n"
'PATH = "dhive/src/module"\n'
"TOKEN = BAHN_API_TOKEN\n",
encoding="utf-8",
)
report = federation_manager.verify_isolation(team_repo, "privat")
assert report.is_isolated is False
assert len(report.leaks) >= 3
leak_types = {l.leak_type for l in report.leaks}
assert "comment" in leak_types
assert "path" in leak_types
assert "env_var" in leak_types
def test_detects_leaks_across_multiple_files(
self, federation_manager: FederationManager, tmp_path: Path
) -> None:
"""Prüft Erkennung von Leaks über mehrere Dateien."""
team_repo = tmp_path / "team-repo"
team_repo.mkdir()
(team_repo / "a.py").write_text(
'x = "dhive/config"\n', encoding="utf-8"
)
(team_repo / "b.yaml").write_text(
"context: bahn\n", encoding="utf-8"
)
report = federation_manager.verify_isolation(team_repo, "privat")
assert report.is_isolated is False
files_with_leaks = {l.file_path for l in report.leaks}
assert len(files_with_leaks) == 2
# ---------------------------------------------------------------------------
# Tests: prepare_team_repo
# ---------------------------------------------------------------------------
class TestPrepareTeamRepo:
"""Tests für die Team-Repo-Erstellung mit nur eigenem Kontext."""
def test_prepare_creates_directory(
self, federation_manager: FederationManager, monorepo_root: Path
) -> None:
"""Prüft dass ein Team-Repo-Verzeichnis erstellt wird."""
# Erstelle Quelldateien im Kontextordner
privat_dir = monorepo_root / "privat"
(privat_dir / "project" / "src").mkdir(parents=True)
(privat_dir / "project" / "src" / "main.py").write_text(
"print('hello')\n", encoding="utf-8"
)
result = federation_manager.prepare_team_repo("privat")
assert result.exists()
assert result.is_dir()
def test_prepare_copies_context_files(
self, federation_manager: FederationManager, monorepo_root: Path
) -> None:
"""Prüft dass Dateien des eigenen Kontexts kopiert werden."""
privat_dir = monorepo_root / "privat"
(privat_dir / "src").mkdir()
(privat_dir / "src" / "app.py").write_text(
"print('app')\n", encoding="utf-8"
)
(privat_dir / "README.md").write_text(
"# My Project\n", encoding="utf-8"
)
result = federation_manager.prepare_team_repo("privat")
assert (result / "src" / "app.py").exists()
assert (result / "README.md").exists()
def test_prepare_invalid_context_raises(
self, federation_manager: FederationManager
) -> None:
"""Prüft ValueError bei ungültigem Kontext."""
with pytest.raises(ValueError, match="Ungültiger Kontext"):
federation_manager.prepare_team_repo("invalid")
def test_prepare_unconfigured_context_raises(
self, team_repos_config: Path, monorepo_root: Path
) -> None:
"""Prüft ValueError wenn kein Team-Repo konfiguriert ist."""
# "shared" is not in VALID_CONTEXTS so it raises "Ungültiger Kontext"
fm = FederationManager(
config_path=team_repos_config,
monorepo_root=monorepo_root,
)
with pytest.raises(ValueError, match="Ungültiger Kontext"):
fm.prepare_team_repo("shared")
def test_prepare_sanitizes_cross_context_leaks(
self, federation_manager: FederationManager, monorepo_root: Path
) -> None:
"""Prüft dass Cross-Context-Leaks im Team-Repo bereinigt werden."""
privat_dir = monorepo_root / "privat"
(privat_dir / "config.yaml").write_text(
"path: dhive/some-project\nname: my-project\n",
encoding="utf-8",
)
result = federation_manager.prepare_team_repo("privat")
# Die Datei sollte bereinigt worden sein
config_content = (result / "config.yaml").read_text(encoding="utf-8")
assert "dhive/" not in config_content
def test_prepare_with_encryption_manager(
self,
federation_manager_with_encryption: FederationManager,
monorepo_root: Path,
) -> None:
"""Prüft Integration mit SecretEncryptionManager."""
privat_dir = monorepo_root / "privat"
(privat_dir / "src").mkdir()
(privat_dir / "src" / "main.py").write_text(
"print('hello')\n", encoding="utf-8"
)
result = federation_manager_with_encryption.prepare_team_repo("privat")
assert result.exists()
# EncryptionManager.is_authorized sollte aufgerufen worden sein
federation_manager_with_encryption.encryption_manager.is_authorized.assert_called_with("privat")
def test_prepare_filters_gitattributes(
self,
federation_manager_with_encryption: FederationManager,
monorepo_root: Path,
) -> None:
"""Prüft dass .gitattributes nur eigene Filter enthält."""
privat_dir = monorepo_root / "privat"
(privat_dir / ".gitattributes").write_text(
".env filter=git-crypt-privat diff=git-crypt-privat\n"
"*.pem filter=git-crypt-dhive diff=git-crypt-dhive\n"
"*.key filter=git-crypt-bahn diff=git-crypt-bahn\n",
encoding="utf-8",
)
result = federation_manager_with_encryption.prepare_team_repo("privat")
gitattributes = (result / ".gitattributes").read_text(encoding="utf-8")
assert "git-crypt-privat" in gitattributes
assert "git-crypt-dhive" not in gitattributes
assert "git-crypt-bahn" not in gitattributes
def test_prepare_empty_context_folder(
self, federation_manager: FederationManager, monorepo_root: Path
) -> None:
"""Prüft dass ein leerer Kontextordner ein leeres Team-Repo erzeugt."""
# privat/ exists but is empty
result = federation_manager.prepare_team_repo("privat")
assert result.exists()
# ---------------------------------------------------------------------------
# Tests: Integration verify_isolation + prepare_team_repo
# ---------------------------------------------------------------------------
class TestIsolationIntegration:
"""Integrationstests für Isolation-Verifikation mit Team-Repo-Vorbereitung."""
def test_prepared_repo_passes_isolation_check(
self, federation_manager: FederationManager, monorepo_root: Path
) -> None:
"""Prüft dass ein vorbereitetes Team-Repo die Isolationsprüfung besteht."""
privat_dir = monorepo_root / "privat"
(privat_dir / "src").mkdir()
(privat_dir / "src" / "clean.py").write_text(
"# Clean code without cross-context references\n"
"x = 42\n",
encoding="utf-8",
)
team_repo_path = federation_manager.prepare_team_repo("privat")
report = federation_manager.verify_isolation(team_repo_path, "privat")
assert report.is_isolated is True
def test_prepared_repo_with_initial_leaks_is_sanitized(
self, federation_manager: FederationManager, monorepo_root: Path
) -> None:
"""Prüft dass Leaks beim Vorbereiten automatisch bereinigt werden."""
privat_dir = monorepo_root / "privat"
(privat_dir / "leak.py").write_text(
"# Reference to dhive/project\n"
'path = "bahn/data"\n'
"clean_line = True\n",
encoding="utf-8",
)
team_repo_path = federation_manager.prepare_team_repo("privat")
# Nach Sanitisierung sollte der Report sauber sein
report = federation_manager.verify_isolation(team_repo_path, "privat")
assert report.is_isolated is True
@@ -0,0 +1,390 @@
"""Property-Based Tests für Team-Mitglieder-Isolation (Property 27).
**Validates: Requirements 10.5, 10.9**
Property 27: Team-Mitglieder können die Existenz anderer Kontexte nicht entdecken
- Für jedes valide Member-Onboarding darf das Ergebnis keine Referenzen auf
andere Kontexte, das Monorepo, den Hub oder Cross-Context-Daten enthalten.
- Die team_repo_url im Ergebnis muss ausschließlich die URL des zugewiesenen
Kontexts enthalten.
- Die Strings "monorepo" oder "hub" dürfen niemals im Onboarding-Ergebnis erscheinen.
- Für ungültige Mitglieder (leerer Name oder leere E-Mail) muss das Onboarding
mit Fehlern abgelehnt werden.
"""
from __future__ import annotations
import re
from pathlib import Path
import yaml
from hypothesis import given, settings, HealthCheck, assume
from hypothesis import strategies as st
from monorepo.federation import FederationManager, MemberInfo, MemberOnboardingResult
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
VALID_CONTEXTS = ["privat", "dhive", "bahn"]
OTHER_CONTEXT_MARKERS = ["privat", "dhive", "bahn"]
MONOREPO_MARKERS = ["monorepo", "hub-and-spoke", "spoke", "federation"]
# "hub" als eigenständiges Wort (nicht als Teil von "github" o.ä.)
HUB_STANDALONE_MARKERS = ["hub"]
def _make_team_repos_config(config_path: Path, team_repos: list[dict] | None = None) -> Path:
"""Erstellt eine team-repos.yaml Konfiguration."""
config_path.parent.mkdir(parents=True, exist_ok=True)
if team_repos is None:
team_repos = [
{
"context": "privat",
"url": "https://github.com/test/privat-team.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
},
{
"context": "dhive",
"url": "https://gitlab.dhive.io/team/dhive-mono.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
},
{
"context": "bahn",
"url": "https://gitlab.example.com/bahn-workspace.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "daily",
},
]
data = {
"version": "1.0",
"federation": {
"topology": "hub-and-spoke",
"hub_owner": "andre",
"conflict_strategy": "team-wins",
},
"team_repos": team_repos,
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f)
return config_path
def _make_federation_manager(tmp_path: Path) -> FederationManager:
"""Erstellt einen FederationManager mit Standard-Konfiguration."""
config_path = tmp_path / "config" / "team-repos.yaml"
_make_team_repos_config(config_path)
monorepo_root = tmp_path / "monorepo"
monorepo_root.mkdir(parents=True, exist_ok=True)
for ctx in VALID_CONTEXTS:
(monorepo_root / ctx).mkdir(exist_ok=True)
(monorepo_root / "shared").mkdir(exist_ok=True)
return FederationManager(
config_path=config_path,
monorepo_root=monorepo_root,
)
def _result_to_strings(result: MemberOnboardingResult) -> list[str]:
"""Konvertiert alle String-Felder des Onboarding-Ergebnisses in eine Liste."""
strings = [
result.context,
result.member_name,
result.team_repo_url,
]
strings.extend(result.errors)
return strings
def _get_other_contexts(context: str) -> list[str]:
"""Liefert alle Kontexte außer dem gegebenen."""
return [c for c in VALID_CONTEXTS if c != context]
# ---------------------------------------------------------------------------
# Strategien
# ---------------------------------------------------------------------------
# Valide Mitglieder-Namen (nicht-leer)
member_names = st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "Zs", "Pd")),
min_size=1,
max_size=50,
).filter(lambda s: s.strip() != "")
# Valide E-Mail-Adressen
member_emails = st.from_regex(
r"[a-z][a-z0-9]{1,10}@[a-z]{2,8}\.[a-z]{2,4}",
fullmatch=True,
)
# Rollen
member_roles = st.sampled_from(["developer", "reviewer", "admin", "reader"])
# Kontexte
contexts = st.sampled_from(VALID_CONTEXTS)
# Ungültige (leere) Strings für Fehlertests
empty_or_whitespace = st.sampled_from(["", " ", " ", "\t", "\n"])
# ---------------------------------------------------------------------------
# Property 27: Team-Mitglieder können die Existenz anderer Kontexte nicht entdecken
# ---------------------------------------------------------------------------
class TestMemberIsolationNoOtherContexts:
"""Property-Tests: Onboarding-Ergebnis enthält keine Referenzen auf andere Kontexte.
**Validates: Requirements 10.5, 10.9**
"""
@given(context=contexts, name=member_names, email=member_emails, role=member_roles)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_onboarding_result_contains_no_other_context_references(
self, tmp_path: Path, context: str, name: str, email: str, role: str
) -> None:
"""Für jedes valide Onboarding darf das Ergebnis keine Referenzen
auf andere Kontexte als den zugewiesenen enthalten.
**Validates: Requirements 10.5, 10.9**
"""
fm = _make_federation_manager(tmp_path)
member = MemberInfo(name=name, email=email, role=role)
result = fm.onboard_member(context, member)
# Nur erfolgreiches Onboarding prüfen
assert result.success is True, (
f"Onboarding sollte für validen Member gelingen: {result.errors}"
)
other_contexts = _get_other_contexts(context)
all_strings = _result_to_strings(result)
for s in all_strings:
for other_ctx in other_contexts:
# Prüfe dass der Kontextname nicht als eigenständiges Wort oder
# Pfadbestandteil in den Ergebnis-Strings auftritt
assert other_ctx not in s.lower(), (
f"Onboarding-Ergebnis für Kontext '{context}' enthält "
f"Referenz auf anderen Kontext '{other_ctx}' in: '{s}'"
)
class TestMemberIsolationOnlyOwnTeamRepoUrl:
"""Property-Tests: Onboarding-Ergebnis enthält nur die Team-Repo-URL des eigenen Kontexts.
**Validates: Requirements 10.5, 10.9**
"""
@given(context=contexts, name=member_names, email=member_emails, role=member_roles)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_team_repo_url_matches_configured_context_url(
self, tmp_path: Path, context: str, name: str, email: str, role: str
) -> None:
"""Die team_repo_url im Ergebnis muss der konfigurierten URL des
zugewiesenen Kontexts entsprechen.
**Validates: Requirements 10.5, 10.9**
"""
fm = _make_federation_manager(tmp_path)
member = MemberInfo(name=name, email=email, role=role)
result = fm.onboard_member(context, member)
assert result.success is True, (
f"Onboarding sollte für validen Member gelingen: {result.errors}"
)
# Die URL muss genau die konfigurierte Team-Repo-URL für den Kontext sein
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",
}
expected_url = expected_urls[context]
assert result.team_repo_url == expected_url, (
f"Team-Repo-URL für Kontext '{context}' sollte '{expected_url}' sein, "
f"war aber '{result.team_repo_url}'"
)
@given(context=contexts, name=member_names, email=member_emails)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_team_repo_url_contains_no_other_context_urls(
self, tmp_path: Path, context: str, name: str, email: str
) -> None:
"""Die team_repo_url darf keine URL eines anderen Kontexts enthalten.
**Validates: Requirements 10.5, 10.9**
"""
fm = _make_federation_manager(tmp_path)
member = MemberInfo(name=name, email=email)
result = fm.onboard_member(context, member)
assert result.success is True, (
f"Onboarding sollte für validen Member gelingen: {result.errors}"
)
other_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",
}
# Entferne die eigene URL
del other_urls[context]
for other_ctx, other_url in other_urls.items():
assert other_url not in result.team_repo_url, (
f"Team-Repo-URL für '{context}' enthält URL von '{other_ctx}': "
f"'{result.team_repo_url}'"
)
class TestMemberIsolationNoMonorepoOrHubReferences:
"""Property-Tests: Weder 'monorepo' noch 'hub' dürfen im Onboarding-Ergebnis erscheinen.
**Validates: Requirements 10.5, 10.9**
"""
@given(context=contexts, name=member_names, email=member_emails, role=member_roles)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_no_monorepo_or_hub_in_onboarding_result(
self, tmp_path: Path, context: str, name: str, email: str, role: str
) -> None:
"""Die Strings 'monorepo' und 'hub' dürfen niemals im
Onboarding-Ergebnis auftauchen.
**Validates: Requirements 10.5, 10.9**
Hinweis: 'hub' wird als eigenständiges Wort geprüft (nicht als
Teilstring in z.B. 'github.com').
"""
fm = _make_federation_manager(tmp_path)
member = MemberInfo(name=name, email=email, role=role)
result = fm.onboard_member(context, member)
assert result.success is True, (
f"Onboarding sollte für validen Member gelingen: {result.errors}"
)
all_strings = _result_to_strings(result)
for s in all_strings:
s_lower = s.lower()
# Prüfe Marker die als Substring gesucht werden können
for marker in MONOREPO_MARKERS:
assert marker not in s_lower, (
f"Onboarding-Ergebnis enthält verbotenen Begriff '{marker}' "
f"in: '{s}' (Kontext: '{context}', Mitglied: '{name}')"
)
# Prüfe 'hub' als eigenständiges Wort (nicht Teil von 'github' o.ä.)
for marker in HUB_STANDALONE_MARKERS:
# Wortgrenze-Match: \bhub\b erkennt 'hub' nur alleinstehend
if re.search(rf'\b{re.escape(marker)}\b', s_lower):
# Doppelt prüfen: ist es wirklich alleinstehend?
# "github" enthält "hub" aber nicht als Wortgrenze
assert False, (
f"Onboarding-Ergebnis enthält verbotenen eigenständigen "
f"Begriff '{marker}' in: '{s}' "
f"(Kontext: '{context}', Mitglied: '{name}')"
)
class TestMemberIsolationInvalidMemberRejected:
"""Property-Tests: Ungültige Mitglieder (leerer Name oder E-Mail) werden abgelehnt.
**Validates: Requirements 10.5, 10.9**
"""
@given(context=contexts, email=member_emails, role=member_roles)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_empty_name_rejected_with_errors(
self, tmp_path: Path, context: str, email: str, role: str
) -> None:
"""Onboarding mit leerem Namen muss fehlschlagen.
**Validates: Requirements 10.5, 10.9**
"""
fm = _make_federation_manager(tmp_path)
for empty_name in ["", " ", " ", "\t"]:
member = MemberInfo(name=empty_name, email=email, role=role)
result = fm.onboard_member(context, member)
assert result.success is False, (
f"Onboarding sollte mit leerem Namen fehlschlagen "
f"(Name='{repr(empty_name)}', Kontext='{context}')"
)
assert len(result.errors) > 0, (
f"Fehlermeldung fehlt bei leerem Namen"
)
@given(context=contexts, name=member_names, role=member_roles)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_empty_email_rejected_with_errors(
self, tmp_path: Path, context: str, name: str, role: str
) -> None:
"""Onboarding mit leerer E-Mail muss fehlschlagen.
**Validates: Requirements 10.5, 10.9**
"""
fm = _make_federation_manager(tmp_path)
for empty_email in ["", " ", " ", "\t"]:
member = MemberInfo(name=name, email=empty_email, role=role)
result = fm.onboard_member(context, member)
assert result.success is False, (
f"Onboarding sollte mit leerer E-Mail fehlschlagen "
f"(Email='{repr(empty_email)}', Kontext='{context}')"
)
assert len(result.errors) > 0, (
f"Fehlermeldung fehlt bei leerer E-Mail"
)
@given(context=contexts, name=member_names, email=member_emails)
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_invalid_member_error_also_isolated(
self, tmp_path: Path, context: str, name: str, email: str
) -> None:
"""Auch Fehlermeldungen bei ungültigem Onboarding dürfen keine
Cross-Context-Informationen offenlegen.
**Validates: Requirements 10.5, 10.9**
"""
fm = _make_federation_manager(tmp_path)
# Teste mit leerem Namen
member = MemberInfo(name="", email=email)
result = fm.onboard_member(context, member)
assert result.success is False
other_contexts = _get_other_contexts(context)
# Prüfe dass Fehlermeldungen keine anderen Kontexte erwähnen
for error in result.errors:
for other_ctx in other_contexts:
assert other_ctx not in error.lower(), (
f"Fehlermeldung bei ungültigem Onboarding enthält "
f"Referenz auf '{other_ctx}': '{error}'"
)
for marker in MONOREPO_MARKERS:
assert marker not in error.lower(), (
f"Fehlermeldung enthält verbotenen Begriff '{marker}': '{error}'"
)
for marker in HUB_STANDALONE_MARKERS:
if re.search(rf'\b{re.escape(marker)}\b', error.lower()):
assert False, (
f"Fehlermeldung enthält verbotenen eigenständigen "
f"Begriff '{marker}': '{error}'"
)
@@ -0,0 +1,706 @@
"""Property-basierte Tests für bidirektionale Synchronisation.
**Validates: Requirements 10.3, 10.4, 10.6, 10.7, 10.8**
Property 26: Bidirektionale Synchronisation bewahrt Git-Historie und löst Konflikte korrekt
For any bidirectional sync operation:
(a) Git history must be preserved in both directions (commits_synced >= 0)
(b) When conflicts occur, sync must abort with appropriate error and conflict list
(c) Team-Repo is Single Source of Truth — team-wins strategy resolves to team version
(d) full_sync must abort if pre-conflicts are detected (no partial sync)
(e) Sync direction constraints must be respected (hub-to-spoke blocks pull, etc.)
Testing approach:
- Mock git operations (subprocess calls) as the existing tests do
- Generate various sync configurations, contexts, and conflict scenarios
- Verify invariants hold across all generated inputs
"""
from __future__ import annotations
import subprocess
import tempfile
from dataclasses import field
from datetime import datetime
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import yaml
from hypothesis import given, settings, assume, note
from hypothesis import strategies as st
from monorepo.federation import FederationManager, ConflictResult, SubtreeSyncEngine
from monorepo.models import ConflictInfo, SyncResult, TeamRepoEntry
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
VALID_CONTEXTS = st.sampled_from(["privat", "dhive", "bahn"])
SYNC_DIRECTIONS = st.sampled_from(["bidirectional", "hub-to-spoke", "spoke-to-hub"])
SYNC_FREQUENCIES = st.sampled_from(["on-push", "hourly", "daily", "manual"])
BRANCHES = st.sampled_from(["main", "develop", "release/1.0", "feature/sync"])
CONFLICT_STRATEGIES = st.sampled_from(["team-wins", "hub-wins", "manual"])
# Simulated commit counts from git output
COMMIT_COUNTS = st.integers(min_value=0, max_value=50)
# File paths that could appear in conflicts
CONFLICT_FILE_PATHS = st.builds(
lambda ctx, name: f"{ctx}/{name}",
VALID_CONTEXTS,
st.from_regex(r"[a-z][a-z0-9_/\-]{2,20}\.[a-z]{1,4}", fullmatch=True),
)
# Merge conflict messages from git
MERGE_CONFLICT_MESSAGES = st.sampled_from([
"CONFLICT (content): Merge conflict in {file}\nAutomatic merge failed",
"CONFLICT (modify/delete): {file} deleted in HEAD and modified in upstream",
"CONFLICT (rename): {file} renamed in both branches",
])
# Network/auth error messages
GIT_ERROR_MESSAGES = st.sampled_from([
"fatal: unable to access: Could not resolve host",
"fatal: Authentication failed",
"fatal: Could not read from remote repository.",
"error: failed to push some refs",
])
@st.composite
def team_repo_configs(draw: st.DrawFn) -> dict[str, Any]:
"""Generates a valid team-repos.yaml configuration."""
contexts_to_include = draw(
st.lists(VALID_CONTEXTS, min_size=1, max_size=3, unique=True)
)
team_repos = []
for ctx in contexts_to_include:
team_repos.append({
"context": ctx,
"url": f"https://example.com/{ctx}-team.git",
"branch": draw(BRANCHES),
"sync_direction": draw(SYNC_DIRECTIONS),
"sync_frequency": draw(SYNC_FREQUENCIES),
})
return {
"version": "1.0",
"federation": {
"topology": "hub-and-spoke",
"hub_owner": "andre",
"conflict_strategy": "team-wins",
},
"team_repos": team_repos,
}
@st.composite
def sync_success_output(draw: st.DrawFn) -> str:
"""Generates stdout from a successful git subtree pull/push."""
commits = draw(COMMIT_COUNTS)
if commits == 0:
return ""
lines = []
for i in range(commits):
lines.append(f"abc{i:04d}f Commit message {i}")
lines.append(f" {commits} files changed, {commits * 3} insertions(+)")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_config(tmp: Path, config_data: dict[str, Any]) -> Path:
"""Creates a team-repos.yaml config file."""
config_path = tmp / "config" / "team-repos.yaml"
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(config_data, f)
return config_path
def _create_federation_manager(
tmp: Path, config_data: dict[str, Any]
) -> FederationManager:
"""Creates a FederationManager with test configuration."""
monorepo_root = tmp / "monorepo"
monorepo_root.mkdir(exist_ok=True)
for ctx in ("privat", "dhive", "bahn", "shared"):
(monorepo_root / ctx).mkdir(exist_ok=True)
config_path = _create_config(tmp, config_data)
return FederationManager(
config_path=config_path,
monorepo_root=monorepo_root,
)
def _get_configured_context(config_data: dict[str, Any]) -> str | None:
"""Returns the first context from the config, or None."""
repos = config_data.get("team_repos", [])
if repos:
return repos[0]["context"]
return None
# ---------------------------------------------------------------------------
# Property Tests
# ---------------------------------------------------------------------------
class TestProperty26SyncCommitsNonNegative:
"""For any sync operation that succeeds, commits_synced must be >= 0.
**Validates: Requirements 10.3, 10.4, 10.8**
"""
@given(
config=team_repo_configs(),
git_output=sync_success_output(),
)
@settings(max_examples=200)
def test_successful_sync_from_team_has_non_negative_commits(
self,
config: dict[str, Any],
git_output: str,
) -> None:
"""After a successful sync_from_team, commits_synced >= 0."""
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
fm = _create_federation_manager(tmp, config)
# Find a context that allows pull (not hub-to-spoke)
pull_ctx = None
for repo in config["team_repos"]:
if repo["sync_direction"] != "hub-to-spoke":
pull_ctx = repo["context"]
break
if pull_ctx is None:
# All are hub-to-spoke, skip this scenario
return
with patch.object(fm.sync_engine, "_run_git") as mock_git:
mock_git.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout=git_output, stderr=""
)
result = fm.sync_from_team(pull_ctx)
if result.success:
assert result.commits_synced >= 0, (
f"commits_synced must be >= 0 on success, got {result.commits_synced}"
)
@given(
config=team_repo_configs(),
git_output=sync_success_output(),
)
@settings(max_examples=200)
def test_successful_sync_to_team_has_non_negative_commits(
self,
config: dict[str, Any],
git_output: str,
) -> None:
"""After a successful sync_to_team, commits_synced >= 0."""
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
fm = _create_federation_manager(tmp, config)
# Find a context that allows push (not spoke-to-hub)
push_ctx = None
for repo in config["team_repos"]:
if repo["sync_direction"] != "spoke-to-hub":
push_ctx = repo["context"]
break
if push_ctx is None:
return
with patch.object(fm.sync_engine, "_run_git") as mock_git:
mock_git.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout=git_output, stderr=""
)
result = fm.sync_to_team(push_ctx)
if result.success:
assert result.commits_synced >= 0, (
f"commits_synced must be >= 0 on success, got {result.commits_synced}"
)
class TestProperty26SyncDirectionConstraints:
"""For any sync direction mismatch, the operation must fail with an error.
**Validates: Requirements 10.3, 10.4**
"""
@given(config=team_repo_configs())
@settings(max_examples=200)
def test_hub_to_spoke_blocks_pull(
self,
config: dict[str, Any],
) -> None:
"""When sync_direction is hub-to-spoke, sync_from_team must fail."""
# Find or create a hub-to-spoke context
hub_to_spoke_ctx = None
for repo in config["team_repos"]:
if repo["sync_direction"] == "hub-to-spoke":
hub_to_spoke_ctx = repo["context"]
break
if hub_to_spoke_ctx is None:
return # No hub-to-spoke config in this example
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
fm = _create_federation_manager(tmp, config)
result = fm.sync_from_team(hub_to_spoke_ctx)
assert result.success is False, (
f"sync_from_team should fail for hub-to-spoke direction, "
f"context='{hub_to_spoke_ctx}'"
)
assert len(result.conflicts) >= 1, (
"Failed sync must have at least one conflict entry with error details"
)
@given(config=team_repo_configs())
@settings(max_examples=200)
def test_spoke_to_hub_blocks_push(
self,
config: dict[str, Any],
) -> None:
"""When sync_direction is spoke-to-hub, sync_to_team must fail."""
spoke_to_hub_ctx = None
for repo in config["team_repos"]:
if repo["sync_direction"] == "spoke-to-hub":
spoke_to_hub_ctx = repo["context"]
break
if spoke_to_hub_ctx is None:
return
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
fm = _create_federation_manager(tmp, config)
result = fm.sync_to_team(spoke_to_hub_ctx)
assert result.success is False, (
f"sync_to_team should fail for spoke-to-hub direction, "
f"context='{spoke_to_hub_ctx}'"
)
assert len(result.conflicts) >= 1, (
"Failed sync must have at least one conflict entry with error details"
)
@given(config=team_repo_configs())
@settings(max_examples=200)
def test_full_sync_requires_bidirectional(
self,
config: dict[str, Any],
) -> None:
"""full_sync must fail when sync_direction is not bidirectional."""
non_bidir_ctx = None
for repo in config["team_repos"]:
if repo["sync_direction"] != "bidirectional":
non_bidir_ctx = repo["context"]
break
if non_bidir_ctx is None:
return
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
fm = _create_federation_manager(tmp, config)
result = fm.full_sync(non_bidir_ctx)
assert result.success is False, (
f"full_sync should fail for non-bidirectional direction, "
f"context='{non_bidir_ctx}'"
)
class TestProperty26ConflictResolutionTeamWins:
"""For any conflict resolution with team-wins, resolved state matches team version.
**Validates: Requirements 10.6, 10.7**
"""
@given(
context=VALID_CONTEXTS,
num_files=st.integers(min_value=1, max_value=5),
)
@settings(max_examples=200)
def test_team_wins_uses_theirs_checkout(
self,
context: str,
num_files: int,
) -> None:
"""With team-wins strategy, git checkout --theirs must be used for each conflict file."""
config_data = {
"version": "1.0",
"federation": {
"topology": "hub-and-spoke",
"hub_owner": "andre",
"conflict_strategy": "team-wins",
},
"team_repos": [
{
"context": context,
"url": f"https://example.com/{context}-team.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
},
],
}
# Generate conflict file list within the context prefix
conflict_files = [f"{context}/file_{i}.py" for i in range(num_files)]
diff_output = "\n".join(conflict_files)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
fm = _create_federation_manager(tmp, config_data)
call_count = 0
def mock_run_git(args: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
nonlocal call_count
call_count += 1
if args[:3] == ["diff", "--name-only", "--diff-filter=U"]:
return subprocess.CompletedProcess(
args=args, returncode=0, stdout=diff_output, stderr=""
)
# checkout --theirs and add commands
return subprocess.CompletedProcess(
args=args, returncode=0, stdout="", stderr=""
)
with patch.object(fm.sync_engine, "_run_git", side_effect=mock_run_git):
result = fm.resolve_conflict(context, strategy="team-wins")
assert result.success is True, (
f"resolve_conflict with team-wins should succeed, errors: {result.errors}"
)
assert set(result.resolved_files) == set(conflict_files), (
f"All conflict files should be resolved. "
f"Expected: {conflict_files}, Got: {result.resolved_files}"
)
@given(
context=VALID_CONTEXTS,
num_files=st.integers(min_value=1, max_value=5),
)
@settings(max_examples=200)
def test_hub_wins_uses_ours_checkout(
self,
context: str,
num_files: int,
) -> None:
"""With hub-wins strategy, git checkout --ours must be used."""
config_data = {
"version": "1.0",
"federation": {
"topology": "hub-and-spoke",
"hub_owner": "andre",
"conflict_strategy": "team-wins",
},
"team_repos": [
{
"context": context,
"url": f"https://example.com/{context}-team.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
},
],
}
conflict_files = [f"{context}/src/module_{i}.py" for i in range(num_files)]
diff_output = "\n".join(conflict_files)
checkout_flags_used: list[str] = []
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
fm = _create_federation_manager(tmp, config_data)
def mock_run_git(args: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
if args[:3] == ["diff", "--name-only", "--diff-filter=U"]:
return subprocess.CompletedProcess(
args=args, returncode=0, stdout=diff_output, stderr=""
)
if args[0] == "checkout" and len(args) >= 2:
checkout_flags_used.append(args[1])
return subprocess.CompletedProcess(
args=args, returncode=0, stdout="", stderr=""
)
with patch.object(fm.sync_engine, "_run_git", side_effect=mock_run_git):
result = fm.resolve_conflict(context, strategy="hub-wins")
assert result.success is True
# All checkout calls should use --ours for hub-wins
ours_calls = [f for f in checkout_flags_used if f == "--ours"]
assert len(ours_calls) == num_files, (
f"hub-wins should use --ours for all {num_files} files, "
f"got {len(ours_calls)} --ours calls"
)
class TestProperty26FullSyncAbortsOnPreConflicts:
"""full_sync must abort if pre-conflicts are detected (no partial sync).
**Validates: Requirements 10.7, 10.8**
"""
@given(
context=VALID_CONTEXTS,
num_conflicts=st.integers(min_value=1, max_value=5),
)
@settings(max_examples=200)
def test_full_sync_aborts_with_pre_conflicts(
self,
context: str,
num_conflicts: int,
) -> None:
"""full_sync must return failure and conflicts if pre-conflicts exist."""
config_data = {
"version": "1.0",
"federation": {
"topology": "hub-and-spoke",
"hub_owner": "andre",
"conflict_strategy": "team-wins",
},
"team_repos": [
{
"context": context,
"url": f"https://example.com/{context}-team.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
},
],
}
# Simulate uncommitted changes (pre-conflicts)
status_lines = [
f" M {context}/file_{i}.py" for i in range(num_conflicts)
]
status_output = "\n".join(status_lines)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
fm = _create_federation_manager(tmp, config_data)
def mock_run_git(args: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
if "status" in args:
return subprocess.CompletedProcess(
args=args, returncode=0, stdout=status_output, stderr=""
)
# Subtree pull/push should NOT be called
raise AssertionError(
f"Git subtree command should not be called when pre-conflicts "
f"exist, but got: {args}"
)
with patch.object(fm.sync_engine, "_run_git", side_effect=mock_run_git):
result = fm.full_sync(context)
assert result.success is False, (
"full_sync must fail when pre-conflicts are detected"
)
assert result.direction == "full"
assert result.commits_synced == 0, (
"No commits should be synced when aborting due to pre-conflicts"
)
assert len(result.conflicts) >= 1, (
"Pre-conflicts must be reported in the result"
)
@given(
context=VALID_CONTEXTS,
pull_output=sync_success_output(),
push_output=sync_success_output(),
)
@settings(max_examples=200)
def test_full_sync_succeeds_without_conflicts(
self,
context: str,
pull_output: str,
push_output: str,
) -> None:
"""full_sync succeeds when no pre-conflicts and both pull/push succeed."""
config_data = {
"version": "1.0",
"federation": {
"topology": "hub-and-spoke",
"hub_owner": "andre",
"conflict_strategy": "team-wins",
},
"team_repos": [
{
"context": context,
"url": f"https://example.com/{context}-team.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
},
],
}
call_index = 0
outputs = ["", pull_output, push_output] # status(empty), pull, push
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
fm = _create_federation_manager(tmp, config_data)
def mock_run_git(args: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
nonlocal call_index
idx = min(call_index, len(outputs) - 1)
call_index += 1
return subprocess.CompletedProcess(
args=args, returncode=0, stdout=outputs[idx], stderr=""
)
with patch.object(fm.sync_engine, "_run_git", side_effect=mock_run_git):
result = fm.full_sync(context)
assert result.success is True, (
f"full_sync should succeed without conflicts, got errors: {result.conflicts}"
)
assert result.direction == "full"
assert result.commits_synced >= 0
class TestProperty26ResolvedFilesWithinContextPrefix:
"""For any resolved conflict, only files within the context prefix are resolved.
**Validates: Requirements 10.6, 10.7**
"""
@given(
context=VALID_CONTEXTS,
strategy=st.sampled_from(["team-wins", "hub-wins"]),
num_context_files=st.integers(min_value=1, max_value=3),
num_other_files=st.integers(min_value=0, max_value=3),
)
@settings(max_examples=200)
def test_only_context_prefixed_files_resolved(
self,
context: str,
strategy: str,
num_context_files: int,
num_other_files: int,
) -> None:
"""resolve_conflict only resolves files within the specified context prefix."""
config_data = {
"version": "1.0",
"federation": {
"topology": "hub-and-spoke",
"hub_owner": "andre",
"conflict_strategy": "team-wins",
},
"team_repos": [
{
"context": context,
"url": f"https://example.com/{context}-team.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
},
],
}
# Files within the context
context_files = [f"{context}/src/file_{i}.py" for i in range(num_context_files)]
# Files outside the context (other contexts)
other_contexts = [c for c in ("privat", "dhive", "bahn") if c != context]
other_files = [
f"{other_contexts[i % len(other_contexts)]}/file_{i}.py"
for i in range(num_other_files)
]
# Combined output from git diff (shows all conflicted files)
all_files = context_files + other_files
diff_output = "\n".join(all_files)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
fm = _create_federation_manager(tmp, config_data)
def mock_run_git(args: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
if args[:3] == ["diff", "--name-only", "--diff-filter=U"]:
return subprocess.CompletedProcess(
args=args, returncode=0, stdout=diff_output, stderr=""
)
return subprocess.CompletedProcess(
args=args, returncode=0, stdout="", stderr=""
)
with patch.object(fm.sync_engine, "_run_git", side_effect=mock_run_git):
result = fm.resolve_conflict(context, strategy=strategy)
# All resolved files must start with the context prefix
for resolved_file in result.resolved_files:
assert resolved_file.startswith(f"{context}/"), (
f"Resolved file '{resolved_file}' is outside context '{context}/' prefix"
)
# Only the context-prefixed files should be resolved
assert set(result.resolved_files) == set(context_files), (
f"Only files with prefix '{context}/' should be resolved. "
f"Expected: {context_files}, Got: {result.resolved_files}"
)
@given(context=VALID_CONTEXTS)
@settings(max_examples=100)
def test_invalid_strategy_returns_error(
self,
context: str,
) -> None:
"""resolve_conflict with invalid strategy must return error."""
config_data = {
"version": "1.0",
"federation": {
"topology": "hub-and-spoke",
"hub_owner": "andre",
"conflict_strategy": "team-wins",
},
"team_repos": [
{
"context": context,
"url": f"https://example.com/{context}-team.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
},
],
}
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
fm = _create_federation_manager(tmp, config_data)
result = fm.resolve_conflict(context, strategy="invalid-strategy")
assert result.success is False, (
"resolve_conflict with invalid strategy must fail"
)
assert len(result.errors) >= 1, (
"Invalid strategy must produce at least one error message"
)
@@ -0,0 +1,482 @@
"""Property-basierte Tests für Team-Repo-Isolation (Property 25).
**Validates: Requirements 10.5, 10.9, 10.10, 10.12**
Property 25: Team-Repos enthalten ausschließlich Inhalte des eigenen Kontexts
For any Team-Repo prepared by the FederationManager, it must contain
exclusively content from its own context. No references to other contexts
(paths, env vars, config refs, comments) are allowed. Secrets from other
contexts must not be present.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import yaml
from hypothesis import assume, given, settings
from hypothesis import strategies as st
from monorepo.federation import FederationManager
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
WORK_CONTEXTS = ["privat", "dhive", "bahn"]
SECRET_FILE_PATTERNS = [".env", "private.pem", "server.key", "api-token.txt"]
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
@st.composite
def context_pair(draw: st.DrawFn) -> tuple[str, str]:
"""Generates a pair (own_context, other_context) where they differ."""
own = draw(st.sampled_from(WORK_CONTEXTS))
other = draw(st.sampled_from([c for c in WORK_CONTEXTS if c != own]))
return own, other
@st.composite
def cross_context_path_content(draw: st.DrawFn, other_context: str) -> str:
"""Generates file content containing a path reference to another context.
Creates lines like:
- path: dhive/some-project/src
- import from bahn/module
- cp privat/data.csv .
"""
prefix = draw(st.sampled_from([
"path: ", "import ", "cp ", 'source = "', "target: ", "from ",
]))
suffix = draw(st.sampled_from([
"/project/src", "/data.csv", "/module", "/config.yaml",
"/secrets", "/tools/script.sh",
]))
# Add some clean lines around the cross-context reference
clean_line = draw(st.sampled_from([
"x = 42\n", "print('hello')\n", "# clean comment\n", "name: my-app\n",
]))
return f"{clean_line}{prefix}{other_context}{suffix}\n{clean_line}"
@st.composite
def cross_context_env_var_content(draw: st.DrawFn, other_context: str) -> str:
"""Generates file content containing env var references to another context.
Creates lines like:
- DHIVE_API_KEY=value
- os.environ["BAHN_TOKEN"]
"""
var_suffix = draw(st.sampled_from([
"_API_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_DATABASE_URL",
]))
ctx_upper = other_context.upper()
style = draw(st.sampled_from(["assignment", "environ_access", "export"]))
if style == "assignment":
return f"x = 1\n{ctx_upper}{var_suffix}=some_value\nprint('done')\n"
elif style == "environ_access":
return f'import os\nkey = os.environ["{ctx_upper}{var_suffix}"]\n'
else:
return f"export {ctx_upper}{var_suffix}=secret123\n"
@st.composite
def cross_context_config_ref_content(draw: st.DrawFn, other_context: str) -> str:
"""Generates file content with config references to another context.
Creates lines like:
- filter=git-crypt-dhive
- context: bahn
"""
style = draw(st.sampled_from(["git_crypt", "context_yaml"]))
if style == "git_crypt":
return f".env filter=git-crypt-{other_context} diff=git-crypt-{other_context}\n"
else:
return f"name: project\ncontext: {other_context}\nversion: 1.0\n"
@st.composite
def cross_context_comment_content(draw: st.DrawFn, other_context: str) -> str:
"""Generates file content with comments referencing another context.
Creates lines like:
- # TODO: Check dhive integration
- // From bahn team
"""
comment_style = draw(st.sampled_from(["#", "//", "/*"]))
phrase = draw(st.sampled_from([
f"From {other_context} team",
f"TODO: Check {other_context} integration",
f"Originally from {other_context}",
f"See {other_context} for reference",
]))
return f"x = 1\n{comment_style} {phrase}\nprint('done')\n"
@st.composite
def file_name_with_extension(draw: st.DrawFn) -> str:
"""Generates a valid file name with a text extension."""
base = draw(st.sampled_from([
"main", "config", "settings", "app", "utils", "script", "readme",
]))
ext = draw(st.sampled_from([
".py", ".yaml", ".yml", ".json", ".sh", ".md", ".txt", ".toml",
]))
return f"{base}{ext}"
@st.composite
def secret_file_for_context(draw: st.DrawFn, context: str) -> tuple[str, bytes]:
"""Generates a secret file name and content that belongs to a specific context."""
filename = draw(st.sampled_from(SECRET_FILE_PATTERNS))
content = draw(st.binary(min_size=10, max_size=100))
return filename, content
# ---------------------------------------------------------------------------
# Fixtures (as helper functions since hypothesis doesn't use pytest fixtures directly)
# ---------------------------------------------------------------------------
def _create_team_repos_config(tmp_dir: Path) -> Path:
"""Creates a team-repos.yaml config file."""
config_path = tmp_dir / "config" / "team-repos.yaml"
config_path.parent.mkdir(parents=True, exist_ok=True)
data = {
"version": "1.0",
"federation": {
"topology": "hub-and-spoke",
"hub_owner": "andre",
"conflict_strategy": "team-wins",
},
"team_repos": [
{
"context": ctx,
"url": f"https://example.com/{ctx}-team.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
}
for ctx in WORK_CONTEXTS
],
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f)
return config_path
def _create_monorepo_root(tmp_dir: Path) -> Path:
"""Creates a minimal monorepo root with context folders."""
root = tmp_dir / "monorepo"
root.mkdir(parents=True, exist_ok=True)
for ctx in WORK_CONTEXTS:
(root / ctx).mkdir(exist_ok=True)
(root / "shared").mkdir(exist_ok=True)
return root
def _create_federation_manager(tmp_dir: Path) -> tuple[FederationManager, Path]:
"""Creates a FederationManager instance with temp config and monorepo root."""
config_path = _create_team_repos_config(tmp_dir)
monorepo_root = _create_monorepo_root(tmp_dir)
mock_encryption = MagicMock()
mock_encryption.is_authorized.return_value = True
fm = FederationManager(
config_path=config_path,
monorepo_root=monorepo_root,
encryption_manager=mock_encryption,
)
return fm, monorepo_root
# ---------------------------------------------------------------------------
# Property Tests
# ---------------------------------------------------------------------------
class TestProperty25TeamRepoContainsOnlyOwnContext:
"""Property 25: Team-Repos enthalten ausschließlich Inhalte des eigenen Kontexts.
**Validates: Requirements 10.5, 10.9, 10.10, 10.12**
For any Team-Repo prepared by the FederationManager, regardless of
what cross-context references existed in the source files, the prepared
team repo must pass verify_isolation (no leaks detected).
"""
@given(data=st.data())
@settings(max_examples=100, deadline=10000)
def test_prepared_repo_passes_isolation_with_path_references(
self, data: st.DataObject, tmp_path_factory
) -> None:
"""For any context and files containing cross-context path references,
prepare_team_repo must produce a repo that passes verify_isolation.
Even when source files contain paths like 'dhive/project' or 'bahn/data',
the prepared team repo must be clean.
"""
tmp_dir = tmp_path_factory.mktemp("prop25_path")
fm, monorepo_root = _create_federation_manager(tmp_dir)
own_ctx, other_ctx = data.draw(context_pair())
content = data.draw(cross_context_path_content(other_ctx))
filename = data.draw(file_name_with_extension())
# Place a file with cross-context path references in the context folder
ctx_dir = monorepo_root / own_ctx
(ctx_dir / filename).write_text(content, encoding="utf-8")
# Prepare team repo - this should sanitize cross-context references
team_repo_path = fm.prepare_team_repo(own_ctx)
# Verify isolation passes
report = fm.verify_isolation(team_repo_path, own_ctx)
assert report.is_isolated, (
f"Team-Repo for '{own_ctx}' has leaks after prepare_team_repo! "
f"Source had path refs to '{other_ctx}'. "
f"Leaks found: {[(l.file_path, l.leak_type, l.leaked_context) for l in report.leaks]}"
)
@given(data=st.data())
@settings(max_examples=100, deadline=10000)
def test_prepared_repo_passes_isolation_with_env_var_references(
self, data: st.DataObject, tmp_path_factory
) -> None:
"""For any context and files containing cross-context env var references,
prepare_team_repo must produce a repo that passes verify_isolation.
Even when source files reference DHIVE_API_KEY or BAHN_TOKEN,
the prepared team repo must be clean.
"""
tmp_dir = tmp_path_factory.mktemp("prop25_env")
fm, monorepo_root = _create_federation_manager(tmp_dir)
own_ctx, other_ctx = data.draw(context_pair())
content = data.draw(cross_context_env_var_content(other_ctx))
filename = data.draw(file_name_with_extension())
ctx_dir = monorepo_root / own_ctx
(ctx_dir / filename).write_text(content, encoding="utf-8")
team_repo_path = fm.prepare_team_repo(own_ctx)
report = fm.verify_isolation(team_repo_path, own_ctx)
assert report.is_isolated, (
f"Team-Repo for '{own_ctx}' has leaks after prepare_team_repo! "
f"Source had env var refs to '{other_ctx}'. "
f"Leaks found: {[(l.file_path, l.leak_type, l.leaked_context) for l in report.leaks]}"
)
@given(data=st.data())
@settings(max_examples=100, deadline=10000)
def test_prepared_repo_has_no_gitattributes_for_other_contexts(
self, data: st.DataObject, tmp_path_factory
) -> None:
"""For any prepared team-repo, .gitattributes must not contain
git-crypt filters for other contexts.
If source has filters like git-crypt-dhive or git-crypt-bahn,
prepare_team_repo must filter them out.
"""
tmp_dir = tmp_path_factory.mktemp("prop25_gitattr")
fm, monorepo_root = _create_federation_manager(tmp_dir)
own_ctx = data.draw(st.sampled_from(WORK_CONTEXTS))
other_contexts = [c for c in WORK_CONTEXTS if c != own_ctx]
# Create .gitattributes with filters for ALL contexts (including own + others)
lines = [f".env filter=git-crypt-{own_ctx} diff=git-crypt-{own_ctx}"]
for other_ctx in other_contexts:
ext = data.draw(st.sampled_from([".env", "*.pem", "*.key"]))
lines.append(f"{ext} filter=git-crypt-{other_ctx} diff=git-crypt-{other_ctx}")
ctx_dir = monorepo_root / own_ctx
(ctx_dir / ".gitattributes").write_text("\n".join(lines) + "\n", encoding="utf-8")
# Also add a regular source file so the repo isn't empty
(ctx_dir / "main.py").write_text("x = 1\n", encoding="utf-8")
team_repo_path = fm.prepare_team_repo(own_ctx)
# Check .gitattributes in the prepared repo
gitattr_path = team_repo_path / ".gitattributes"
if gitattr_path.exists():
content = gitattr_path.read_text(encoding="utf-8")
for other_ctx in other_contexts:
assert f"git-crypt-{other_ctx}" not in content, (
f".gitattributes in team-repo for '{own_ctx}' still contains "
f"git-crypt filter for '{other_ctx}'. Content:\n{content}"
)
@given(data=st.data())
@settings(max_examples=100, deadline=10000)
def test_prepared_repo_secrets_belong_only_to_own_context(
self, data: st.DataObject, tmp_path_factory
) -> None:
"""For any prepared team-repo, secret files (.env, *.pem, *.key) must
only belong to the own context. No secrets from other contexts may be present.
This verifies that even if the source directory somehow contains files
referencing other contexts' secrets, the prepared repo is clean.
"""
tmp_dir = tmp_path_factory.mktemp("prop25_secrets")
fm, monorepo_root = _create_federation_manager(tmp_dir)
own_ctx = data.draw(st.sampled_from(WORK_CONTEXTS))
# Create some secret files in the own context
ctx_dir = monorepo_root / own_ctx
secret_name = data.draw(st.sampled_from(SECRET_FILE_PATTERNS))
secret_content = f"{own_ctx.upper()}_SECRET=my-own-secret\n"
(ctx_dir / secret_name).write_text(secret_content, encoding="utf-8")
# Also place a normal source file
(ctx_dir / "app.py").write_text("print('app')\n", encoding="utf-8")
team_repo_path = fm.prepare_team_repo(own_ctx)
# Verify no file paths in the team repo reference other contexts
report = fm.verify_isolation(team_repo_path, own_ctx)
assert report.is_isolated, (
f"Team-Repo for '{own_ctx}' has cross-context leaks even for secrets. "
f"Leaks: {[(l.file_path, l.leak_type, l.leaked_context) for l in report.leaks]}"
)
@given(data=st.data())
@settings(max_examples=100, deadline=10000)
def test_no_file_path_in_prepared_repo_references_other_context_folders(
self, data: st.DataObject, tmp_path_factory
) -> None:
"""For any prepared team-repo, no file path within the repo should
contain references to other context folders.
The directory structure itself must not expose other context names
as folder paths.
"""
tmp_dir = tmp_path_factory.mktemp("prop25_dirpaths")
fm, monorepo_root = _create_federation_manager(tmp_dir)
own_ctx = data.draw(st.sampled_from(WORK_CONTEXTS))
other_contexts = [c for c in WORK_CONTEXTS if c != own_ctx]
# Create a directory structure with clean names
ctx_dir = monorepo_root / own_ctx
subdir = data.draw(st.sampled_from(["src", "lib", "docs", "tests", "config"]))
(ctx_dir / subdir).mkdir(parents=True, exist_ok=True)
(ctx_dir / subdir / "module.py").write_text("pass\n", encoding="utf-8")
team_repo_path = fm.prepare_team_repo(own_ctx)
# Collect all file/directory paths in the prepared repo
all_paths = [
str(p.relative_to(team_repo_path))
for p in team_repo_path.rglob("*")
]
for path_str in all_paths:
for other_ctx in other_contexts:
# Path segments should not be named after other contexts
path_parts = Path(path_str).parts
assert other_ctx not in path_parts, (
f"Directory structure in team-repo for '{own_ctx}' contains "
f"path segment '{other_ctx}': {path_str}"
)
@given(data=st.data())
@settings(max_examples=80, deadline=10000)
def test_prepared_repo_with_multiple_cross_context_leak_types(
self, data: st.DataObject, tmp_path_factory
) -> None:
"""For any context and files containing MULTIPLE types of cross-context
references (paths, env vars, config refs, comments simultaneously),
prepare_team_repo must still produce a fully isolated repo.
"""
tmp_dir = tmp_path_factory.mktemp("prop25_multi")
fm, monorepo_root = _create_federation_manager(tmp_dir)
own_ctx, other_ctx = data.draw(context_pair())
ctx_upper = other_ctx.upper()
# Create a file that has ALL types of leaks
multi_leak_content = (
f"# Reference to {other_ctx} team project\n"
f'import_path = "{other_ctx}/module/utils"\n'
f"{ctx_upper}_API_KEY=secret123\n"
f"context: {other_ctx}\n"
f"clean_value = 42\n"
)
ctx_dir = monorepo_root / own_ctx
(ctx_dir / "leaky_file.py").write_text(multi_leak_content, encoding="utf-8")
team_repo_path = fm.prepare_team_repo(own_ctx)
report = fm.verify_isolation(team_repo_path, own_ctx)
assert report.is_isolated, (
f"Team-Repo for '{own_ctx}' still has leaks after sanitizing "
f"multiple leak types referencing '{other_ctx}'. "
f"Leaks: {[(l.file_path, l.leak_type, l.leaked_context) for l in report.leaks]}"
)
@given(data=st.data())
@settings(max_examples=80, deadline=10000)
def test_prepared_repo_with_config_references_passes_isolation(
self, data: st.DataObject, tmp_path_factory
) -> None:
"""For any context and files containing config references to other
contexts (like context: bahn in YAML), prepare_team_repo must produce
a repo that passes verify_isolation.
"""
tmp_dir = tmp_path_factory.mktemp("prop25_config")
fm, monorepo_root = _create_federation_manager(tmp_dir)
own_ctx, other_ctx = data.draw(context_pair())
content = data.draw(cross_context_config_ref_content(other_ctx))
filename = data.draw(file_name_with_extension())
ctx_dir = monorepo_root / own_ctx
(ctx_dir / filename).write_text(content, encoding="utf-8")
team_repo_path = fm.prepare_team_repo(own_ctx)
report = fm.verify_isolation(team_repo_path, own_ctx)
assert report.is_isolated, (
f"Team-Repo for '{own_ctx}' has config ref leaks to '{other_ctx}'. "
f"Leaks: {[(l.file_path, l.leak_type, l.leaked_context) for l in report.leaks]}"
)
@given(data=st.data())
@settings(max_examples=80, deadline=10000)
def test_prepared_repo_with_comment_references_passes_isolation(
self, data: st.DataObject, tmp_path_factory
) -> None:
"""For any context and files containing comments referencing other
contexts, prepare_team_repo must produce a repo that passes verify_isolation.
"""
tmp_dir = tmp_path_factory.mktemp("prop25_comment")
fm, monorepo_root = _create_federation_manager(tmp_dir)
own_ctx, other_ctx = data.draw(context_pair())
content = data.draw(cross_context_comment_content(other_ctx))
filename = data.draw(file_name_with_extension())
ctx_dir = monorepo_root / own_ctx
(ctx_dir / filename).write_text(content, encoding="utf-8")
team_repo_path = fm.prepare_team_repo(own_ctx)
report = fm.verify_isolation(team_repo_path, own_ctx)
assert report.is_isolated, (
f"Team-Repo for '{own_ctx}' has comment leaks to '{other_ctx}'. "
f"Leaks: {[(l.file_path, l.leak_type, l.leaked_context) for l in report.leaks]}"
)
@@ -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
@@ -0,0 +1,547 @@
"""Unit-Tests für das hooks-Modul.
Tests für:
- HookManager: Installation, Deinstallation, Validierung
- Read-Only-Schutz: Prüfung gestaged Dateien in geschützten Pfaden
- Secret-Verschlüsselungs-Prüfung: Unverschlüsselte Secrets blockieren
- Integration mit ContextGuard und SecretEncryptionManager
- Hook-Installation bei `init`
"""
from __future__ import annotations
import os
import stat
from pathlib import Path
import pytest
import yaml
from monorepo.hooks import HookManager, install_hooks, uninstall_hooks
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def monorepo_root(tmp_path: Path) -> Path:
"""Erstellt ein minimales Monorepo mit .git-Verzeichnis und Konfiguration."""
root = tmp_path / "monorepo"
root.mkdir()
# Git-Verzeichnis simulieren
git_dir = root / ".git"
git_dir.mkdir()
(git_dir / "hooks").mkdir()
# Kontextordner
for ctx in ["privat", "dhive", "bahn", "shared"]:
(root / ctx).mkdir()
# shared/config
config_dir = root / "shared" / "config"
config_dir.mkdir(parents=True)
# repos.yaml mit einem Read-Only-Repo
repos_config = {
"repos": [
{
"name": "symphony-spec",
"url": "https://github.com/openai/symphony",
"mode": "read-only",
"target": "shared/references/symphony",
"pinned": "v1.0.0",
"mechanism": "subtree",
},
{
"name": "db-wissensdatenbank",
"url": "https://gitlab.2700.2db.it/team/db-wissen.git",
"mode": "upstream",
"target": "bahn/db-wissensdatenbank",
"pinned": "main",
"mechanism": "subtree",
},
]
}
with open(config_dir / "repos.yaml", "w", encoding="utf-8") as f:
yaml.dump(repos_config, f)
# access-config.yaml (für ContextGuard)
access_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": {
"env_file": "shared/.env",
"allowed_shared": ["*"],
},
}
}
with open(config_dir / "access-config.yaml", "w", encoding="utf-8") as f:
yaml.dump(access_config, f)
# .gitattributes im privat-Kontext (mit git-crypt-Filter)
privat_ga = root / "privat" / ".gitattributes"
privat_ga.write_text(
"# git-crypt Filter für Kontext: privat\n"
".env filter=git-crypt-privat diff=git-crypt-privat\n"
"*.pem filter=git-crypt-privat diff=git-crypt-privat\n"
"*.key filter=git-crypt-privat diff=git-crypt-privat\n"
"*token* filter=git-crypt-privat diff=git-crypt-privat\n"
"*secret* filter=git-crypt-privat diff=git-crypt-privat\n",
encoding="utf-8",
)
return root
@pytest.fixture
def hook_manager(monorepo_root: Path) -> HookManager:
"""Erstellt einen HookManager für das Test-Monorepo."""
return HookManager(monorepo_root=monorepo_root)
# ---------------------------------------------------------------------------
# Tests: Installation und Deinstallation
# ---------------------------------------------------------------------------
class TestHookInstallation:
"""Tests für die Hook-Installation und -Deinstallation."""
def test_install_creates_hook_file(self, hook_manager: HookManager, monorepo_root: Path):
"""Hook-Installation erstellt die pre-commit Datei."""
hook_manager.install_hooks()
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
assert hook_path.exists()
def test_hook_is_executable(self, hook_manager: HookManager, monorepo_root: Path):
"""Installierter Hook hat Ausführungsrechte (Unix) bzw. existiert (Windows)."""
hook_manager.install_hooks()
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
if os.name != "nt":
mode = hook_path.stat().st_mode
assert mode & stat.S_IXUSR # Owner execute
else:
# Windows unterstützt keine Unix-Permissions, nur Existenz prüfen
assert hook_path.exists()
def test_hook_contains_shebang(self, hook_manager: HookManager, monorepo_root: Path):
"""Installierter Hook beginnt mit Shebang."""
hook_manager.install_hooks()
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
assert content.startswith("#!/bin/sh")
def test_hook_contains_markers(self, hook_manager: HookManager, monorepo_root: Path):
"""Installierter Hook enthält Start- und End-Marker."""
hook_manager.install_hooks()
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
assert "# === Monorepo Integrated Pre-Commit Hook (auto-generiert) ===" in content
assert "# === Ende Monorepo Integrated Pre-Commit Hook ===" in content
def test_hook_contains_readonly_section(self, hook_manager: HookManager, monorepo_root: Path):
"""Installierter Hook enthält Read-Only-Schutz-Abschnitt."""
hook_manager.install_hooks()
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
assert "Read-Only-Repos Schutz" in content
assert "shared/references/symphony" in content
def test_hook_contains_secrets_section(self, hook_manager: HookManager, monorepo_root: Path):
"""Installierter Hook enthält Secret-Prüfungs-Abschnitt."""
hook_manager.install_hooks()
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
assert "Unverschlüsselte Secrets Prüfung" in content
assert "check_encrypted" in content
assert "is_secret_file" in content
def test_hook_preserves_existing_content(self, hook_manager: HookManager, monorepo_root: Path):
"""Installation bewahrt bestehende Hook-Inhalte."""
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
hook_path.write_text("#!/bin/sh\n# Custom Hook Logic\necho 'custom'\n", encoding="utf-8")
hook_manager.install_hooks()
content = hook_path.read_text(encoding="utf-8")
# Bestehender Inhalt muss erhalten bleiben
assert "# Custom Hook Logic" in content
assert "echo 'custom'" in content
# Neuer Monorepo-Abschnitt muss vorhanden sein
assert "Monorepo Integrated Pre-Commit Hook" in content
def test_hook_update_replaces_existing_section(self, hook_manager: HookManager, monorepo_root: Path):
"""Erneute Installation aktualisiert nur den Monorepo-Abschnitt."""
hook_manager.install_hooks()
# Zweite Installation (simuliert Update)
hook_manager.install_hooks()
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
# Sollte genau einmal den Marker enthalten (kein doppelter Abschnitt)
assert content.count("# === Monorepo Integrated Pre-Commit Hook") == 1
def test_uninstall_removes_section(self, hook_manager: HookManager, monorepo_root: Path):
"""Deinstallation entfernt den Monorepo-Abschnitt."""
hook_manager.install_hooks()
hook_manager.uninstall_hooks()
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
# Hook-Datei sollte entfernt sein (war nur Monorepo-Inhalt)
assert not hook_path.exists()
def test_uninstall_preserves_custom_content(self, hook_manager: HookManager, monorepo_root: Path):
"""Deinstallation bewahrt benutzerdefinierte Hook-Inhalte."""
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
hook_path.write_text("#!/bin/sh\n# Custom Logic\necho 'keep'\n", encoding="utf-8")
hook_manager.install_hooks()
hook_manager.uninstall_hooks()
assert hook_path.exists()
content = hook_path.read_text(encoding="utf-8")
assert "# Custom Logic" in content
assert "Monorepo Integrated Pre-Commit Hook" not in content
def test_is_hook_installed(self, hook_manager: HookManager):
"""is_hook_installed gibt korrekt True/False zurück."""
assert not hook_manager.is_hook_installed()
hook_manager.install_hooks()
assert hook_manager.is_hook_installed()
def test_uninstall_when_no_hook(self, hook_manager: HookManager):
"""Deinstallation ohne vorhandenen Hook läuft ohne Fehler."""
# Sollte keine Exception werfen
hook_manager.uninstall_hooks()
# ---------------------------------------------------------------------------
# Tests: Read-Only-Schutz
# ---------------------------------------------------------------------------
class TestReadOnlyProtection:
"""Tests für die Read-Only-Repo-Schutzprüfung."""
def test_readonly_path_is_detected(self, hook_manager: HookManager):
"""Gestagte Dateien in Read-Only-Repos werden blockiert."""
errors = hook_manager.validate_staged_files([
"shared/references/symphony/README.md",
])
assert len(errors) == 1
assert "read-only" in errors[0]
assert "symphony" in errors[0]
def test_readonly_subdirectory_is_protected(self, hook_manager: HookManager):
"""Dateien in Unterverzeichnissen von Read-Only-Repos werden blockiert."""
errors = hook_manager.validate_staged_files([
"shared/references/symphony/src/main.py",
])
assert len(errors) == 1
assert "read-only" in errors[0]
def test_non_readonly_repo_is_allowed(self, hook_manager: HookManager):
"""Dateien in Upstream-Repos werden nicht blockiert (nur Read-Only)."""
errors = hook_manager.validate_staged_files([
"bahn/db-wissensdatenbank/docs/README.md",
])
# Upstream-Repos sollten nicht blockiert werden
readonly_errors = [e for e in errors if "read-only" in e]
assert len(readonly_errors) == 0
def test_normal_files_are_allowed(self, hook_manager: HookManager):
"""Normale Dateien außerhalb von Read-Only-Repos werden durchgelassen."""
errors = hook_manager.validate_staged_files([
"privat/my-project/src/main.py",
"dhive/tooling/config.yaml",
])
assert len(errors) == 0
def test_multiple_violations_reported(self, hook_manager: HookManager):
"""Mehrere Verstöße werden alle gemeldet."""
errors = hook_manager.validate_staged_files([
"shared/references/symphony/file1.py",
"shared/references/symphony/file2.py",
])
assert len(errors) == 2
def test_protected_paths_returned(self, hook_manager: HookManager):
"""get_protected_paths gibt die konfigurierten Read-Only-Pfade zurück."""
paths = hook_manager.get_protected_paths()
assert "shared/references/symphony" in paths
# Upstream-Repos sollten NICHT in der geschützten Liste sein
assert "bahn/db-wissensdatenbank" not in paths
# ---------------------------------------------------------------------------
# Tests: Secret-Verschlüsselungs-Prüfung
# ---------------------------------------------------------------------------
class TestSecretEncryptionCheck:
"""Tests für die Prüfung unverschlüsselter Secrets."""
def test_env_file_without_encryption_blocked(self, hook_manager: HookManager, monorepo_root: Path):
"""Unverschlüsselte .env-Datei wird blockiert (kein Filter konfiguriert)."""
# Erstelle eine .env im dhive-Kontext OHNE git-crypt-Filter
env_path = monorepo_root / "dhive" / ".env"
env_path.write_text("SECRET_KEY=abc123\n", encoding="utf-8")
errors = hook_manager.validate_staged_files(["dhive/.env"])
assert len(errors) == 1
assert "nicht verschlüsselt" in errors[0]
def test_env_file_with_filter_allowed(self, hook_manager: HookManager, monorepo_root: Path):
"""Unverschlüsselte .env-Datei mit git-crypt-Filter wird durchgelassen."""
# privat/.env hat einen git-crypt-Filter in .gitattributes
env_path = monorepo_root / "privat" / ".env"
env_path.write_text("MY_SECRET=xyz\n", encoding="utf-8")
errors = hook_manager.validate_staged_files(["privat/.env"])
assert len(errors) == 0
def test_pem_file_without_filter_blocked(self, hook_manager: HookManager, monorepo_root: Path):
"""Unverschlüsselte .pem-Datei ohne Filter wird blockiert."""
pem_path = monorepo_root / "dhive" / "cert.pem"
pem_path.write_text("-----BEGIN CERTIFICATE-----\n...\n", encoding="utf-8")
errors = hook_manager.validate_staged_files(["dhive/cert.pem"])
assert len(errors) == 1
assert "nicht verschlüsselt" in errors[0]
def test_pem_file_with_filter_allowed(self, hook_manager: HookManager, monorepo_root: Path):
"""Unverschlüsselte .pem-Datei mit git-crypt-Filter wird durchgelassen."""
pem_path = monorepo_root / "privat" / "server.pem"
pem_path.write_text("-----BEGIN RSA PRIVATE KEY-----\n...\n", encoding="utf-8")
errors = hook_manager.validate_staged_files(["privat/server.pem"])
assert len(errors) == 0
def test_key_file_detected(self, hook_manager: HookManager, monorepo_root: Path):
"""*.key-Dateien werden als Secrets erkannt."""
key_path = monorepo_root / "dhive" / "private.key"
key_path.write_text("key-data\n", encoding="utf-8")
errors = hook_manager.validate_staged_files(["dhive/private.key"])
assert len(errors) == 1
def test_token_file_detected(self, hook_manager: HookManager, monorepo_root: Path):
"""Dateien mit 'token' im Namen werden als Secrets erkannt."""
token_path = monorepo_root / "dhive" / "api-token.txt"
token_path.write_text("token123\n", encoding="utf-8")
errors = hook_manager.validate_staged_files(["dhive/api-token.txt"])
assert len(errors) == 1
def test_secret_file_detected(self, hook_manager: HookManager, monorepo_root: Path):
"""Dateien mit 'secret' im Namen werden als Secrets erkannt."""
secret_path = monorepo_root / "dhive" / "my-secret-config.yaml"
secret_path.write_text("data: secret\n", encoding="utf-8")
errors = hook_manager.validate_staged_files(["dhive/my-secret-config.yaml"])
assert len(errors) == 1
def test_encrypted_file_is_allowed(self, hook_manager: HookManager, monorepo_root: Path):
"""Verschlüsselte Dateien (mit git-crypt-Header) werden durchgelassen."""
# Simuliere eine git-crypt-verschlüsselte Datei
enc_path = monorepo_root / "dhive" / ".env"
enc_path.write_bytes(b"\x00GITCRYPT\x00" + b"encrypted-data")
errors = hook_manager.validate_staged_files(["dhive/.env"])
assert len(errors) == 0
def test_normal_files_not_flagged(self, hook_manager: HookManager, monorepo_root: Path):
"""Normale Dateien (nicht Secret-Patterns) werden nicht geprüft."""
py_path = monorepo_root / "privat" / "main.py"
py_path.write_text("print('hello')\n", encoding="utf-8")
errors = hook_manager.validate_staged_files(["privat/main.py"])
assert len(errors) == 0
def test_gitattributes_pattern_matching(self, hook_manager: HookManager):
"""Pattern-Matching für .gitattributes funktioniert korrekt."""
# Interne Methode testen
assert hook_manager._pattern_matches_file(".env", ".env")
assert hook_manager._pattern_matches_file("*.pem", "server.pem")
assert hook_manager._pattern_matches_file("*.key", "private.key")
assert hook_manager._pattern_matches_file("*token*", "api-token.txt")
assert hook_manager._pattern_matches_file("*secret*", "my-secret-config.yaml")
# Negative Cases
assert not hook_manager._pattern_matches_file(".env", ".envrc")
assert not hook_manager._pattern_matches_file("*.pem", "file.txt")
# ---------------------------------------------------------------------------
# Tests: Convenience-Funktionen und CLI-Integration
# ---------------------------------------------------------------------------
class TestConvenienceFunctions:
"""Tests für install_hooks/uninstall_hooks Convenience-Funktionen."""
def test_install_hooks_function(self, monorepo_root: Path):
"""install_hooks()-Funktion erstellt den Hook erfolgreich."""
install_hooks(monorepo_root)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
assert hook_path.exists()
content = hook_path.read_text(encoding="utf-8")
assert "Monorepo Integrated Pre-Commit Hook" in content
def test_uninstall_hooks_function(self, monorepo_root: Path):
"""uninstall_hooks()-Funktion entfernt den Hook."""
install_hooks(monorepo_root)
uninstall_hooks(monorepo_root)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
assert not hook_path.exists()
def test_install_without_access_config(self, tmp_path: Path):
"""Installation funktioniert auch ohne access-config.yaml."""
root = tmp_path / "bare-mono"
root.mkdir()
(root / ".git" / "hooks").mkdir(parents=True)
(root / "shared" / "config").mkdir(parents=True)
# Nur repos.yaml, keine access-config.yaml
repos = {"repos": []}
with open(root / "shared" / "config" / "repos.yaml", "w") as f:
yaml.dump(repos, f)
install_hooks(root)
hook_path = root / ".git" / "hooks" / "pre-commit"
assert hook_path.exists()
def test_install_without_repos_yaml(self, tmp_path: Path):
"""Installation funktioniert auch ohne repos.yaml (leere Schutzliste)."""
root = tmp_path / "bare-mono"
root.mkdir()
(root / ".git" / "hooks").mkdir(parents=True)
(root / "shared" / "config").mkdir(parents=True)
install_hooks(root)
hook_path = root / ".git" / "hooks" / "pre-commit"
assert hook_path.exists()
content = hook_path.read_text(encoding="utf-8")
assert "Keine Read-Only-Repos konfiguriert" in content
# ---------------------------------------------------------------------------
# Tests: ContextGuard-Integration
# ---------------------------------------------------------------------------
class TestContextGuardIntegration:
"""Tests für die Integration mit dem ContextGuard."""
def test_hook_manager_with_context_guard(self, monorepo_root: Path):
"""HookManager funktioniert mit eingebundenem ContextGuard."""
from monorepo.security import ContextGuard
guard = ContextGuard(monorepo_root)
hm = HookManager(
monorepo_root=monorepo_root,
context_guard=guard,
)
# Normale Datei sollte kein Problem sein
errors = hm.validate_staged_files(["privat/project/main.py"])
assert len(errors) == 0
def test_hook_manager_without_context_guard(self, monorepo_root: Path):
"""HookManager funktioniert auch ohne ContextGuard."""
hm = HookManager(monorepo_root=monorepo_root)
errors = hm.validate_staged_files(["privat/project/main.py"])
assert len(errors) == 0
# ---------------------------------------------------------------------------
# Tests: SecretEncryptionManager-Integration
# ---------------------------------------------------------------------------
class TestEncryptionManagerIntegration:
"""Tests für die Integration mit dem SecretEncryptionManager."""
def test_hook_manager_with_encryption_manager(self, monorepo_root: Path):
"""HookManager funktioniert mit eingebundenem SecretEncryptionManager."""
from monorepo.encryption import SecretEncryptionManager
from monorepo.models import MachineContext
mc = MachineContext(
name="test-machine",
description="Test",
authorized_contexts=["privat", "dhive", "bahn"],
key_source="keyring",
)
enc = SecretEncryptionManager(monorepo_root, mc)
hm = HookManager(
monorepo_root=monorepo_root,
encryption_manager=enc,
)
hm.install_hooks()
assert hm.is_hook_installed()
# ---------------------------------------------------------------------------
# Tests: is_secret_file Erkennung
# ---------------------------------------------------------------------------
class TestSecretFileDetection:
"""Tests für die _is_secret_file Methode."""
def test_env_detected(self, hook_manager: HookManager):
"""Standard .env wird erkannt."""
assert hook_manager._is_secret_file("privat/.env")
assert hook_manager._is_secret_file("dhive/.env")
assert hook_manager._is_secret_file(".env")
def test_env_prefix_detected(self, hook_manager: HookManager):
""".env.local und ähnliche werden erkannt."""
assert hook_manager._is_secret_file("privat/.env.local")
assert hook_manager._is_secret_file(".env.production")
def test_pem_detected(self, hook_manager: HookManager):
""".pem-Dateien werden erkannt."""
assert hook_manager._is_secret_file("certs/server.pem")
assert hook_manager._is_secret_file("privat/my.pem")
def test_key_detected(self, hook_manager: HookManager):
""".key-Dateien werden erkannt."""
assert hook_manager._is_secret_file("privat/ssl.key")
assert hook_manager._is_secret_file("private.key")
def test_token_detected(self, hook_manager: HookManager):
"""Dateien mit 'token' werden erkannt."""
assert hook_manager._is_secret_file("api-token.txt")
assert hook_manager._is_secret_file("github_token")
assert hook_manager._is_secret_file("TOKEN_FILE")
def test_secret_detected(self, hook_manager: HookManager):
"""Dateien mit 'secret' werden erkannt."""
assert hook_manager._is_secret_file("my-secret.yaml")
assert hook_manager._is_secret_file("SECRET_CONFIG")
def test_normal_files_not_detected(self, hook_manager: HookManager):
"""Normale Dateien werden nicht als Secrets erkannt."""
assert not hook_manager._is_secret_file("main.py")
assert not hook_manager._is_secret_file("README.md")
assert not hook_manager._is_secret_file("package.json")
assert not hook_manager._is_secret_file("config.yaml")
@@ -0,0 +1,360 @@
"""Property-basierte Tests: ETL-Fehlerresilienz bewahrt erfolgreiche Artefakte.
**Validates: Requirements 3.20**
Property 10: ETL-Fehlerresilienz bewahrt erfolgreiche Artefakte
- IF die ETL-Pipeline bei der Verarbeitung einer Quelle fehlschlägt,
THEN muss sie: den Fehler mit Quellidentifikator und Zeitstempel protokollieren,
bereits erfolgreich verarbeitete Artefakte beibehalten und die fehlgeschlagene
Quelle beim nächsten Durchlauf erneut verarbeiten.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import yaml
from hypothesis import given, settings
from hypothesis import strategies as st
from monorepo.knowledge.etl import ETLPipeline, IngestResult
from monorepo.knowledge.index import YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource
from monorepo.knowledge.store import KnowledgeStore
from monorepo.models import ScopeConfig
# --- Constants ---
ARTIFACT_TYPES = ["decision", "note", "meeting", "reference", "pattern"]
CONTEXTS = ["privat", "dhive", "bahn", "shared"]
SAMPLE_TAGS = ["api", "architecture", "python", "devops", "testing", "design"]
# --- Strategies ---
@st.composite
def valid_artifact_content(draw: st.DrawFn) -> str:
"""Generates content for a valid artifact (with some non-empty text)."""
num_lines = draw(st.integers(min_value=1, max_value=5))
lines = []
for _ in range(num_lines):
line = draw(st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "Zs")),
min_size=5,
max_size=50,
))
lines.append(line)
return "\n".join(lines)
@st.composite
def valid_artifact_metadata(draw: st.DrawFn) -> dict[str, object]:
"""Generates valid YAML frontmatter metadata for an artifact."""
title = draw(st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "Zs")),
min_size=3,
max_size=40,
))
artifact_type = draw(st.sampled_from(ARTIFACT_TYPES))
tags = draw(st.lists(st.sampled_from(SAMPLE_TAGS), min_size=1, max_size=3, unique=True))
return {
"type": artifact_type,
"title": title,
"tags": tags,
}
@st.composite
def invalid_file_content(draw: st.DrawFn) -> str:
"""Generates content that will fail frontmatter parsing.
Produces files without valid YAML frontmatter:
- No --- delimiters at all (plain text or heading only)
- Only one --- delimiter (no closing)
These are the cases that MarkdownSource handles gracefully
(ValueError from parse_frontmatter), keeping other artifacts intact.
"""
strategy_choice = draw(st.integers(min_value=0, max_value=1))
if strategy_choice == 0:
# No frontmatter at all - just plain text
text = draw(st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "Zs")),
min_size=10,
max_size=100,
))
return f"# Just a heading\n{text}"
else:
# Only opening delimiter, no closing
text = draw(st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "Zs")),
min_size=10,
max_size=50,
))
return f"---\ntitle: broken\n{text}"
@st.composite
def mixed_artifact_batch(draw: st.DrawFn) -> tuple[int, int]:
"""Generates counts for valid and invalid artifacts.
Returns (valid_count, invalid_count) with at least 1 valid and 1 invalid.
"""
valid_count = draw(st.integers(min_value=1, max_value=5))
invalid_count = draw(st.integers(min_value=1, max_value=5))
return (valid_count, invalid_count)
# --- Helpers ---
def _create_mixed_source_directory(
tmp_dir: Path,
valid_count: int,
invalid_count: int,
draw_valid_meta: list[dict[str, object]],
draw_valid_content: list[str],
draw_invalid_content: list[str],
) -> Path:
"""Creates a source directory with a mix of valid and invalid files.
Valid files have proper YAML frontmatter.
Invalid files have corrupted/missing frontmatter.
"""
source_dir = tmp_dir / "source"
source_dir.mkdir(parents=True, exist_ok=True)
# Create valid files
for i in range(valid_count):
meta = draw_valid_meta[i]
content = draw_valid_content[i]
frontmatter = yaml.dump(meta, default_flow_style=False, allow_unicode=True, sort_keys=False)
file_content = f"---\n{frontmatter}---\n{content}"
file_path = source_dir / f"valid-{i}.md"
file_path.write_text(file_content, encoding="utf-8")
# Create invalid files
for i in range(invalid_count):
content = draw_invalid_content[i]
file_path = source_dir / f"invalid-{i}.md"
file_path.write_text(content, encoding="utf-8")
return source_dir
def _create_knowledge_store(tmp_dir: Path) -> KnowledgeStore:
"""Creates a KnowledgeStore in a temporary directory."""
store_path = tmp_dir / "knowledge-store"
store_path.mkdir(parents=True, exist_ok=True)
scope_config = ScopeConfig(scopes={
"privat": {"scope": "privat", "paths": ["privat/"]},
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
"shared": {"scope": "shared", "paths": ["shared/"]},
})
return KnowledgeStore(base_path=store_path, scope_config=scope_config)
# --- Tests ---
class TestProperty10ETLErrorResilience:
"""Property 10: ETL-Fehlerresilienz bewahrt erfolgreiche Artefakte.
**Validates: Requirements 3.20**
If the ETL pipeline fails when processing a source, it must:
1. Log the error with source identifier (file path)
2. Retain already-successfully-processed artifacts
3. Mark the failed source for retry on the next run
"""
@given(
counts=mixed_artifact_batch(),
context=st.sampled_from(CONTEXTS),
)
@settings(max_examples=50, deadline=None)
def test_valid_artifacts_retained_despite_invalid_files(
self,
counts: tuple[int, int],
context: str,
) -> None:
"""All N valid artifacts must be present in the index even when
M invalid artifacts also exist in the source directory."""
valid_count, invalid_count = counts
with tempfile.TemporaryDirectory(prefix="monorepo_pbt_") as tmp:
tmp_dir = Path(tmp)
store = _create_knowledge_store(tmp_dir)
# Generate valid artifacts
valid_metas = []
valid_contents = []
for i in range(valid_count):
meta = {"type": "note", "title": f"Valid Article {i}", "tags": ["testing"]}
valid_metas.append(meta)
valid_contents.append(f"Content for valid article {i}\nSome more text here.")
# Generate invalid file contents (no valid frontmatter)
invalid_contents = []
for i in range(invalid_count):
# Alternate between different kinds of invalid content
if i % 2 == 0:
invalid_contents.append(f"# No frontmatter\nJust plain text {i}")
else:
invalid_contents.append(f"---\ntitle: broken {i}\nUnclosed frontmatter")
source_dir = _create_mixed_source_directory(
tmp_dir, valid_count, invalid_count,
valid_metas, valid_contents, invalid_contents,
)
# Run the ETL pipeline
source = MarkdownSource(directory=source_dir)
result = store.ingest(source, context=context)
# Verify: All N valid artifacts are present in the index
index_entries = store.get_index(scope=context)
assert len(index_entries) == valid_count, (
f"Expected {valid_count} entries in index, got {len(index_entries)}. "
f"Valid artifacts must be retained despite {invalid_count} invalid files."
)
@given(
counts=mixed_artifact_batch(),
context=st.sampled_from(CONTEXTS),
)
@settings(max_examples=50, deadline=None)
def test_errors_list_has_entries_for_invalid_files(
self,
counts: tuple[int, int],
context: str,
) -> None:
"""The errors list must have M entries, one per invalid file."""
valid_count, invalid_count = counts
with tempfile.TemporaryDirectory(prefix="monorepo_pbt_") as tmp:
tmp_dir = Path(tmp)
store = _create_knowledge_store(tmp_dir)
# Generate valid artifacts
valid_metas = []
valid_contents = []
for i in range(valid_count):
meta = {"type": "decision", "title": f"Good Doc {i}", "tags": ["api"]}
valid_metas.append(meta)
valid_contents.append(f"Decision content {i}.")
# Generate invalid file contents
invalid_contents = []
for i in range(invalid_count):
invalid_contents.append(f"# No frontmatter at all for file {i}\nPlain text.")
source_dir = _create_mixed_source_directory(
tmp_dir, valid_count, invalid_count,
valid_metas, valid_contents, invalid_contents,
)
# Run the ETL pipeline
source = MarkdownSource(directory=source_dir)
result = store.ingest(source, context=context)
# Verify: Errors list has M entries (one per invalid file)
assert len(result.errors) == invalid_count, (
f"Expected {invalid_count} errors, got {len(result.errors)}. "
f"Each invalid file must produce exactly one error entry."
)
@given(
counts=mixed_artifact_batch(),
context=st.sampled_from(CONTEXTS),
)
@settings(max_examples=50, deadline=None)
def test_each_error_has_file_path(
self,
counts: tuple[int, int],
context: str,
) -> None:
"""Each error entry must contain a file path identifying the failed source."""
valid_count, invalid_count = counts
with tempfile.TemporaryDirectory(prefix="monorepo_pbt_") as tmp:
tmp_dir = Path(tmp)
store = _create_knowledge_store(tmp_dir)
valid_metas = []
valid_contents = []
for i in range(valid_count):
meta = {"type": "pattern", "title": f"Pattern {i}", "tags": ["design"]}
valid_metas.append(meta)
valid_contents.append(f"Pattern description {i}.")
invalid_contents = []
for i in range(invalid_count):
invalid_contents.append(f"No valid frontmatter here {i}")
source_dir = _create_mixed_source_directory(
tmp_dir, valid_count, invalid_count,
valid_metas, valid_contents, invalid_contents,
)
source = MarkdownSource(directory=source_dir)
result = store.ingest(source, context=context)
# Verify: Each error has a file_path that is non-empty
for error in result.errors:
assert error.file_path, (
f"Error entry must have a non-empty file_path. "
f"Got error with empty path: {error}"
)
# file_path should point to an actual path (contains path separator or filename)
assert len(error.file_path) > 0, (
f"Error file_path must identify the source. Got: '{error.file_path}'"
)
@given(
counts=mixed_artifact_batch(),
context=st.sampled_from(CONTEXTS),
)
@settings(max_examples=50, deadline=None)
def test_processed_count_equals_valid_count(
self,
counts: tuple[int, int],
context: str,
) -> None:
"""result.processed must equal N (only valid ones counted as processed)."""
valid_count, invalid_count = counts
with tempfile.TemporaryDirectory(prefix="monorepo_pbt_") as tmp:
tmp_dir = Path(tmp)
store = _create_knowledge_store(tmp_dir)
valid_metas = []
valid_contents = []
for i in range(valid_count):
meta = {"type": "reference", "title": f"Ref {i}", "tags": ["cloud"]}
valid_metas.append(meta)
valid_contents.append(f"Reference material {i}.")
invalid_contents = []
for i in range(invalid_count):
invalid_contents.append(f"---\ntitle: unclosed {i}\nBroken file content")
source_dir = _create_mixed_source_directory(
tmp_dir, valid_count, invalid_count,
valid_metas, valid_contents, invalid_contents,
)
source = MarkdownSource(directory=source_dir)
result = store.ingest(source, context=context)
# Verify: result.processed == N (only valid ones counted)
assert result.processed == valid_count, (
f"Expected processed={valid_count} (valid files only), "
f"got processed={result.processed}. "
f"Invalid files must not be counted as processed."
)
@@ -0,0 +1,296 @@
"""Property-basierte Tests für die Volltextsuche im KnowledgeStore.
**Validates: Requirements 3.7**
Property 9: Volltextsuche findet indexierte Inhalte
- For any indexed artifact with content, a search query matching part of its
title, tags, or summary must return that artifact in the results (provided
the artifact's scope is in allowed_scopes).
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.store import KnowledgeStore, SearchResult
from monorepo.models import ScopeConfig
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
ALL_SCOPES = ["privat", "dhive", "bahn", "shared"]
ARTIFACT_TYPES = ["decision", "note", "meeting", "reference", "pattern"]
# Words that form valid, searchable content (lowercase, no whitespace)
SAMPLE_WORDS = [
"api", "kubernetes", "docker", "python", "design",
"architecture", "cloud", "deployment", "service", "testing",
"database", "security", "network", "monitoring", "logging",
"pipeline", "migration", "config", "infra", "tooling",
"workflow", "automation", "integration", "analytics", "platform",
]
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
@st.composite
def index_entry_strategy(draw: st.DrawFn) -> IndexEntry:
"""Generates a random but well-formed IndexEntry."""
scope = draw(st.sampled_from(ALL_SCOPES))
artifact_type = draw(st.sampled_from(ARTIFACT_TYPES))
# Generate title from sample words (guaranteed searchable content)
title_words = draw(st.lists(
st.sampled_from(SAMPLE_WORDS), min_size=1, max_size=4
))
title = " ".join(title_words).title()
# Generate tags from sample words
tags = draw(st.lists(
st.sampled_from(SAMPLE_WORDS), min_size=1, max_size=5, unique=True
))
# Generate summary from sample words
summary_words = draw(st.lists(
st.sampled_from(SAMPLE_WORDS), min_size=2, max_size=8
))
summary = " ".join(summary_words)
# Generate unique ID
name_suffix = draw(st.integers(min_value=1, max_value=9999))
entry_id = f"{scope}/{artifact_type}s/entry-{name_suffix}"
return IndexEntry(
id=entry_id,
title=title,
type=artifact_type,
tags=tags,
scope=scope,
summary=summary,
path=f"{entry_id}.md",
content_hash=f"sha256:{draw(st.text(alphabet='0123456789abcdef', min_size=8, max_size=8))}",
links=[],
)
@st.composite
def entry_with_query_from_title(draw: st.DrawFn) -> tuple[IndexEntry, str]:
"""Generates an IndexEntry and a query substring drawn from its title."""
entry = draw(index_entry_strategy())
# The title is composed of sample words joined with spaces and title-cased.
# Pick a substring: use a word that appears in the title (lowercased for search).
title_lower = entry.title.lower()
# Pick a starting position and extract a substring of at least 3 chars
assume(len(title_lower) >= 3)
start = draw(st.integers(min_value=0, max_value=len(title_lower) - 3))
end = draw(st.integers(min_value=start + 3, max_value=len(title_lower)))
query = title_lower[start:end]
# Ensure the query is non-empty and non-whitespace
assume(query.strip() != "")
return entry, query.strip()
@st.composite
def entry_with_query_from_tag(draw: st.DrawFn) -> tuple[IndexEntry, str]:
"""Generates an IndexEntry and a query substring drawn from one of its tags."""
entry = draw(index_entry_strategy())
assume(len(entry.tags) > 0)
tag = draw(st.sampled_from(entry.tags))
# Tags are single words from SAMPLE_WORDS - use as query directly
assume(len(tag) >= 2)
return entry, tag
@st.composite
def entry_with_query_from_summary(draw: st.DrawFn) -> tuple[IndexEntry, str]:
"""Generates an IndexEntry and a query substring drawn from its summary."""
entry = draw(index_entry_strategy())
summary_lower = entry.summary.lower()
assume(len(summary_lower) >= 3)
start = draw(st.integers(min_value=0, max_value=len(summary_lower) - 3))
end = draw(st.integers(min_value=start + 3, max_value=len(summary_lower)))
query = summary_lower[start:end]
assume(query.strip() != "")
return entry, query.strip()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_scope_config() -> ScopeConfig:
"""Creates a standard ScopeConfig for testing."""
return ScopeConfig(scopes={
"privat": {"scope": "privat", "paths": ["privat/"]},
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
"shared": {"scope": "shared", "paths": ["shared/"]},
})
def _build_store(entries: list[IndexEntry]) -> KnowledgeStore:
"""Creates a KnowledgeStore populated with given entries in-memory."""
scope_config = _build_scope_config()
tmp = Path(tempfile.mkdtemp())
store = KnowledgeStore(tmp, scope_config)
for entry in entries:
store.index.update_entry(entry)
return store
# ---------------------------------------------------------------------------
# Property Tests
# ---------------------------------------------------------------------------
class TestProperty9FulltextSearchFindsIndexedContent:
"""Property 9: Volltextsuche findet indexierte Inhalte.
**Validates: Requirements 3.7**
For any indexed artifact with content, a search query matching part
of its title, tags, or summary must return that artifact in the results
(when the artifact's scope is in allowed_scopes).
"""
@given(data=st.data())
@settings(max_examples=200)
def test_search_finds_entry_by_title_substring(self, data: st.DataObject) -> None:
"""A substring from an entry's title must return that entry in results.
**Validates: Requirements 3.7**
"""
entry, query = data.draw(entry_with_query_from_title())
# Include the entry's scope in allowed_scopes so it's findable
allowed_scopes = [entry.scope]
store = _build_store([entry])
results = store.search(query, allowed_scopes)
result_ids = {r.entry.id for r in results}
assert entry.id in result_ids, (
f"Entry '{entry.id}' with title '{entry.title}' was NOT found "
f"when searching for query '{query}' (from title). "
f"Allowed scopes: {allowed_scopes}, entry scope: {entry.scope}"
)
@given(data=st.data())
@settings(max_examples=200)
def test_search_finds_entry_by_tag(self, data: st.DataObject) -> None:
"""A tag from an entry must return that entry in search results.
**Validates: Requirements 3.7**
"""
entry, query = data.draw(entry_with_query_from_tag())
allowed_scopes = [entry.scope]
store = _build_store([entry])
results = store.search(query, allowed_scopes)
result_ids = {r.entry.id for r in results}
assert entry.id in result_ids, (
f"Entry '{entry.id}' with tags {entry.tags} was NOT found "
f"when searching for query '{query}' (from tags). "
f"Allowed scopes: {allowed_scopes}, entry scope: {entry.scope}"
)
@given(data=st.data())
@settings(max_examples=200)
def test_search_finds_entry_by_summary_substring(self, data: st.DataObject) -> None:
"""A substring from an entry's summary must return that entry in results.
**Validates: Requirements 3.7**
"""
entry, query = data.draw(entry_with_query_from_summary())
allowed_scopes = [entry.scope]
store = _build_store([entry])
results = store.search(query, allowed_scopes)
result_ids = {r.entry.id for r in results}
assert entry.id in result_ids, (
f"Entry '{entry.id}' with summary '{entry.summary}' was NOT found "
f"when searching for query '{query}' (from summary). "
f"Allowed scopes: {allowed_scopes}, entry scope: {entry.scope}"
)
@given(data=st.data())
@settings(max_examples=200)
def test_search_finds_entry_when_scope_included(self, data: st.DataObject) -> None:
"""When multiple entries exist, an entry is found if its scope is allowed
and the query matches its content.
**Validates: Requirements 3.7**
"""
# Generate multiple entries with various scopes
entries = data.draw(st.lists(
index_entry_strategy(), min_size=3, max_size=20, unique_by=lambda e: e.id
))
assume(len(entries) >= 3)
# Pick a target entry and derive a query from its title
target_idx = data.draw(st.integers(min_value=0, max_value=len(entries) - 1))
target = entries[target_idx]
# Use first word of title as query (guaranteed to be in title)
title_lower = target.title.lower()
first_word = title_lower.split()[0] if title_lower.split() else title_lower
assume(len(first_word) >= 3)
# Include target's scope in allowed_scopes
allowed_scopes = list({target.scope} | {
e.scope for e in data.draw(
st.lists(st.sampled_from(entries), min_size=0, max_size=3)
)
})
store = _build_store(entries)
results = store.search(first_word, allowed_scopes)
result_ids = {r.entry.id for r in results}
assert target.id in result_ids, (
f"Target entry '{target.id}' (scope='{target.scope}', title='{target.title}') "
f"was NOT found when searching for '{first_word}' with "
f"allowed_scopes={allowed_scopes}."
)
@given(data=st.data())
@settings(max_examples=200)
def test_search_excludes_entry_when_scope_not_included(self, data: st.DataObject) -> None:
"""Even when content matches, entries are excluded if their scope is not allowed.
This verifies the interaction between fulltext search and scope filtering:
The search finds the content, but the scope gate prevents it from appearing.
**Validates: Requirements 3.7**
"""
entry, query = data.draw(entry_with_query_from_title())
# Use scopes that do NOT include the entry's scope
other_scopes = [s for s in ALL_SCOPES if s != entry.scope]
assume(len(other_scopes) > 0)
allowed_scopes = data.draw(st.lists(
st.sampled_from(other_scopes), min_size=1, max_size=len(other_scopes), unique=True
))
store = _build_store([entry])
results = store.search(query, allowed_scopes)
result_ids = {r.entry.id for r in results}
assert entry.id not in result_ids, (
f"Entry '{entry.id}' (scope='{entry.scope}') appeared in results "
f"even though allowed_scopes={allowed_scopes} does not include "
f"'{entry.scope}'. Query was '{query}'."
)
@@ -0,0 +1,312 @@
"""Property-basierte Tests für den Wissensspeicher: Graph-Verknüpfungen bidirektional.
**Validates: Requirements 3.8**
Property 11: Graph-Verknüpfungen werden in beiden Artefakten reflektiert
- For any zwei Artefakte, wenn eine gerichtete Verknüpfung von A nach B erstellt wird,
muss die Kante sowohl im YAML-Frontmatter von A als auch im Frontmatter von B erscheinen.
- Die Verlinkung ist bidirektional im Index (source.links enthält target_id, target.links enthält source_id).
- Wiederholtes Aufrufen von link_artifacts ist idempotent (keine Duplikat-Links).
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from hypothesis import given, settings
from hypothesis import strategies as st
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.store import KnowledgeStore
from monorepo.models import ArtifactType, Context, ScopeConfig
# --- Constants ---
VALID_ARTIFACT_TYPES = [t.value for t in ArtifactType]
VALID_SCOPES = [c.value for c in Context]
VALID_RELATIONS = [
"implements",
"references",
"extends",
"relates-to",
"depends-on",
"contradicts",
"supersedes",
]
# --- Strategies ---
@st.composite
def valid_entry_id(draw: st.DrawFn) -> str:
"""Generates a valid artifact ID in scope/type/name format."""
scope = draw(st.sampled_from(VALID_SCOPES))
artifact_type = draw(st.sampled_from(VALID_ARTIFACT_TYPES))
name = draw(st.from_regex(r"[a-z][a-z0-9\-]{1,15}[a-z0-9]", fullmatch=True))
return f"{scope}/{artifact_type}/{name}"
@st.composite
def valid_index_entry(draw: st.DrawFn) -> IndexEntry:
"""Generates a valid IndexEntry with unique ID."""
entry_id = draw(valid_entry_id())
title = draw(
st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "Z"), whitelist_characters="-_ "),
min_size=3,
max_size=40,
).filter(lambda t: t.strip() != "")
)
tags = draw(
st.lists(
st.from_regex(r"[a-z][a-z0-9]{1,10}", fullmatch=True),
min_size=0,
max_size=3,
unique=True,
)
)
# Extract scope from the ID
scope = entry_id.split("/")[0]
artifact_type = entry_id.split("/")[1]
return IndexEntry(
id=entry_id,
title=title,
type=artifact_type,
tags=tags,
scope=scope,
summary=f"Summary for {title[:20]}",
path=f"{entry_id}.md",
content_hash=f"sha256:{'a' * 64}",
links=[],
)
@st.composite
def valid_entries_with_link_pairs(
draw: st.DrawFn,
) -> tuple[list[IndexEntry], list[tuple[int, int, str]]]:
"""Generates N random IndexEntry objects and random link pairs from them.
Returns:
A tuple of (entries, link_pairs) where link_pairs is a list of
(source_index, target_index, relation) tuples.
"""
# Generate between 2 and 8 unique entries
entries = draw(
st.lists(valid_index_entry(), min_size=2, max_size=8, unique_by=lambda e: e.id)
)
n = len(entries)
# Generate random link pairs (source_idx, target_idx) from the entries
# Ensure source != target
possible_pairs = [(i, j) for i in range(n) for j in range(n) if i != j]
if not possible_pairs:
return entries, []
num_links = draw(st.integers(min_value=1, max_value=min(len(possible_pairs), 6)))
selected_pairs = draw(
st.lists(
st.sampled_from(possible_pairs),
min_size=num_links,
max_size=num_links,
)
)
relations = draw(
st.lists(
st.sampled_from(VALID_RELATIONS),
min_size=num_links,
max_size=num_links,
)
)
link_pairs = [
(pair[0], pair[1], rel) for pair, rel in zip(selected_pairs, relations)
]
return entries, link_pairs
def _make_scope_config() -> ScopeConfig:
"""Creates a standard ScopeConfig for testing."""
return ScopeConfig(
scopes={
"privat": {"scope": "privat", "paths": ["privat/"]},
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
"shared": {"scope": "shared", "paths": ["shared/"]},
}
)
# --- Property Tests ---
class TestProperty11GraphLinksBidirectional:
"""Property 11: Graph-Verknüpfungen werden in beiden Artefakten reflektiert.
**Validates: Requirements 3.8**
For any two artifacts, when a directed link from A to B is created:
1. source_entry.links must contain target_id
2. target_entry.links must contain source_id (bidirectional)
3. Calling link_artifacts again is idempotent (no duplicate links)
4. Works with various relation types
"""
@given(data=valid_entries_with_link_pairs())
@settings(max_examples=100, deadline=10000)
def test_link_artifacts_bidirectional(
self,
data: tuple[list[IndexEntry], list[tuple[int, int, str]]],
) -> None:
"""After linking, both artifacts' index entries contain each other's ID."""
entries, link_pairs = data
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
store = KnowledgeStore(tmp, _make_scope_config())
# Add all entries to the store
for entry in entries:
store.index.update_entry(entry)
# Apply all link pairs
for source_idx, target_idx, relation in link_pairs:
source_id = entries[source_idx].id
target_id = entries[target_idx].id
store.link_artifacts(source_id, target_id, relation)
# Verify: source_entry.links contains target_id
source_entry = store.index.get_entry(source_id)
assert source_entry is not None, (
f"Source entry '{source_id}' not found in index after linking"
)
assert target_id in source_entry.links, (
f"Target '{target_id}' not found in source '{source_id}' links. "
f"Links: {source_entry.links}"
)
# Verify: target_entry.links contains source_id (bidirectional)
target_entry = store.index.get_entry(target_id)
assert target_entry is not None, (
f"Target entry '{target_id}' not found in index after linking"
)
assert source_id in target_entry.links, (
f"Source '{source_id}' not found in target '{target_id}' links "
f"(bidirectional check failed). Links: {target_entry.links}"
)
@given(data=valid_entries_with_link_pairs())
@settings(max_examples=100, deadline=10000)
def test_link_artifacts_idempotent(
self,
data: tuple[list[IndexEntry], list[tuple[int, int, str]]],
) -> None:
"""Calling link_artifacts multiple times does not create duplicate links."""
entries, link_pairs = data
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
store = KnowledgeStore(tmp, _make_scope_config())
# Add all entries to the store
for entry in entries:
store.index.update_entry(entry)
# Apply each link pair TWICE to test idempotency
for source_idx, target_idx, relation in link_pairs:
source_id = entries[source_idx].id
target_id = entries[target_idx].id
store.link_artifacts(source_id, target_id, relation)
store.link_artifacts(source_id, target_id, relation)
# Verify: no duplicate links in any entry
for entry in entries:
current = store.index.get_entry(entry.id)
assert current is not None
# Each link target should appear at most once
assert len(current.links) == len(set(current.links)), (
f"Duplicate links found in entry '{entry.id}': {current.links}"
)
@given(
entry_a=valid_index_entry(),
entry_b=valid_index_entry(),
relation=st.sampled_from(VALID_RELATIONS),
)
@settings(max_examples=100, deadline=10000)
def test_link_artifacts_various_relations(
self,
entry_a: IndexEntry,
entry_b: IndexEntry,
relation: str,
) -> None:
"""Bidirectional linking works correctly with all supported relation types."""
# Ensure distinct IDs
if entry_a.id == entry_b.id:
return # skip when IDs collide (rare due to generation)
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
store = KnowledgeStore(tmp, _make_scope_config())
store.index.update_entry(entry_a)
store.index.update_entry(entry_b)
store.link_artifacts(entry_a.id, entry_b.id, relation)
# Verify bidirectional
a = store.index.get_entry(entry_a.id)
b = store.index.get_entry(entry_b.id)
assert a is not None and entry_b.id in a.links
assert b is not None and entry_a.id in b.links
@given(data=valid_entries_with_link_pairs())
@settings(max_examples=50, deadline=10000)
def test_link_count_consistent(
self,
data: tuple[list[IndexEntry], list[tuple[int, int, str]]],
) -> None:
"""After linking, the total link references are consistent across all entries.
For every link (A -> B), both A and B must have each other in their links.
This verifies the bidirectional invariant holds for the entire graph.
"""
entries, link_pairs = data
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
store = KnowledgeStore(tmp, _make_scope_config())
for entry in entries:
store.index.update_entry(entry)
# Apply all links
for source_idx, target_idx, relation in link_pairs:
source_id = entries[source_idx].id
target_id = entries[target_idx].id
store.link_artifacts(source_id, target_id, relation)
# Verify global bidirectional invariant:
# For every entry X, if Y is in X.links, then X must be in Y.links
for entry in entries:
current = store.index.get_entry(entry.id)
assert current is not None
for linked_id in current.links:
linked_entry = store.index.get_entry(linked_id)
assert linked_entry is not None, (
f"Linked entry '{linked_id}' from '{entry.id}' not found in index"
)
assert entry.id in linked_entry.links, (
f"Bidirectional invariant violated: "
f"'{entry.id}' links to '{linked_id}', "
f"but '{linked_id}' does not link back. "
f"'{linked_id}'.links = {linked_entry.links}"
)
@@ -0,0 +1,292 @@
"""Property-basierte Tests für inkrementelle Verarbeitung der ETL-Pipeline.
**Validates: Requirements 3.12**
Property 8: Inkrementelle Verarbeitung erkennt Änderungen über Content-Hash
- For any source, if the content hash has not changed since the last processing,
no update must occur. If the hash has changed, exactly the affected artifact
must be updated.
"""
from __future__ import annotations
import string
from pathlib import Path
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.knowledge.etl import ETLPipeline, IngestResult
from monorepo.knowledge.index import YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
# Characters suitable for artifact names (kebab-case, simple)
NAME_CHARS = string.ascii_lowercase + string.digits
# Valid artifact types
ARTIFACT_TYPES = ["note", "decision", "pattern", "reference", "meeting"]
@st.composite
def artifact_content(draw: st.DrawFn) -> str:
"""Generate non-empty markdown content for an artifact body."""
# Generate 1-5 lines of text content
lines = draw(st.lists(
st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "P", "Z"), min_codepoint=32, max_codepoint=126),
min_size=5,
max_size=80,
),
min_size=1,
max_size=5,
))
return "\n".join(lines) + "\n"
@st.composite
def artifact_name(draw: st.DrawFn) -> str:
"""Generate a unique artifact filename stem (kebab-case, 3-20 chars)."""
length = draw(st.integers(min_value=3, max_value=15))
name = draw(st.text(alphabet=NAME_CHARS, min_size=length, max_size=length))
return name
@st.composite
def artifact_spec(draw: st.DrawFn) -> dict[str, str]:
"""Generate a specification for an artifact: name, type, title, content."""
name = draw(artifact_name())
art_type = draw(st.sampled_from(ARTIFACT_TYPES))
title = f"Title {name}"
tags = draw(st.lists(st.sampled_from(["test", "prop", "etl", "data"]), min_size=0, max_size=3))
content = draw(artifact_content())
return {
"name": name,
"type": art_type,
"title": title,
"tags": tags,
"content": content,
}
@st.composite
def artifact_set(draw: st.DrawFn, min_size: int = 2, max_size: int = 8) -> list[dict[str, str]]:
"""Generate a set of unique artifacts (unique names)."""
count = draw(st.integers(min_value=min_size, max_value=max_size))
specs: list[dict[str, str]] = []
names_used: set[str] = set()
for _ in range(count):
spec = draw(artifact_spec())
# Ensure unique names
attempt = 0
while spec["name"] in names_used and attempt < 20:
spec = draw(artifact_spec())
attempt += 1
if spec["name"] not in names_used:
names_used.add(spec["name"])
specs.append(spec)
assume(len(specs) >= min_size)
return specs
def _write_artifact_file(directory: Path, spec: dict[str, str]) -> Path:
"""Write a markdown artifact file based on spec dict."""
tags_str = ", ".join(spec["tags"]) if spec["tags"] else ""
frontmatter = (
f"---\n"
f"type: {spec['type']}\n"
f"title: \"{spec['title']}\"\n"
f"tags: [{tags_str}]\n"
f"---\n"
)
file_path = directory / f"{spec['name']}.md"
file_path.write_text(frontmatter + spec["content"], encoding="utf-8")
return file_path
def _setup_pipeline(tmp_path: Path) -> tuple[ETLPipeline, YAMLIndex, Path]:
"""Create an ETLPipeline with a fresh index and store path."""
store_path = tmp_path / "store"
store_path.mkdir(parents=True, exist_ok=True)
index = YAMLIndex(store_path / "_index.yaml")
index.load()
pipeline = ETLPipeline(index=index, store_path=store_path)
return pipeline, index, store_path
# ---------------------------------------------------------------------------
# Property 8 Tests
# ---------------------------------------------------------------------------
class TestProperty8IncrementalProcessing:
"""Property 8: Inkrementelle Verarbeitung erkennt Änderungen über Content-Hash.
**Validates: Requirements 3.12**
"""
@given(specs=artifact_set(min_size=2, max_size=8))
@settings(max_examples=50, deadline=None)
def test_unchanged_content_skips_all(self, specs: list[dict[str, str]], tmp_path_factory) -> None:
"""For any N artifacts ingested, re-running ingestion with SAME content
must yield updated==0 and skipped==N."""
tmp_path = tmp_path_factory.mktemp("unchanged")
source_dir = tmp_path / "source"
source_dir.mkdir()
# Write N artifacts
for spec in specs:
_write_artifact_file(source_dir, spec)
pipeline, index, store_path = _setup_pipeline(tmp_path)
n = len(specs)
# First ingestion: all N should be updated
source1 = MarkdownSource(directory=source_dir)
result1 = pipeline.ingest(source1, "bahn")
assert result1.processed == n
assert result1.updated == n
assert result1.skipped == 0
# Second ingestion (same content): all N should be skipped
source2 = MarkdownSource(directory=source_dir)
result2 = pipeline.ingest(source2, "bahn")
assert result2.processed == n, (
f"Expected {n} processed, got {result2.processed}"
)
assert result2.updated == 0, (
f"Expected 0 updated (content unchanged), got {result2.updated}"
)
assert result2.skipped == n, (
f"Expected {n} skipped, got {result2.skipped}"
)
@given(
specs=artifact_set(min_size=3, max_size=8),
modification_seed=st.randoms(use_true_random=False),
)
@settings(max_examples=50, deadline=None)
def test_modified_subset_updates_only_changed(
self, specs: list[dict[str, str]], modification_seed, tmp_path_factory
) -> None:
"""For any N artifacts, modifying a random subset M means:
re-ingestion yields updated==M, skipped==(N-M)."""
tmp_path = tmp_path_factory.mktemp("modified")
source_dir = tmp_path / "source"
source_dir.mkdir()
# Write N artifacts
for spec in specs:
_write_artifact_file(source_dir, spec)
pipeline, index, store_path = _setup_pipeline(tmp_path)
n = len(specs)
# First ingestion
source1 = MarkdownSource(directory=source_dir)
result1 = pipeline.ingest(source1, "bahn")
assert result1.processed == n
assert result1.updated == n
# Choose a random non-empty subset to modify (at least 1, at most N-1)
# This ensures we have both modified and unmodified artifacts
max_modify = max(1, n - 1)
m = modification_seed.randint(1, max_modify)
indices_to_modify = modification_seed.sample(range(n), m)
# Modify the chosen subset by changing their content
for idx in indices_to_modify:
spec = specs[idx]
# Change the content to something different
new_content = f"Modified content for {spec['name']} - unique {modification_seed.randint(1, 999999)}\n"
spec_modified = dict(spec)
spec_modified["content"] = new_content
_write_artifact_file(source_dir, spec_modified)
# Second ingestion
source2 = MarkdownSource(directory=source_dir)
result2 = pipeline.ingest(source2, "bahn")
assert result2.processed == n, (
f"Expected {n} processed, got {result2.processed}"
)
assert result2.updated == m, (
f"Expected {m} updated (modified subset), got {result2.updated}"
)
assert result2.skipped == n - m, (
f"Expected {n - m} skipped, got {result2.skipped}"
)
@given(
specs=artifact_set(min_size=3, max_size=8),
modification_seed=st.randoms(use_true_random=False),
)
@settings(max_examples=50, deadline=None)
def test_only_modified_artifacts_have_changed_hash(
self, specs: list[dict[str, str]], modification_seed, tmp_path_factory
) -> None:
"""After modifying a subset, only those artifacts' content_hash in the
index must have changed. Unmodified artifacts retain their original hash."""
tmp_path = tmp_path_factory.mktemp("hash_check")
source_dir = tmp_path / "source"
source_dir.mkdir()
# Write N artifacts
for spec in specs:
_write_artifact_file(source_dir, spec)
pipeline, index, store_path = _setup_pipeline(tmp_path)
n = len(specs)
# First ingestion
source1 = MarkdownSource(directory=source_dir)
pipeline.ingest(source1, "bahn")
# Capture original hashes from index
original_hashes: dict[str, str] = {}
for entry_id, entry in index.entries.items():
original_hashes[entry_id] = entry.content_hash
# Choose a random subset to modify
max_modify = max(1, n - 1)
m = modification_seed.randint(1, max_modify)
indices_to_modify = set(modification_seed.sample(range(n), m))
# Track which artifact IDs should change
modified_names: set[str] = set()
for idx in indices_to_modify:
spec = specs[idx]
modified_names.add(spec["name"])
new_content = f"Changed hash content {spec['name']} - {modification_seed.randint(1, 999999)}\n"
spec_modified = dict(spec)
spec_modified["content"] = new_content
_write_artifact_file(source_dir, spec_modified)
# Second ingestion
source2 = MarkdownSource(directory=source_dir)
pipeline.ingest(source2, "bahn")
# Verify: only modified artifacts have changed hashes
for entry_id, entry in index.entries.items():
# Extract the artifact name from the entry id (format: "bahn/{type}/{name}")
parts = entry_id.split("/")
name = parts[-1] if len(parts) >= 3 else entry_id
if name in modified_names:
# Hash must have changed
assert entry.content_hash != original_hashes.get(entry_id, ""), (
f"Artifact '{entry_id}' was modified but hash did not change. "
f"Old: {original_hashes.get(entry_id)}, New: {entry.content_hash}"
)
else:
# Hash must remain the same
assert entry.content_hash == original_hashes.get(entry_id, ""), (
f"Artifact '{entry_id}' was NOT modified but hash changed. "
f"Old: {original_hashes.get(entry_id)}, New: {entry.content_hash}"
)
@@ -0,0 +1,458 @@
"""Unit-Tests für NoteGraphMigrator.
Testet die Migration der NoteGraph-Verzeichnisstruktur in den KnowledgeStore:
- Directory-Typ-Mapping (decisions→decision, inbox→note, meetings→meeting, etc.)
- Frontmatter-Generierung für Dateien ohne Frontmatter
- Frontmatter-Anreicherung für Dateien mit bestehendem Frontmatter
- Scope-Konfiguration laden
- Validierung nach Migration
- Fehlerbehandlung
Requirements: 3.5, 3.14
"""
from __future__ import annotations
from pathlib import Path
import pytest
from monorepo.knowledge.migration import (
NOTEGRAPH_DIR_TYPE_MAP,
MigrationResult,
NoteGraphMigrator,
ValidationResult,
load_scope_config,
)
from monorepo.knowledge.store import KnowledgeStore
from monorepo.models import ScopeConfig
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def scope_config_file(tmp_path: Path) -> Path:
"""Erstellt eine temporäre scopes.yaml Datei."""
config_path = tmp_path / "scopes.yaml"
config_path.write_text(
"""\
version: "1.0"
scopes:
- name: privat
context: privat
description: "Persönlicher Wissensbereich"
knowledge_path: shared/knowledge-store/privat/
- name: bahn
context: bahn
description: "DB InfraGO Wissensbereich"
knowledge_path: shared/knowledge-store/bahn/
- name: shared
context: shared
description: "Kontextübergreifender Wissensbereich"
knowledge_path: shared/knowledge-store/shared/
""",
encoding="utf-8",
)
return config_path
@pytest.fixture
def knowledge_store(tmp_path: Path) -> KnowledgeStore:
"""Erstellt einen KnowledgeStore in einem temporären Verzeichnis."""
store_path = tmp_path / "knowledge-store"
store_path.mkdir()
scope_config = ScopeConfig(scopes={
"privat": {"scope": "privat"},
"bahn": {"scope": "bahn"},
"shared": {"scope": "shared"},
})
return KnowledgeStore(base_path=store_path, scope_config=scope_config)
@pytest.fixture
def notegraph_dir(tmp_path: Path) -> Path:
"""Erstellt eine NoteGraph-Verzeichnisstruktur mit Testdaten."""
notegraph = tmp_path / "notegraph"
notegraph.mkdir()
# decisions/
decisions = notegraph / "decisions"
decisions.mkdir()
(decisions / "api-design.md").write_text(
"""\
---
type: decision
title: "API-Design Entscheidung"
tags: [api, architecture]
source_context: bahn
---
# API-Design Entscheidung
Wir verwenden REST-APIs.
""",
encoding="utf-8",
)
# inbox/ Datei ohne Frontmatter
inbox = notegraph / "inbox"
inbox.mkdir()
(inbox / "quick-note.md").write_text(
"""\
# Quick Note
Eine schnelle Notiz ohne Frontmatter.
""",
encoding="utf-8",
)
# meetings/
meetings = notegraph / "meetings"
meetings.mkdir()
(meetings / "sprint-review.md").write_text(
"""\
---
title: "Sprint Review 42"
tags: [sprint, review]
---
# Sprint Review 42
Besprechung des aktuellen Sprints.
""",
encoding="utf-8",
)
# people/
people = notegraph / "people"
people.mkdir()
(people / "max-mustermann.md").write_text(
"""\
---
type: reference
title: "Max Mustermann"
tags: [team, developer]
source_context: bahn
---
# Max Mustermann
Backend-Entwickler im Team.
""",
encoding="utf-8",
)
# projects/
projects = notegraph / "projects"
projects.mkdir()
(projects / "wissensdatenbank.md").write_text(
"""\
---
type: project
title: "DB Wissensdatenbank"
tags: [knowledge, etl]
source_context: bahn
---
# DB Wissensdatenbank
ETL-Pipeline für DB-InfraGO.
""",
encoding="utf-8",
)
return notegraph
# ---------------------------------------------------------------------------
# Tests: load_scope_config
# ---------------------------------------------------------------------------
class TestLoadScopeConfig:
"""Tests für das Laden der Scope-Konfiguration."""
def test_load_valid_config(self, scope_config_file: Path) -> None:
"""Lädt eine gültige scopes.yaml erfolgreich."""
config = load_scope_config(scope_config_file)
assert isinstance(config, ScopeConfig)
assert "privat" in config.scopes
assert "bahn" in config.scopes
assert config.scopes["privat"]["scope"] == "privat"
def test_load_missing_file_raises(self, tmp_path: Path) -> None:
"""FileNotFoundError wenn die Datei nicht existiert."""
with pytest.raises(FileNotFoundError):
load_scope_config(tmp_path / "missing.yaml")
def test_load_invalid_format_raises(self, tmp_path: Path) -> None:
"""ValueError bei ungültigem Format."""
bad_file = tmp_path / "bad.yaml"
bad_file.write_text("just a string", encoding="utf-8")
with pytest.raises(ValueError):
load_scope_config(bad_file)
# ---------------------------------------------------------------------------
# Tests: NoteGraphMigrator.migrate
# ---------------------------------------------------------------------------
class TestNoteGraphMigration:
"""Tests für die NoteGraph-Migration."""
def test_migrate_full_notegraph(
self,
knowledge_store: KnowledgeStore,
scope_config_file: Path,
notegraph_dir: Path,
) -> None:
"""Migriert eine vollständige NoteGraph-Struktur."""
migrator = NoteGraphMigrator(
store=knowledge_store, scope_config_path=scope_config_file
)
result = migrator.migrate(source_path=notegraph_dir, context="bahn")
assert isinstance(result, MigrationResult)
assert result.migrated_count >= 4 # Mindestens 4 neue Artefakte
assert result.success
def test_migrate_adds_frontmatter_to_plain_files(
self,
knowledge_store: KnowledgeStore,
scope_config_file: Path,
notegraph_dir: Path,
) -> None:
"""Dateien ohne Frontmatter erhalten generiertes Frontmatter."""
migrator = NoteGraphMigrator(
store=knowledge_store, scope_config_path=scope_config_file
)
migrator.migrate(source_path=notegraph_dir, context="bahn")
# Die inbox/quick-note.md sollte jetzt Frontmatter haben
inbox_file = notegraph_dir / "inbox" / "quick-note.md"
content = inbox_file.read_text(encoding="utf-8")
assert content.startswith("---")
assert "type: note" in content
assert "source_context: bahn" in content
def test_migrate_enriches_existing_frontmatter(
self,
knowledge_store: KnowledgeStore,
scope_config_file: Path,
notegraph_dir: Path,
) -> None:
"""Existierendes Frontmatter wird um fehlende Felder ergänzt."""
migrator = NoteGraphMigrator(
store=knowledge_store, scope_config_path=scope_config_file
)
migrator.migrate(source_path=notegraph_dir, context="bahn")
# meetings/sprint-review.md hatte keinen type und keinen source_context
meeting_file = notegraph_dir / "meetings" / "sprint-review.md"
content = meeting_file.read_text(encoding="utf-8")
assert "type: meeting" in content
assert "source_context: bahn" in content
def test_migrate_preserves_existing_metadata(
self,
knowledge_store: KnowledgeStore,
scope_config_file: Path,
notegraph_dir: Path,
) -> None:
"""Vorhandene Metadaten (Tags, Title) bleiben erhalten."""
migrator = NoteGraphMigrator(
store=knowledge_store, scope_config_path=scope_config_file
)
migrator.migrate(source_path=notegraph_dir, context="bahn")
# decisions/api-design.md hatte bereits vollständige Metadaten
decision_file = notegraph_dir / "decisions" / "api-design.md"
content = decision_file.read_text(encoding="utf-8")
assert "API-Design Entscheidung" in content
assert "api" in content
def test_migrate_directory_type_mapping(
self,
knowledge_store: KnowledgeStore,
scope_config_file: Path,
notegraph_dir: Path,
) -> None:
"""Verzeichnisse werden korrekt auf Artefakt-Typen gemappt."""
migrator = NoteGraphMigrator(
store=knowledge_store, scope_config_path=scope_config_file
)
migrator.migrate(source_path=notegraph_dir, context="bahn")
# Index prüfen
index_entries = knowledge_store.get_index(scope="bahn")
types_found = {entry.type for entry in index_entries}
# Mindestens decision, note, meeting, reference, project
assert "decision" in types_found
assert "note" in types_found
assert "meeting" in types_found
assert "reference" in types_found
assert "project" in types_found
def test_migrate_nonexistent_path_returns_error(
self,
knowledge_store: KnowledgeStore,
scope_config_file: Path,
tmp_path: Path,
) -> None:
"""Migration mit nicht-existierendem Pfad gibt Fehler zurück."""
migrator = NoteGraphMigrator(
store=knowledge_store, scope_config_path=scope_config_file
)
result = migrator.migrate(
source_path=tmp_path / "nonexistent", context="bahn"
)
assert len(result.errors) > 0
assert result.migrated_count == 0
def test_migrate_empty_notegraph(
self,
knowledge_store: KnowledgeStore,
scope_config_file: Path,
tmp_path: Path,
) -> None:
"""Migration eines leeren NoteGraph-Verzeichnisses."""
empty_dir = tmp_path / "empty-notegraph"
empty_dir.mkdir()
migrator = NoteGraphMigrator(
store=knowledge_store, scope_config_path=scope_config_file
)
result = migrator.migrate(source_path=empty_dir, context="bahn")
assert result.migrated_count == 0
assert result.skipped_count == 0
assert len(result.errors) == 0
def test_migrate_partial_notegraph(
self,
knowledge_store: KnowledgeStore,
scope_config_file: Path,
tmp_path: Path,
) -> None:
"""Migration mit nur einigen der NoteGraph-Verzeichnisse vorhanden."""
partial = tmp_path / "partial-notegraph"
partial.mkdir()
# Nur decisions/ anlegen
decisions = partial / "decisions"
decisions.mkdir()
(decisions / "one-decision.md").write_text(
"""\
---
type: decision
title: "Eine Entscheidung"
tags: [test]
source_context: privat
---
# Eine Entscheidung
Inhalt der Entscheidung.
""",
encoding="utf-8",
)
migrator = NoteGraphMigrator(
store=knowledge_store, scope_config_path=scope_config_file
)
result = migrator.migrate(source_path=partial, context="privat")
assert result.migrated_count == 1
assert len(result.errors) == 0
# ---------------------------------------------------------------------------
# Tests: NoteGraphMigrator.validate
# ---------------------------------------------------------------------------
class TestNoteGraphValidation:
"""Tests für die Migrations-Validierung."""
def test_validate_after_successful_migration(
self,
knowledge_store: KnowledgeStore,
scope_config_file: Path,
notegraph_dir: Path,
) -> None:
"""Validierung nach erfolgreicher Migration ist erfolgreich."""
migrator = NoteGraphMigrator(
store=knowledge_store, scope_config_path=scope_config_file
)
migrator.migrate(source_path=notegraph_dir, context="bahn")
validation = migrator.validate(context="bahn")
assert isinstance(validation, ValidationResult)
assert validation.valid
assert validation.total >= 4
assert validation.found_in_index == validation.total
assert len(validation.missing) == 0
def test_validate_empty_context_is_valid(
self,
knowledge_store: KnowledgeStore,
scope_config_file: Path,
) -> None:
"""Validierung eines leeren Kontexts ist gültig (0 Artefakte)."""
migrator = NoteGraphMigrator(
store=knowledge_store, scope_config_path=scope_config_file
)
validation = migrator.validate(context="privat")
assert validation.valid
assert validation.total == 0
def test_validate_returns_details(
self,
knowledge_store: KnowledgeStore,
scope_config_file: Path,
notegraph_dir: Path,
) -> None:
"""Validierung enthält detaillierte Ergebnisse pro Artefakt."""
migrator = NoteGraphMigrator(
store=knowledge_store, scope_config_path=scope_config_file
)
migrator.migrate(source_path=notegraph_dir, context="bahn")
validation = migrator.validate(context="bahn")
assert len(validation.details) > 0
for detail in validation.details:
assert detail.file_name
assert detail.found_in_index
# ---------------------------------------------------------------------------
# Tests: NOTEGRAPH_DIR_TYPE_MAP
# ---------------------------------------------------------------------------
class TestDirectoryTypeMapping:
"""Tests für das Verzeichnis-Typ-Mapping."""
def test_all_standard_directories_mapped(self) -> None:
"""Alle Standard-NoteGraph-Verzeichnisse sind gemappt."""
assert "decisions" in NOTEGRAPH_DIR_TYPE_MAP
assert "inbox" in NOTEGRAPH_DIR_TYPE_MAP
assert "meetings" in NOTEGRAPH_DIR_TYPE_MAP
assert "people" in NOTEGRAPH_DIR_TYPE_MAP
assert "projects" in NOTEGRAPH_DIR_TYPE_MAP
def test_correct_type_assignments(self) -> None:
"""Verzeichnisse sind den korrekten Typen zugeordnet."""
assert NOTEGRAPH_DIR_TYPE_MAP["decisions"] == "decision"
assert NOTEGRAPH_DIR_TYPE_MAP["inbox"] == "note"
assert NOTEGRAPH_DIR_TYPE_MAP["meetings"] == "meeting"
assert NOTEGRAPH_DIR_TYPE_MAP["people"] == "reference"
assert NOTEGRAPH_DIR_TYPE_MAP["projects"] == "project"
@@ -0,0 +1,340 @@
"""Property-basierte Tests: Progressive-Disclosure-Index enthält nur kompakte Einträge.
**Validates: Requirements 3.16, 3.18**
Property 7: Progressive-Disclosure-Index enthält nur kompakte Einträge
- For any Abfrage des YAML-Index (Schicht 1) müssen die zurückgegebenen Einträge
Titel, Tags, Beziehungen und Kurzbeschreibungen enthalten, aber niemals den
vollständigen Dokumentinhalt. Suchergebnisse liefern ausschließlich Pfade zurück.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import yaml
from hypothesis import given, settings
from hypothesis import strategies as st
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.etl import ETLPipeline
from monorepo.knowledge.sources.markdown import MarkdownSource
from monorepo.knowledge.store import KnowledgeStore
from monorepo.models import ScopeConfig
# --- Constants ---
ARTIFACT_TYPES = ["decision", "note", "meeting", "reference", "pattern"]
CONTEXTS = ["privat", "dhive", "bahn", "shared"]
SAMPLE_TAGS = ["api", "architecture", "python", "devops", "testing", "design", "cloud"]
# --- Strategies ---
@st.composite
def artifact_content(draw: st.DrawFn) -> str:
"""Generates random artifact content of varying length.
Some short (< 150 chars), some long (> 150 chars) to test
that long content never leaks into the index.
"""
# Choose between short and long content
is_long = draw(st.booleans())
if is_long:
# Generate long content (200-1000 chars)
num_lines = draw(st.integers(min_value=5, max_value=20))
lines = []
for _ in range(num_lines):
line = draw(st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "Zs")),
min_size=20,
max_size=80,
))
lines.append(line)
return "\n".join(lines)
else:
# Generate short content (10-100 chars)
return draw(st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "Zs")),
min_size=10,
max_size=100,
))
@st.composite
def artifact_metadata(draw: st.DrawFn) -> dict[str, object]:
"""Generates random artifact metadata for YAML frontmatter."""
title = draw(st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "Zs")),
min_size=3,
max_size=60,
))
artifact_type = draw(st.sampled_from(ARTIFACT_TYPES))
tags = draw(st.lists(st.sampled_from(SAMPLE_TAGS), min_size=1, max_size=4, unique=True))
return {
"type": artifact_type,
"title": title,
"tags": tags,
}
@st.composite
def artifact_batch(draw: st.DrawFn) -> list[tuple[dict[str, object], str]]:
"""Generates a batch of 1-5 artifacts with metadata and content."""
count = draw(st.integers(min_value=1, max_value=5))
artifacts = []
for _ in range(count):
meta = draw(artifact_metadata())
content = draw(artifact_content())
artifacts.append((meta, content))
return artifacts
# --- Helpers ---
def _create_source_directory(tmp_dir: Path, artifacts: list[tuple[dict[str, object], str]]) -> Path:
"""Creates a source directory with markdown files containing YAML frontmatter."""
source_dir = tmp_dir / "source"
source_dir.mkdir(parents=True, exist_ok=True)
for i, (meta, content) in enumerate(artifacts):
frontmatter = yaml.dump(meta, default_flow_style=False, allow_unicode=True, sort_keys=False)
file_content = f"---\n{frontmatter}---\n{content}"
file_path = source_dir / f"artifact-{i}.md"
file_path.write_text(file_content, encoding="utf-8")
return source_dir
def _create_knowledge_store(tmp_dir: Path) -> KnowledgeStore:
"""Creates a KnowledgeStore in a temporary directory."""
store_path = tmp_dir / "knowledge-store"
store_path.mkdir(parents=True, exist_ok=True)
scope_config = ScopeConfig(scopes={
"privat": {"scope": "privat", "paths": ["privat/"]},
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
"shared": {"scope": "shared", "paths": ["shared/"]},
})
return KnowledgeStore(base_path=store_path, scope_config=scope_config)
# Index entry required fields per specification
REQUIRED_INDEX_FIELDS = {"id", "title", "type", "tags", "scope", "summary", "path", "content_hash", "links"}
# --- Tests ---
class TestProperty7ProgressiveDisclosureIndex:
"""Property 7: Progressive-Disclosure-Index enthält nur kompakte Einträge.
**Validates: Requirements 3.16, 3.18**
For any query of the YAML index (Layer 1), the returned entries must
contain title, tags, relationships, and short descriptions, but NEVER
the full document content. Search results must exclusively return paths.
"""
@given(artifacts=artifact_batch())
@settings(max_examples=50, deadline=None)
def test_persisted_yaml_index_has_no_content_field(
self, artifacts: list[tuple[dict[str, object], str]]
) -> None:
"""After ingestion, the persisted YAML index file must NOT contain
a 'content' field in any entry."""
with tempfile.TemporaryDirectory(prefix="monorepo_pbt_") as tmp:
tmp_dir = Path(tmp)
store = _create_knowledge_store(tmp_dir)
source_dir = _create_source_directory(tmp_dir, artifacts)
# Ingest via ETLPipeline
source = MarkdownSource(directory=source_dir)
store.ingest(source, context="bahn")
# Load the persisted YAML index file directly
index_path = store.base_path / "_index.yaml"
if not index_path.exists():
# No artifacts were ingested (possible if all had errors)
return
raw_data = yaml.safe_load(index_path.read_text(encoding="utf-8"))
if raw_data is None or "artifacts" not in raw_data:
return
for entry_data in raw_data["artifacts"]:
assert "content" not in entry_data, (
f"YAML index entry '{entry_data.get('id', '?')}' contains a "
f"'content' field Progressive Disclosure violated!"
)
@given(artifacts=artifact_batch())
@settings(max_examples=50, deadline=None)
def test_persisted_yaml_index_entries_have_required_fields(
self, artifacts: list[tuple[dict[str, object], str]]
) -> None:
"""Each persisted YAML index entry must have all required compact fields:
id, title, type, tags, scope, summary, path, content_hash, links."""
with tempfile.TemporaryDirectory(prefix="monorepo_pbt_") as tmp:
tmp_dir = Path(tmp)
store = _create_knowledge_store(tmp_dir)
source_dir = _create_source_directory(tmp_dir, artifacts)
source = MarkdownSource(directory=source_dir)
store.ingest(source, context="bahn")
index_path = store.base_path / "_index.yaml"
if not index_path.exists():
return
raw_data = yaml.safe_load(index_path.read_text(encoding="utf-8"))
if raw_data is None or "artifacts" not in raw_data:
return
for entry_data in raw_data["artifacts"]:
entry_keys = set(entry_data.keys())
missing = REQUIRED_INDEX_FIELDS - entry_keys
assert not missing, (
f"YAML index entry '{entry_data.get('id', '?')}' is missing "
f"required fields: {missing}"
)
@given(artifacts=artifact_batch())
@settings(max_examples=50, deadline=None)
def test_persisted_yaml_index_summary_within_limit(
self, artifacts: list[tuple[dict[str, object], str]]
) -> None:
"""Each entry's summary must be <= 150 chars (not full content)."""
with tempfile.TemporaryDirectory(prefix="monorepo_pbt_") as tmp:
tmp_dir = Path(tmp)
store = _create_knowledge_store(tmp_dir)
source_dir = _create_source_directory(tmp_dir, artifacts)
source = MarkdownSource(directory=source_dir)
store.ingest(source, context="privat")
index_path = store.base_path / "_index.yaml"
if not index_path.exists():
return
raw_data = yaml.safe_load(index_path.read_text(encoding="utf-8"))
if raw_data is None or "artifacts" not in raw_data:
return
for entry_data in raw_data["artifacts"]:
summary = entry_data.get("summary", "")
assert len(summary) <= 150, (
f"YAML index entry '{entry_data.get('id', '?')}' has summary "
f"of {len(summary)} chars (max 150): '{summary[:60]}...'"
)
@given(artifacts=artifact_batch())
@settings(max_examples=50, deadline=None)
def test_search_results_contain_no_full_content(
self, artifacts: list[tuple[dict[str, object], str]]
) -> None:
"""KnowledgeStore.search() results must not contain full document content.
IndexEntry objects returned have only compact metadata."""
with tempfile.TemporaryDirectory(prefix="monorepo_pbt_") as tmp:
tmp_dir = Path(tmp)
store = _create_knowledge_store(tmp_dir)
source_dir = _create_source_directory(tmp_dir, artifacts)
source = MarkdownSource(directory=source_dir)
store.ingest(source, context="dhive")
# Search with a broad query that should match something
results = store.search("", allowed_scopes=["dhive"])
# Empty query returns no results by design, try with a common term
results = store.search("a", allowed_scopes=["dhive"])
for result in results:
entry = result.entry
# IndexEntry must not have a "content" attribute with actual content
assert not hasattr(entry, "content"), (
f"SearchResult entry '{entry.id}' has a 'content' attribute "
f"Progressive Disclosure violated!"
)
# Verify the entry has only compact fields
entry_dict = entry.to_dict()
assert "content" not in entry_dict, (
f"SearchResult entry '{entry.id}' dict contains 'content' field!"
)
# Summary must be compact
assert len(entry.summary) <= 150, (
f"SearchResult entry '{entry.id}' has summary > 150 chars"
)
@given(artifacts=artifact_batch())
@settings(max_examples=50, deadline=None)
def test_get_index_returns_no_full_content(
self, artifacts: list[tuple[dict[str, object], str]]
) -> None:
"""KnowledgeStore.get_index() must return IndexEntry objects without
full document content only compact metadata."""
with tempfile.TemporaryDirectory(prefix="monorepo_pbt_") as tmp:
tmp_dir = Path(tmp)
store = _create_knowledge_store(tmp_dir)
source_dir = _create_source_directory(tmp_dir, artifacts)
source = MarkdownSource(directory=source_dir)
store.ingest(source, context="bahn")
# Get all index entries
entries = store.get_index()
for entry in entries:
# IndexEntry must not have "content" attribute
assert not hasattr(entry, "content"), (
f"get_index() entry '{entry.id}' has a 'content' attribute "
f"Progressive Disclosure violated!"
)
# Serialized dict must not contain "content"
entry_dict = entry.to_dict()
assert "content" not in entry_dict, (
f"get_index() entry '{entry.id}' dict contains 'content' field!"
)
# Summary must be compact (<= 150 chars)
assert len(entry.summary) <= 150, (
f"get_index() entry '{entry.id}' has summary > 150 chars: "
f"'{entry.summary[:60]}...'"
)
# Entry must have required fields
assert entry.id, "Entry missing 'id'"
assert entry.title is not None, "Entry missing 'title'"
assert entry.type is not None, "Entry missing 'type'"
assert entry.path, "Entry missing 'path'"
assert entry.content_hash, "Entry missing 'content_hash'"
@given(artifacts=artifact_batch())
@settings(max_examples=50, deadline=None)
def test_get_index_scoped_returns_no_full_content(
self, artifacts: list[tuple[dict[str, object], str]]
) -> None:
"""KnowledgeStore.get_index(scope=...) also must not expose content."""
with tempfile.TemporaryDirectory(prefix="monorepo_pbt_") as tmp:
tmp_dir = Path(tmp)
store = _create_knowledge_store(tmp_dir)
source_dir = _create_source_directory(tmp_dir, artifacts)
source = MarkdownSource(directory=source_dir)
store.ingest(source, context="shared")
# Get entries filtered by scope
entries = store.get_index(scope="shared")
for entry in entries:
assert not hasattr(entry, "content"), (
f"get_index(scope) entry '{entry.id}' has 'content' attribute!"
)
entry_dict = entry.to_dict()
assert "content" not in entry_dict, (
f"get_index(scope) entry '{entry.id}' dict has 'content' field!"
)
assert len(entry.summary) <= 150, (
f"get_index(scope) entry '{entry.id}' summary > 150 chars"
)
@@ -0,0 +1,242 @@
"""Property-basierte Tests für den Wissensspeicher: Artefakt-Ingestion.
**Validates: Requirements 3.2, 3.4, 3.6, 3.11, 3.13, 3.17, 3.21**
Property 5: Wissensartefakt-Ingestion erzeugt vollständige Metadaten im Index
- For any gültiges Wissensartefakt aus einem beliebigen Kontext, nach der Verarbeitung
durch die ETL-Pipeline muss es im YAML-Index erscheinen mit korrektem YAML-Frontmatter
(mindestens Typ, Titel, Tags, Quellkontext, Erstelldatum, Content-Hash) und der Pfad
muss der Scope-basierten Ordnerstruktur entsprechen.
"""
from __future__ import annotations
from pathlib import Path
from hypothesis import given, settings
from hypothesis import strategies as st
from monorepo.knowledge.etl import ETLPipeline
from monorepo.knowledge.index import YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource
from monorepo.models import ArtifactType, Context
# --- Constants ---
# Valid artifact types from the model
VALID_ARTIFACT_TYPES = [t.value for t in ArtifactType]
# Valid contexts (non-shared, as source contexts for artifacts)
VALID_CONTEXTS = [c.value for c in Context if c != Context.SHARED]
# --- Strategies ---
@st.composite
def valid_tag(draw: st.DrawFn) -> str:
"""Generates a valid tag: lowercase alphanumeric with optional hyphens."""
return draw(
st.from_regex(r"[a-z][a-z0-9\-]{0,14}[a-z0-9]", fullmatch=True)
)
@st.composite
def valid_artifact_name(draw: st.DrawFn) -> str:
"""Generates a valid artifact filename (kebab-case, no extension)."""
return draw(
st.from_regex(r"[a-z][a-z0-9\-]{1,20}[a-z0-9]", fullmatch=True)
)
@st.composite
def valid_title(draw: st.DrawFn) -> str:
"""Generates a valid artifact title (non-empty, printable)."""
return draw(
st.text(
alphabet=st.characters(
whitelist_categories=("L", "N", "Z"),
whitelist_characters="-_ ",
),
min_size=3,
max_size=60,
).filter(lambda t: t.strip() != "")
)
@st.composite
def valid_markdown_artifact(draw: st.DrawFn) -> dict[str, object]:
"""Generates a complete valid Markdown artifact with frontmatter.
Returns a dict with:
- name: filename without .md extension
- type: artifact type (decision, note, etc.)
- title: artifact title
- tags: list of tags
- content: markdown body content
- context: source context
"""
name = draw(valid_artifact_name())
artifact_type = draw(st.sampled_from(VALID_ARTIFACT_TYPES))
title = draw(valid_title())
tags = draw(st.lists(valid_tag(), min_size=1, max_size=5, unique=True))
context = draw(st.sampled_from(VALID_CONTEXTS))
# Generate some markdown body content
body_line = draw(
st.text(
alphabet=st.characters(
whitelist_categories=("L", "N", "Z"),
whitelist_characters="-_., ",
),
min_size=10,
max_size=100,
).filter(lambda t: t.strip() != "")
)
content = f"\n# {title}\n\n{body_line}\n"
return {
"name": name,
"type": artifact_type,
"title": title,
"tags": tags,
"content": content,
"context": context,
}
def _write_markdown_file(directory: Path, artifact: dict[str, object]) -> Path:
"""Writes a Markdown file with YAML frontmatter to the given directory.
Returns the path to the written file.
"""
name: str = artifact["name"] # type: ignore[assignment]
artifact_type: str = artifact["type"] # type: ignore[assignment]
title: str = artifact["title"] # type: ignore[assignment]
tags: list[str] = artifact["tags"] # type: ignore[assignment]
content: str = artifact["content"] # type: ignore[assignment]
# Build YAML frontmatter
tags_str = ", ".join(tags)
frontmatter = (
f"---\n"
f"type: {artifact_type}\n"
f"title: \"{title}\"\n"
f"tags: [{tags_str}]\n"
f"---\n"
)
file_path = directory / f"{name}.md"
file_path.write_text(frontmatter + content, encoding="utf-8")
return file_path
# --- Property Test ---
class TestProperty5ArtifactIngestionMetadata:
"""Property 5: Wissensartefakt-Ingestion erzeugt vollständige Metadaten im Index.
**Validates: Requirements 3.2, 3.4, 3.6, 3.11, 3.13, 3.17, 3.21**
For any valid knowledge artifact from any context, after processing by the
ETL pipeline, it must appear in the YAML index with:
- id set
- title matches
- type matches
- tags match
- scope == context
- content_hash starts with "sha256:"
- path follows scope-based structure ({context}/{type}/{name}.md)
"""
@given(artifact=valid_markdown_artifact())
@settings(max_examples=50, deadline=5000)
def test_ingested_artifact_has_complete_metadata_in_index(
self, artifact: dict[str, object], tmp_path_factory: object
) -> None:
"""After ingestion, the artifact appears in the YAML index with all required metadata."""
# Use a unique temp directory for each test invocation
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
source_dir = tmp / "source"
source_dir.mkdir()
store_path = tmp / "store"
store_path.mkdir()
# Extract artifact data
name: str = artifact["name"] # type: ignore[assignment]
artifact_type: str = artifact["type"] # type: ignore[assignment]
title: str = artifact["title"] # type: ignore[assignment]
tags: list[str] = artifact["tags"] # type: ignore[assignment]
context: str = artifact["context"] # type: ignore[assignment]
# Write the artifact as a Markdown file
_write_markdown_file(source_dir, artifact)
# Set up ETL pipeline
index = YAMLIndex(store_path / "_index.yaml")
index.load()
pipeline = ETLPipeline(index=index, store_path=store_path)
source = MarkdownSource(directory=source_dir)
# Run ingestion
result = pipeline.ingest(source, context)
# --- Assertions ---
# The artifact was processed successfully
assert result.processed == 1, (
f"Expected 1 processed artifact, got {result.processed}. "
f"Errors: {result.errors}"
)
assert result.updated == 1, (
f"Expected 1 updated artifact, got {result.updated}."
)
# The artifact appears in the YAML index
expected_id = f"{context}/{artifact_type}/{name}"
entry = index.get_entry(expected_id)
assert entry is not None, (
f"Artifact '{expected_id}' not found in index. "
f"Available entries: {list(index.entries.keys())}"
)
# id is set
assert entry.id == expected_id
# title matches
assert entry.title == title
# type matches
assert entry.type == artifact_type
# tags match (order-independent)
assert set(entry.tags) == set(tags), (
f"Tags mismatch: expected {tags}, got {entry.tags}"
)
# scope == context
assert entry.scope == context, (
f"Scope mismatch: expected '{context}', got '{entry.scope}'"
)
# content_hash starts with "sha256:"
assert entry.content_hash.startswith("sha256:"), (
f"Content hash should start with 'sha256:', got '{entry.content_hash}'"
)
# Content hash has actual hex digest after prefix
hex_part = entry.content_hash[len("sha256:"):]
assert len(hex_part) == 64, (
f"SHA-256 hex digest should be 64 chars, got {len(hex_part)}"
)
# path follows scope-based structure: {context}/{type}/{name}.md
# Normalize path separators (Windows uses backslashes in Path objects)
normalized_path = entry.path.replace("\\", "/")
expected_path = f"{context}/{artifact_type}/{name}.md"
assert normalized_path == expected_path, (
f"Path mismatch: expected '{expected_path}', got '{entry.path}'"
)
@@ -0,0 +1,556 @@
"""Tests für Volltextsuche und Scope-basierte Filterung im KnowledgeStore.
Validiert Task 6.4:
- SearchResult Dataclass mit entry, relevance_score, partial_results
- Relevanz-Scoring: title > tags > summary > id
- Sortierung nach Relevanz (höchster Score zuerst)
- Scope-Filterung: Nur Ergebnisse aus autorisierten Scopes
- Keine Offenlegung nicht-autorisierter Artefakte
- Timeout-Handling: Abbruch nach 5 Sekunden mit Teilergebnis-Warnung
Requirements: 3.3, 3.7, 3.9
"""
from __future__ import annotations
import time
from pathlib import Path
from unittest.mock import patch
import pytest
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.store import (
SEARCH_TIMEOUT_SECONDS,
KnowledgeStore,
SearchResult,
_compute_relevance,
)
from monorepo.models import ScopeConfig
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def scope_config() -> ScopeConfig:
"""Beispiel-ScopeConfig."""
return ScopeConfig(scopes={
"privat": {"scope": "privat", "paths": ["privat/"]},
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
"shared": {"scope": "shared", "paths": ["shared/"]},
})
@pytest.fixture
def sample_entries() -> list[IndexEntry]:
"""Vielfältige Einträge zum Testen der Relevanz-Sortierung."""
return [
IndexEntry(
id="bahn/decisions/api-design",
title="API-Designprinzipien für Microservices",
type="decision",
tags=["api", "microservices", "architecture"],
scope="bahn",
summary="REST-API-Konventionen für DB-InfraGO-Dienste",
path="bahn/decisions/api-design.md",
content_hash="sha256:abc123",
links=[],
),
IndexEntry(
id="privat/notes/rest-patterns",
title="REST-Patterns Notizen",
type="note",
tags=["api", "rest", "patterns"],
scope="privat",
summary="Sammlung bewährter REST-Patterns",
path="privat/notes/rest-patterns.md",
content_hash="sha256:def456",
links=[],
),
IndexEntry(
id="dhive/reference/cloud-native",
title="Cloud-Native Architektur Guide",
type="reference",
tags=["cloud", "kubernetes", "architecture"],
scope="dhive",
summary="Leitfaden für cloud-native Anwendungen bei dhive",
path="dhive/reference/cloud-native.md",
content_hash="sha256:ghi789",
links=[],
),
IndexEntry(
id="shared/patterns/api-versioning",
title="API Versioning",
type="pattern",
tags=["api", "versioning", "best-practices"],
scope="shared",
summary="Best Practices für API-Versionierung",
path="shared/patterns/api-versioning.md",
content_hash="sha256:jkl012",
links=[],
),
IndexEntry(
id="bahn/notes/kubernetes-deployment",
title="Kubernetes Deployment Notizen",
type="note",
tags=["kubernetes", "deployment", "infrastructure"],
scope="bahn",
summary="Notizen zum Kubernetes-Deployment bei DB-InfraGO",
path="bahn/notes/kubernetes-deployment.md",
content_hash="sha256:mno345",
links=[],
),
]
@pytest.fixture
def store_with_entries(
tmp_path: Path, scope_config: ScopeConfig, sample_entries: list[IndexEntry]
) -> KnowledgeStore:
"""KnowledgeStore mit vorgeladenen Beispiel-Einträgen."""
store = KnowledgeStore(tmp_path, scope_config)
for entry in sample_entries:
store.index.update_entry(entry)
return store
# ---------------------------------------------------------------------------
# SearchResult Dataclass Tests
# ---------------------------------------------------------------------------
class TestSearchResult:
"""Tests für die SearchResult Dataclass."""
def test_default_values(self) -> None:
"""SearchResult hat korrekte Standardwerte."""
entry = IndexEntry(id="test", title="Test", type="note", scope="privat")
result = SearchResult(entry=entry)
assert result.entry is entry
assert result.relevance_score == 0.0
assert result.partial_results is False
def test_custom_values(self) -> None:
"""SearchResult akzeptiert benutzerdefinierte Werte."""
entry = IndexEntry(id="test", title="Test", type="note", scope="privat")
result = SearchResult(entry=entry, relevance_score=0.85, partial_results=True)
assert result.relevance_score == 0.85
assert result.partial_results is True
def test_relevance_score_range(self) -> None:
"""relevance_score liegt zwischen 0.0 und 1.0."""
entry = IndexEntry(id="test", title="Test", type="note", scope="privat")
# Grenzwerte
result_zero = SearchResult(entry=entry, relevance_score=0.0)
result_one = SearchResult(entry=entry, relevance_score=1.0)
assert result_zero.relevance_score == 0.0
assert result_one.relevance_score == 1.0
# ---------------------------------------------------------------------------
# Relevanz-Scoring Tests
# ---------------------------------------------------------------------------
class TestRelevanceScoring:
"""Tests für die Relevanz-Score-Berechnung."""
def test_title_match_highest_score(self) -> None:
"""Title-Match ergibt höchsten Score (>= 0.8)."""
entry = IndexEntry(
id="test/doc",
title="API Design",
type="note",
tags=["documentation"],
scope="bahn",
summary="Allgemeine Informationen",
)
score = _compute_relevance("api design", entry)
assert score >= 0.8
def test_exact_title_match_maximum_score(self) -> None:
"""Exakter Title-Match ergibt Score 1.0."""
entry = IndexEntry(
id="test/doc",
title="API Design",
type="note",
tags=["misc"],
scope="bahn",
summary="Irgendwas",
)
score = _compute_relevance("api design", entry)
assert score == 1.0
def test_tag_match_medium_score(self) -> None:
"""Tag-Match ergibt mittleren Score (0.6 - 0.79)."""
entry = IndexEntry(
id="test/doc",
title="Allgemeines Dokument",
type="note",
tags=["kubernetes", "deployment"],
scope="bahn",
summary="Nicht relevanter Text",
)
score = _compute_relevance("kubernetes", entry)
assert 0.6 <= score <= 0.79
def test_summary_match_lower_score(self) -> None:
"""Summary-Match ergibt niedrigeren Score (0.4 - 0.59)."""
entry = IndexEntry(
id="test/doc",
title="Allgemeines Dokument",
type="note",
tags=["misc"],
scope="bahn",
summary="Informationen über Kubernetes-Cluster",
)
score = _compute_relevance("kubernetes", entry)
assert 0.4 <= score <= 0.59
def test_id_match_lowest_score(self) -> None:
"""ID-Match ergibt niedrigsten Score (0.2 - 0.39)."""
entry = IndexEntry(
id="bahn/notes/kubernetes-setup",
title="Allgemeines Dokument",
type="note",
tags=["misc"],
scope="bahn",
summary="Nicht relevanter Text",
)
score = _compute_relevance("kubernetes", entry)
assert 0.2 <= score <= 0.39
def test_no_match_zero_score(self) -> None:
"""Kein Match ergibt Score 0.0."""
entry = IndexEntry(
id="test/doc",
title="Allgemeines Dokument",
type="note",
tags=["misc"],
scope="bahn",
summary="Nicht relevanter Text",
)
score = _compute_relevance("zyxwv", entry)
assert score == 0.0
def test_title_beats_tags(self) -> None:
"""Title-Match hat Vorrang vor Tag-Match."""
entry_title = IndexEntry(
id="test/a", title="Kubernetes", type="note",
tags=["misc"], scope="bahn", summary="Anderes",
)
entry_tag = IndexEntry(
id="test/b", title="Anderes Dokument", type="note",
tags=["kubernetes"], scope="bahn", summary="Anderes",
)
score_title = _compute_relevance("kubernetes", entry_title)
score_tag = _compute_relevance("kubernetes", entry_tag)
assert score_title > score_tag
def test_tags_beat_summary(self) -> None:
"""Tag-Match hat Vorrang vor Summary-Match."""
entry_tag = IndexEntry(
id="test/a", title="Anderes", type="note",
tags=["kubernetes"], scope="bahn", summary="Anderer Text",
)
entry_summary = IndexEntry(
id="test/b", title="Anderes", type="note",
tags=["misc"], scope="bahn", summary="Kubernetes ist wichtig",
)
score_tag = _compute_relevance("kubernetes", entry_tag)
score_summary = _compute_relevance("kubernetes", entry_summary)
assert score_tag > score_summary
def test_summary_beats_id(self) -> None:
"""Summary-Match hat Vorrang vor ID-Match."""
entry_summary = IndexEntry(
id="test/other", title="Anderes", type="note",
tags=["misc"], scope="bahn", summary="Kubernetes Deployment Guide",
)
entry_id = IndexEntry(
id="bahn/kubernetes/setup", title="Anderes", type="note",
tags=["misc"], scope="bahn", summary="Anderer Text",
)
score_summary = _compute_relevance("kubernetes", entry_summary)
score_id = _compute_relevance("kubernetes", entry_id)
assert score_summary > score_id
def test_case_insensitive(self) -> None:
"""Relevanz-Berechnung ist case-insensitive bezüglich Entry-Feldern.
Die Funktion erwartet query_lower als bereits lowercase Input.
Die Entry-Felder werden intern lowercase verglichen.
"""
entry_upper = IndexEntry(
id="test/doc", title="API DESIGN", type="note",
tags=[], scope="bahn", summary="",
)
entry_lower = IndexEntry(
id="test/doc", title="api design", type="note",
tags=[], scope="bahn", summary="",
)
# Gleicher Query (lowercase) gegen verschiedene Entry-Schreibweisen
score_upper_entry = _compute_relevance("api design", entry_upper)
score_lower_entry = _compute_relevance("api design", entry_lower)
assert score_upper_entry == score_lower_entry
# ---------------------------------------------------------------------------
# KnowledgeStore.search() Tests
# ---------------------------------------------------------------------------
class TestKnowledgeStoreSearch:
"""Tests für KnowledgeStore.search() mit Relevanz-Sortierung."""
def test_returns_search_results(self, store_with_entries: KnowledgeStore) -> None:
"""search() liefert SearchResult-Objekte."""
results = store_with_entries.search("api", ["bahn", "privat", "shared"])
assert len(results) > 0
for r in results:
assert isinstance(r, SearchResult)
assert isinstance(r.entry, IndexEntry)
assert isinstance(r.relevance_score, float)
assert isinstance(r.partial_results, bool)
def test_sorted_by_relevance_descending(self, store_with_entries: KnowledgeStore) -> None:
"""Ergebnisse sind nach Relevanz absteigend sortiert."""
results = store_with_entries.search("api", ["bahn", "privat", "shared", "dhive"])
scores = [r.relevance_score for r in results]
assert scores == sorted(scores, reverse=True)
def test_title_match_ranked_higher(self, store_with_entries: KnowledgeStore) -> None:
"""Einträge mit Title-Match werden höher gerankt als Tag-Matches."""
results = store_with_entries.search(
"api versioning", ["bahn", "privat", "shared", "dhive"]
)
# "API Versioning" hat exakten Title-Match
if results:
assert results[0].entry.id == "shared/patterns/api-versioning"
def test_empty_query_returns_empty(self, store_with_entries: KnowledgeStore) -> None:
"""Leerer Query liefert leere Ergebnisliste."""
results = store_with_entries.search("", ["bahn", "privat", "shared", "dhive"])
assert results == []
def test_no_match_returns_empty(self, store_with_entries: KnowledgeStore) -> None:
"""Query ohne Treffer liefert leere Liste."""
results = store_with_entries.search("zyxwvutsrq", ["bahn", "privat", "shared", "dhive"])
assert results == []
def test_relevance_scores_within_range(self, store_with_entries: KnowledgeStore) -> None:
"""Alle Relevanz-Scores liegen zwischen 0.0 und 1.0."""
results = store_with_entries.search("api", ["bahn", "privat", "shared", "dhive"])
for r in results:
assert 0.0 <= r.relevance_score <= 1.0
# ---------------------------------------------------------------------------
# Scope-Filterung Tests
# ---------------------------------------------------------------------------
class TestSearchScopeFiltering:
"""Tests für Scope-basierte Filterung in der Suche."""
def test_only_authorized_scopes_returned(self, store_with_entries: KnowledgeStore) -> None:
"""Nur Ergebnisse aus autorisierten Scopes werden zurückgegeben."""
results = store_with_entries.search("api", ["bahn"])
for r in results:
assert r.entry.scope == "bahn"
def test_unauthorized_scope_not_in_results(
self, store_with_entries: KnowledgeStore
) -> None:
"""Artefakte aus nicht-autorisierten Scopes erscheinen nicht."""
# dhive-Scope nicht autorisiert, aber "cloud" existiert dort
results = store_with_entries.search("cloud", ["bahn", "privat"])
for r in results:
assert r.entry.scope != "dhive"
def test_existence_not_revealed(self, store_with_entries: KnowledgeStore) -> None:
"""Existenz nicht-autorisierter Artefakte wird nicht offengelegt.
Requirements: 3.9 unberechtigte Artefakte aus Suchergebnissen
ausschließen, ohne deren Existenz offenzulegen.
"""
# "cloud-native" existiert im dhive-Scope
results_restricted = store_with_entries.search("cloud-native", ["bahn", "privat"])
results_all = store_with_entries.search("cloud-native", ["bahn", "privat", "dhive"])
# Ohne dhive-Berechtigung: Kein Hinweis auf Existenz
assert len(results_restricted) == 0
# Mit dhive-Berechtigung: Ergebnis vorhanden
assert len(results_all) >= 1
def test_empty_scopes_returns_empty(self, store_with_entries: KnowledgeStore) -> None:
"""Leere Scope-Liste liefert keine Ergebnisse."""
results = store_with_entries.search("api", [])
assert results == []
def test_multiple_scopes(self, store_with_entries: KnowledgeStore) -> None:
"""Suche über mehrere Scopes liefert kombinierte Ergebnisse."""
results = store_with_entries.search("api", ["bahn", "shared"])
scopes_found = {r.entry.scope for r in results}
# Beide Scopes sollten vertreten sein
assert "bahn" in scopes_found or "shared" in scopes_found
# ---------------------------------------------------------------------------
# Timeout-Handling Tests
# ---------------------------------------------------------------------------
class TestSearchTimeout:
"""Tests für das Timeout-Handling der Suche."""
def test_default_timeout_is_5_seconds(self) -> None:
"""Standard-Timeout ist 5 Sekunden."""
assert SEARCH_TIMEOUT_SECONDS == 5.0
def test_no_timeout_under_normal_conditions(
self, store_with_entries: KnowledgeStore
) -> None:
"""Unter normalen Bedingungen kein Timeout (partial_results=False)."""
results = store_with_entries.search("api", ["bahn", "privat", "shared", "dhive"])
for r in results:
assert r.partial_results is False
def test_timeout_marks_partial_results(
self, tmp_path: Path, scope_config: ScopeConfig
) -> None:
"""Bei Timeout werden alle Ergebnisse als partial markiert."""
store = KnowledgeStore(tmp_path, scope_config)
# Viele Einträge erstellen
for i in range(100):
entry = IndexEntry(
id=f"bahn/test/entry-{i}",
title=f"Testartikel {i} über API",
type="note",
tags=["api", "test"],
scope="bahn",
summary=f"Zusammenfassung für Entry {i}",
path=f"bahn/test/entry-{i}.md",
content_hash=f"sha256:hash{i}",
)
store.index.update_entry(entry)
# Timeout auf 0 Sekunden simulieren
results = store.search("api", ["bahn"], timeout=0.0)
# Entweder keine Ergebnisse (sofort Timeout) oder alle partial
if results:
for r in results:
assert r.partial_results is True
def test_custom_timeout_parameter(
self, tmp_path: Path, scope_config: ScopeConfig
) -> None:
"""Benutzerdefinierter Timeout wird respektiert."""
store = KnowledgeStore(tmp_path, scope_config)
entry = IndexEntry(
id="bahn/test/entry",
title="API Test",
type="note",
tags=["api"],
scope="bahn",
summary="Test",
path="bahn/test/entry.md",
content_hash="sha256:test",
)
store.index.update_entry(entry)
# Großzügiger Timeout: Normale Ergebnisse
results = store.search("api", ["bahn"], timeout=10.0)
assert len(results) == 1
assert results[0].partial_results is False
def test_timeout_preserves_found_results(
self, tmp_path: Path, scope_config: ScopeConfig
) -> None:
"""Bei Timeout werden bereits gefundene Teilergebnisse beibehalten."""
store = KnowledgeStore(tmp_path, scope_config)
# Viele Einträge erstellen
for i in range(1000):
entry = IndexEntry(
id=f"bahn/test/entry-{i}",
title=f"Testartikel {i} über API",
type="note",
tags=["api", "test"],
scope="bahn",
summary=f"Zusammenfassung für Entry {i}",
path=f"bahn/test/entry-{i}.md",
content_hash=f"sha256:hash{i}",
)
store.index.update_entry(entry)
# Minimaler Timeout: Sollte Teilergebnisse liefern
# Da die Iteration auf einer modernen Maschine schnell ist,
# mocken wir time.monotonic für einen kontrollierten Test
call_count = 0
real_monotonic = time.monotonic
def fake_monotonic() -> float:
nonlocal call_count
call_count += 1
# Nach 5 Aufrufen "Timeout" simulieren
if call_count > 5:
return real_monotonic() + 10.0
return real_monotonic()
with patch("monorepo.knowledge.store.time.monotonic", side_effect=fake_monotonic):
results = store.search("api", ["bahn"], timeout=5.0)
# Sollte Teilergebnisse haben (nicht alle 1000)
if results:
assert all(r.partial_results is True for r in results)
assert len(results) < 1000
# ---------------------------------------------------------------------------
# Integration Tests
# ---------------------------------------------------------------------------
class TestSearchIntegration:
"""Integrationstests für die Volltextsuche."""
def test_search_finds_by_title(self, store_with_entries: KnowledgeStore) -> None:
"""Findet Einträge über Titel-Suche."""
results = store_with_entries.search(
"Cloud-Native Architektur", ["dhive"]
)
assert len(results) >= 1
assert results[0].entry.id == "dhive/reference/cloud-native"
assert results[0].relevance_score >= 0.8 # Title match
def test_search_finds_by_tag(self, store_with_entries: KnowledgeStore) -> None:
"""Findet Einträge über Tag-Suche."""
results = store_with_entries.search("deployment", ["bahn"])
assert len(results) >= 1
assert any(r.entry.id == "bahn/notes/kubernetes-deployment" for r in results)
def test_search_finds_by_summary(self, store_with_entries: KnowledgeStore) -> None:
"""Findet Einträge über Summary-Suche."""
results = store_with_entries.search("bewährter", ["privat"])
assert len(results) == 1
assert results[0].entry.id == "privat/notes/rest-patterns"
def test_case_insensitive_search(self, store_with_entries: KnowledgeStore) -> None:
"""Suche ist case-insensitive."""
results_lower = store_with_entries.search("kubernetes", ["bahn", "dhive"])
results_upper = store_with_entries.search("KUBERNETES", ["bahn", "dhive"])
assert len(results_lower) == len(results_upper)
def test_search_with_all_scopes(self, store_with_entries: KnowledgeStore) -> None:
"""Suche über alle Scopes liefert alle Treffer."""
all_scopes = ["bahn", "privat", "dhive", "shared"]
results = store_with_entries.search("api", all_scopes)
# api kommt in mehreren Einträgen vor (title, tags)
assert len(results) >= 3
@@ -0,0 +1,293 @@
"""Property-basierte Tests für die Scope-basierte Suchfilterung im KnowledgeStore.
**Validates: Requirements 3.3, 3.9**
Property 6: Suche respektiert Scope-Berechtigungen
- For any Suchanfrage und beliebige Menge autorisierter Scopes, dürfen die
Ergebnisse ausschließlich Artefakte enthalten, deren Scope in der autorisierten
Menge liegt. Artefakte aus nicht-autorisierten Scopes dürfen weder in der
Ergebnisliste erscheinen noch darf deren Existenz offengelegt werden.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.store import KnowledgeStore, SearchResult
from monorepo.models import ScopeConfig
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
ALL_SCOPES = ["privat", "dhive", "bahn", "shared"]
ARTIFACT_TYPES = ["decision", "note", "meeting", "reference", "pattern"]
# Sample words that can appear in titles, tags, summaries
SAMPLE_WORDS = [
"api", "kubernetes", "docker", "python", "design",
"architecture", "cloud", "deployment", "service", "testing",
"database", "security", "network", "monitoring", "logging",
"pipeline", "migration", "config", "infra", "tooling",
]
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
@st.composite
def index_entry_strategy(draw: st.DrawFn) -> IndexEntry:
"""Generates a random but well-formed IndexEntry with a random scope."""
scope = draw(st.sampled_from(ALL_SCOPES))
artifact_type = draw(st.sampled_from(ARTIFACT_TYPES))
# Generate a title using sample words
title_words = draw(st.lists(
st.sampled_from(SAMPLE_WORDS), min_size=1, max_size=4
))
title = " ".join(title_words).title()
# Generate tags
tags = draw(st.lists(
st.sampled_from(SAMPLE_WORDS), min_size=1, max_size=5, unique=True
))
# Generate summary using sample words
summary_words = draw(st.lists(
st.sampled_from(SAMPLE_WORDS), min_size=2, max_size=8
))
summary = " ".join(summary_words)
# Generate a unique ID based on scope and type
name_suffix = draw(st.integers(min_value=1, max_value=9999))
entry_id = f"{scope}/{artifact_type}s/entry-{name_suffix}"
return IndexEntry(
id=entry_id,
title=title,
type=artifact_type,
tags=tags,
scope=scope,
summary=summary,
path=f"{entry_id}.md",
content_hash=f"sha256:{draw(st.text(alphabet='0123456789abcdef', min_size=8, max_size=8))}",
links=[],
)
@st.composite
def search_scenario(draw: st.DrawFn) -> tuple[list[IndexEntry], str, list[str]]:
"""Generates a complete search scenario:
- A list of index entries with various scopes
- A search query that matches at least some entries
- A random subset of allowed scopes
Returns:
Tuple of (entries, query, allowed_scopes)
"""
# Generate entries (between 5 and 30)
entries = draw(st.lists(
index_entry_strategy(), min_size=5, max_size=30, unique_by=lambda e: e.id
))
# Pick a search query from the sample words (guaranteed to match something)
query = draw(st.sampled_from(SAMPLE_WORDS))
# Generate a random subset of allowed scopes (at least one, at most all)
allowed_scopes = draw(st.lists(
st.sampled_from(ALL_SCOPES), min_size=1, max_size=4, unique=True
))
return entries, query, allowed_scopes
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_scope_config() -> ScopeConfig:
"""Creates a standard ScopeConfig for testing."""
return ScopeConfig(scopes={
"privat": {"scope": "privat", "paths": ["privat/"]},
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
"shared": {"scope": "shared", "paths": ["shared/"]},
})
def _build_store(entries: list[IndexEntry]) -> KnowledgeStore:
"""Creates a KnowledgeStore populated with the given entries.
Uses a temporary directory so no pytest tmp_path fixture is needed.
The store only uses the path for index file location; we populate
entries in-memory, so the path doesn't need to persist.
"""
scope_config = _build_scope_config()
# Use a temp dir that won't conflict across hypothesis iterations
tmp = Path(tempfile.mkdtemp())
store = KnowledgeStore(tmp, scope_config)
for entry in entries:
store.index.update_entry(entry)
return store
# ---------------------------------------------------------------------------
# Property Tests
# ---------------------------------------------------------------------------
class TestProperty6SearchRespectsScopes:
"""Property 6: Suche respektiert Scope-Berechtigungen.
**Validates: Requirements 3.3, 3.9**
For any search query and any set of authorized scopes, results must
exclusively contain artifacts whose scope is in the authorized set.
Artifacts from unauthorized scopes must neither appear in the result
list nor reveal their existence.
"""
@given(data=st.data())
@settings(max_examples=200)
def test_all_results_have_authorized_scope(self, data: st.DataObject) -> None:
"""ALL search results must have a scope that is in the allowed_scopes set.
**Validates: Requirements 3.3**
"""
entries, query, allowed_scopes = data.draw(search_scenario())
store = _build_store(entries)
results = store.search(query, allowed_scopes)
for result in results:
assert result.entry.scope in allowed_scopes, (
f"Result with scope '{result.entry.scope}' found, "
f"but allowed_scopes are {allowed_scopes}. "
f"Entry: id={result.entry.id}, title={result.entry.title}"
)
@given(data=st.data())
@settings(max_examples=200)
def test_no_unauthorized_entries_in_results(self, data: st.DataObject) -> None:
"""No entry from an unauthorized scope may appear in results.
**Validates: Requirements 3.9**
"""
entries, query, allowed_scopes = data.draw(search_scenario())
unauthorized_scopes = [s for s in ALL_SCOPES if s not in allowed_scopes]
# Only meaningful if there are unauthorized scopes
assume(len(unauthorized_scopes) > 0)
store = _build_store(entries)
results = store.search(query, allowed_scopes)
result_ids = {r.entry.id for r in results}
# Verify that no entry from unauthorized scopes leaked into results
for entry in entries:
if entry.scope in unauthorized_scopes:
assert entry.id not in result_ids, (
f"Entry '{entry.id}' from unauthorized scope '{entry.scope}' "
f"appeared in results. Allowed scopes: {allowed_scopes}"
)
@given(data=st.data())
@settings(max_examples=200)
def test_empty_scopes_yields_no_results(self, data: st.DataObject) -> None:
"""With an empty allowed_scopes list, search must return no results.
**Validates: Requirements 3.3, 3.9**
"""
entries = data.draw(
st.lists(index_entry_strategy(), min_size=5, max_size=20, unique_by=lambda e: e.id)
)
query = data.draw(st.sampled_from(SAMPLE_WORDS))
store = _build_store(entries)
results = store.search(query, [])
assert results == [], (
f"Search with empty allowed_scopes returned {len(results)} results. "
f"Expected 0 results."
)
@given(data=st.data())
@settings(max_examples=200)
def test_restricting_scopes_never_adds_results(self, data: st.DataObject) -> None:
"""Reducing the set of allowed scopes must never increase the number of results.
This tests monotonicity: if S1 ⊆ S2, then |search(q, S1)| ≤ |search(q, S2)|.
This ensures restricting permissions cannot reveal additional artifacts.
**Validates: Requirements 3.3, 3.9**
"""
entries = data.draw(
st.lists(index_entry_strategy(), min_size=5, max_size=30, unique_by=lambda e: e.id)
)
query = data.draw(st.sampled_from(SAMPLE_WORDS))
# Draw a larger scope set and a subset of it
larger_scopes = data.draw(st.lists(
st.sampled_from(ALL_SCOPES), min_size=2, max_size=4, unique=True
))
smaller_scopes = data.draw(st.lists(
st.sampled_from(larger_scopes), min_size=1, max_size=len(larger_scopes) - 1, unique=True
))
# Ensure smaller is truly a subset
assume(set(smaller_scopes).issubset(set(larger_scopes)))
assume(len(smaller_scopes) < len(larger_scopes))
store = _build_store(entries)
results_larger = store.search(query, larger_scopes)
results_smaller = store.search(query, smaller_scopes)
assert len(results_smaller) <= len(results_larger), (
f"Restricting scopes from {larger_scopes} to {smaller_scopes} "
f"increased results from {len(results_larger)} to {len(results_smaller)}. "
f"This violates scope-based access control monotonicity."
)
@given(data=st.data())
@settings(max_examples=200)
def test_unauthorized_existence_not_revealed(self, data: st.DataObject) -> None:
"""The existence of artifacts in unauthorized scopes must not be
discoverable through search results (no metadata leakage).
**Validates: Requirements 3.9**
"""
entries, query, allowed_scopes = data.draw(search_scenario())
unauthorized_scopes = [s for s in ALL_SCOPES if s not in allowed_scopes]
assume(len(unauthorized_scopes) > 0)
store = _build_store(entries)
results = store.search(query, allowed_scopes)
# Collect all information exposed through results
exposed_ids = {r.entry.id for r in results}
exposed_scopes = {r.entry.scope for r in results}
# No result should reference any unauthorized scope in its scope field
for scope in unauthorized_scopes:
assert scope not in exposed_scopes, (
f"Unauthorized scope '{scope}' exposed in result scopes. "
f"Allowed: {allowed_scopes}"
)
# No unauthorized entry IDs should appear in results
unauthorized_ids = {e.id for e in entries if e.scope in unauthorized_scopes}
leaked_ids = exposed_ids & unauthorized_ids
assert len(leaked_ids) == 0, (
f"Unauthorized entry IDs leaked into results: {leaked_ids}"
)
@@ -0,0 +1,718 @@
"""Tests für KnowledgeStore und YAMLIndex.
Validiert:
- IndexEntry-Serialisierung und Deserialisierung
- YAMLIndex: load, save, update_entry, search, get_by_scope
- KnowledgeStore: search, get_index, link_artifacts
- Progressive Disclosure: Kein vollständiger Inhalt im Index
"""
from pathlib import Path
import pytest
import yaml
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.store import KnowledgeStore
from monorepo.models import ScopeConfig
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_index_path(tmp_path: Path) -> Path:
"""Temporärer Pfad für die Index-Datei."""
return tmp_path / "_index.yaml"
@pytest.fixture
def sample_entries() -> list[IndexEntry]:
"""Beispiel-Einträge für Tests."""
return [
IndexEntry(
id="bahn/decisions/api-design",
title="API-Designprinzipien für Microservices",
type="decision",
tags=["api", "microservices", "architecture"],
scope="bahn",
summary="REST-API-Konventionen für DB-InfraGO-Dienste",
path="bahn/decisions/api-design.md",
content_hash="sha256:abc123",
links=["shared/patterns/api-versioning"],
),
IndexEntry(
id="privat/notes/rest-patterns",
title="REST-Patterns Notizen",
type="note",
tags=["api", "rest", "patterns"],
scope="privat",
summary="Sammlung bewährter REST-Patterns",
path="privat/notes/rest-patterns.md",
content_hash="sha256:def456",
links=["bahn/decisions/api-design"],
),
IndexEntry(
id="dhive/reference/cloud-native",
title="Cloud-Native Architektur Guide",
type="reference",
tags=["cloud", "kubernetes", "architecture"],
scope="dhive",
summary="Leitfaden für cloud-native Anwendungen bei dhive",
path="dhive/reference/cloud-native.md",
content_hash="sha256:ghi789",
links=[],
),
]
@pytest.fixture
def scope_config() -> ScopeConfig:
"""Beispiel-ScopeConfig."""
return ScopeConfig(scopes={
"privat": {"scope": "privat", "paths": ["privat/"]},
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
"shared": {"scope": "shared", "paths": ["shared/"]},
})
# ---------------------------------------------------------------------------
# IndexEntry Tests
# ---------------------------------------------------------------------------
class TestIndexEntry:
"""Tests für IndexEntry Dataclass."""
def test_to_dict_contains_all_fields(self, sample_entries: list[IndexEntry]) -> None:
"""to_dict() enthält alle definierten Felder."""
entry = sample_entries[0]
d = entry.to_dict()
assert d["id"] == "bahn/decisions/api-design"
assert d["title"] == "API-Designprinzipien für Microservices"
assert d["type"] == "decision"
assert d["tags"] == ["api", "microservices", "architecture"]
assert d["scope"] == "bahn"
assert d["summary"] == "REST-API-Konventionen für DB-InfraGO-Dienste"
assert d["path"] == "bahn/decisions/api-design.md"
assert d["content_hash"] == "sha256:abc123"
assert d["links"] == ["shared/patterns/api-versioning"]
def test_from_dict_roundtrip(self, sample_entries: list[IndexEntry]) -> None:
"""from_dict(to_dict()) ergibt identischen Eintrag."""
for entry in sample_entries:
d = entry.to_dict()
restored = IndexEntry.from_dict(d)
assert restored.id == entry.id
assert restored.title == entry.title
assert restored.type == entry.type
assert restored.tags == entry.tags
assert restored.scope == entry.scope
assert restored.summary == entry.summary
assert restored.path == entry.path
assert restored.content_hash == entry.content_hash
assert restored.links == entry.links
def test_from_dict_with_missing_fields(self) -> None:
"""from_dict() mit fehlenden Feldern setzt leere Defaults."""
entry = IndexEntry.from_dict({"id": "test/minimal", "title": "Minimal", "type": "note"})
assert entry.id == "test/minimal"
assert entry.title == "Minimal"
assert entry.type == "note"
assert entry.tags == []
assert entry.scope == ""
assert entry.summary == ""
assert entry.path == ""
assert entry.content_hash == ""
assert entry.links == []
# ---------------------------------------------------------------------------
# YAMLIndex Tests
# ---------------------------------------------------------------------------
class TestYAMLIndex:
"""Tests für YAMLIndex."""
def test_load_nonexistent_file(self, tmp_index_path: Path) -> None:
"""load() bei nicht-existierender Datei erzeugt leeren Index."""
index = YAMLIndex(tmp_index_path)
index.load()
assert index.entries == {}
assert index.version == "1.0"
assert index.last_updated == ""
def test_save_creates_file(self, tmp_index_path: Path) -> None:
"""save() erstellt die Index-Datei."""
index = YAMLIndex(tmp_index_path)
index.load()
index.save()
assert tmp_index_path.exists()
def test_save_and_load_roundtrip(
self, tmp_index_path: Path, sample_entries: list[IndexEntry]
) -> None:
"""save() und load() ergeben identische Einträge."""
# Speichern
index = YAMLIndex(tmp_index_path)
index.load()
for entry in sample_entries:
index.update_entry(entry)
index.save()
# Laden
index2 = YAMLIndex(tmp_index_path)
index2.load()
assert len(index2.entries) == len(sample_entries)
for entry in sample_entries:
loaded = index2.get_entry(entry.id)
assert loaded is not None
assert loaded.title == entry.title
assert loaded.type == entry.type
assert loaded.tags == entry.tags
assert loaded.scope == entry.scope
def test_save_format(
self, tmp_index_path: Path, sample_entries: list[IndexEntry]
) -> None:
"""save() erzeugt das korrekte YAML-Format gemäß Design."""
index = YAMLIndex(tmp_index_path)
index.load()
index.update_entry(sample_entries[0])
index.save()
with open(tmp_index_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
assert "version" in data
assert data["version"] == "1.0"
assert "last_updated" in data
assert "artifacts" in data
assert isinstance(data["artifacts"], list)
assert len(data["artifacts"]) == 1
artifact = data["artifacts"][0]
assert artifact["id"] == "bahn/decisions/api-design"
assert artifact["title"] == "API-Designprinzipien für Microservices"
assert artifact["type"] == "decision"
assert artifact["scope"] == "bahn"
assert "content_hash" in artifact
assert "links" in artifact
def test_update_entry_adds_new(self, tmp_index_path: Path) -> None:
"""update_entry() fügt neuen Eintrag hinzu."""
index = YAMLIndex(tmp_index_path)
index.load()
entry = IndexEntry(id="test/new-entry", title="Neuer Eintrag", type="note")
index.update_entry(entry)
assert "test/new-entry" in index.entries
assert index.entries["test/new-entry"].title == "Neuer Eintrag"
def test_update_entry_overwrites_existing(
self, tmp_index_path: Path, sample_entries: list[IndexEntry]
) -> None:
"""update_entry() überschreibt bestehenden Eintrag bei gleicher ID."""
index = YAMLIndex(tmp_index_path)
index.load()
index.update_entry(sample_entries[0])
# Aktualisieren
updated = IndexEntry(
id=sample_entries[0].id,
title="Aktualisierter Titel",
type="decision",
scope="bahn",
)
index.update_entry(updated)
assert index.entries[sample_entries[0].id].title == "Aktualisierter Titel"
assert len(index.entries) == 1
def test_update_entry_empty_id_raises(self, tmp_index_path: Path) -> None:
"""update_entry() mit leerer ID wirft ValueError."""
index = YAMLIndex(tmp_index_path)
index.load()
with pytest.raises(ValueError, match="nicht-leere ID"):
index.update_entry(IndexEntry(id="", title="Test", type="note"))
def test_search_finds_by_title(
self, tmp_index_path: Path, sample_entries: list[IndexEntry]
) -> None:
"""search() findet Einträge über den Titel."""
index = YAMLIndex(tmp_index_path)
index.load()
for entry in sample_entries:
index.update_entry(entry)
results = index.search("API-Design", ["bahn", "privat", "dhive"])
assert len(results) >= 1
assert any(r.id == "bahn/decisions/api-design" for r in results)
def test_search_finds_by_tags(
self, tmp_index_path: Path, sample_entries: list[IndexEntry]
) -> None:
"""search() findet Einträge über Tags."""
index = YAMLIndex(tmp_index_path)
index.load()
for entry in sample_entries:
index.update_entry(entry)
results = index.search("kubernetes", ["dhive"])
assert len(results) == 1
assert results[0].id == "dhive/reference/cloud-native"
def test_search_finds_by_summary(
self, tmp_index_path: Path, sample_entries: list[IndexEntry]
) -> None:
"""search() findet Einträge über Summary."""
index = YAMLIndex(tmp_index_path)
index.load()
for entry in sample_entries:
index.update_entry(entry)
results = index.search("bewährter", ["privat"])
assert len(results) == 1
assert results[0].id == "privat/notes/rest-patterns"
def test_search_respects_scope_filter(
self, tmp_index_path: Path, sample_entries: list[IndexEntry]
) -> None:
"""search() liefert nur Ergebnisse aus erlaubten Scopes."""
index = YAMLIndex(tmp_index_path)
index.load()
for entry in sample_entries:
index.update_entry(entry)
# Suche nach "api" existiert in bahn und privat
results = index.search("api", ["bahn"])
# Nur bahn-Ergebnisse
for r in results:
assert r.scope == "bahn"
def test_search_empty_query_returns_empty(
self, tmp_index_path: Path, sample_entries: list[IndexEntry]
) -> None:
"""search() mit leerem Query gibt leere Liste zurück."""
index = YAMLIndex(tmp_index_path)
index.load()
for entry in sample_entries:
index.update_entry(entry)
results = index.search("", ["bahn", "privat", "dhive"])
assert results == []
def test_search_case_insensitive(
self, tmp_index_path: Path, sample_entries: list[IndexEntry]
) -> None:
"""search() ist case-insensitive."""
index = YAMLIndex(tmp_index_path)
index.load()
for entry in sample_entries:
index.update_entry(entry)
results_lower = index.search("rest", ["bahn", "privat", "dhive"])
results_upper = index.search("REST", ["bahn", "privat", "dhive"])
assert len(results_lower) == len(results_upper)
def test_get_by_scope(
self, tmp_index_path: Path, sample_entries: list[IndexEntry]
) -> None:
"""get_by_scope() liefert alle Einträge eines Scopes."""
index = YAMLIndex(tmp_index_path)
index.load()
for entry in sample_entries:
index.update_entry(entry)
bahn_entries = index.get_by_scope("bahn")
assert len(bahn_entries) == 1
assert bahn_entries[0].id == "bahn/decisions/api-design"
privat_entries = index.get_by_scope("privat")
assert len(privat_entries) == 1
assert privat_entries[0].id == "privat/notes/rest-patterns"
def test_get_by_scope_empty(
self, tmp_index_path: Path, sample_entries: list[IndexEntry]
) -> None:
"""get_by_scope() für nicht-existierenden Scope gibt leere Liste."""
index = YAMLIndex(tmp_index_path)
index.load()
for entry in sample_entries:
index.update_entry(entry)
results = index.get_by_scope("nonexistent")
assert results == []
def test_remove_entry(self, tmp_index_path: Path, sample_entries: list[IndexEntry]) -> None:
"""remove_entry() entfernt einen Eintrag aus dem Index."""
index = YAMLIndex(tmp_index_path)
index.load()
index.update_entry(sample_entries[0])
result = index.remove_entry(sample_entries[0].id)
assert result is True
assert sample_entries[0].id not in index.entries
def test_remove_entry_nonexistent(self, tmp_index_path: Path) -> None:
"""remove_entry() gibt False zurück bei nicht-existierendem Eintrag."""
index = YAMLIndex(tmp_index_path)
index.load()
result = index.remove_entry("nonexistent/entry")
assert result is False
def test_get_entry(self, tmp_index_path: Path, sample_entries: list[IndexEntry]) -> None:
"""get_entry() liefert Eintrag nach ID."""
index = YAMLIndex(tmp_index_path)
index.load()
index.update_entry(sample_entries[0])
entry = index.get_entry(sample_entries[0].id)
assert entry is not None
assert entry.id == sample_entries[0].id
def test_get_entry_nonexistent(self, tmp_index_path: Path) -> None:
"""get_entry() gibt None zurück bei nicht-existierendem Eintrag."""
index = YAMLIndex(tmp_index_path)
index.load()
entry = index.get_entry("nonexistent")
assert entry is None
def test_progressive_disclosure_no_content_in_index(
self, tmp_index_path: Path
) -> None:
"""Der Index enthält niemals vollständigen Dokumentinhalt."""
index = YAMLIndex(tmp_index_path)
index.load()
# Ein Eintrag mit allen Feldern aber kein 'content'-Feld
entry = IndexEntry(
id="test/doc",
title="Testdokument",
type="note",
tags=["test"],
scope="privat",
summary="Kurzbeschreibung des Dokuments",
path="privat/notes/test-doc.md",
content_hash="sha256:test123",
links=[],
)
index.update_entry(entry)
index.save()
# YAML-Datei prüfen: Kein 'content'-Feld in Artefakten
with open(tmp_index_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
for artifact in data["artifacts"]:
assert "content" not in artifact
# Nur erlaubte Felder
allowed_fields = {"id", "title", "type", "tags", "scope", "summary",
"path", "content_hash", "links"}
assert set(artifact.keys()) <= allowed_fields
# ---------------------------------------------------------------------------
# KnowledgeStore Tests
# ---------------------------------------------------------------------------
class TestKnowledgeStore:
"""Tests für KnowledgeStore."""
def test_init_creates_index(self, tmp_path: Path, scope_config: ScopeConfig) -> None:
"""KnowledgeStore initialisiert YAMLIndex."""
store = KnowledgeStore(tmp_path, scope_config)
assert store.index is not None
assert isinstance(store.index, YAMLIndex)
def test_search_delegates_to_index(
self, tmp_path: Path, scope_config: ScopeConfig, sample_entries: list[IndexEntry]
) -> None:
"""search() liefert SearchResult-Objekte mit Relevanz-Score."""
store = KnowledgeStore(tmp_path, scope_config)
for entry in sample_entries:
store.index.update_entry(entry)
results = store.search("api", ["bahn", "privat"])
assert len(results) >= 1
# Ergebnisse sind SearchResult-Objekte
from monorepo.knowledge.store import SearchResult
for r in results:
assert isinstance(r, SearchResult)
assert r.entry.scope in ["bahn", "privat"]
assert 0.0 <= r.relevance_score <= 1.0
def test_search_scope_filtering(
self, tmp_path: Path, scope_config: ScopeConfig, sample_entries: list[IndexEntry]
) -> None:
"""search() respektiert Scope-Berechtigungen."""
store = KnowledgeStore(tmp_path, scope_config)
for entry in sample_entries:
store.index.update_entry(entry)
# Nur privat-Scope zugelassen
results = store.search("api", ["privat"])
for r in results:
assert r.entry.scope == "privat"
def test_get_index_all(
self, tmp_path: Path, scope_config: ScopeConfig, sample_entries: list[IndexEntry]
) -> None:
"""get_index() ohne Scope liefert alle Einträge."""
store = KnowledgeStore(tmp_path, scope_config)
for entry in sample_entries:
store.index.update_entry(entry)
all_entries = store.get_index()
assert len(all_entries) == len(sample_entries)
def test_get_index_with_scope(
self, tmp_path: Path, scope_config: ScopeConfig, sample_entries: list[IndexEntry]
) -> None:
"""get_index(scope) filtert nach Scope."""
store = KnowledgeStore(tmp_path, scope_config)
for entry in sample_entries:
store.index.update_entry(entry)
bahn_entries = store.get_index("bahn")
assert len(bahn_entries) == 1
assert bahn_entries[0].scope == "bahn"
def test_link_artifacts(
self, tmp_path: Path, scope_config: ScopeConfig, sample_entries: list[IndexEntry]
) -> None:
"""link_artifacts() erstellt bidirektionale Verknüpfung."""
store = KnowledgeStore(tmp_path, scope_config)
# Frische Einträge ohne bestehende Links
entry_a = IndexEntry(
id="test/entry-a", title="Entry A", type="note", scope="privat"
)
entry_b = IndexEntry(
id="test/entry-b", title="Entry B", type="decision", scope="bahn"
)
store.index.update_entry(entry_a)
store.index.update_entry(entry_b)
store.link_artifacts("test/entry-a", "test/entry-b", "references")
# Prüfe bidirektionale Links
a = store.index.get_entry("test/entry-a")
b = store.index.get_entry("test/entry-b")
assert a is not None and "test/entry-b" in a.links
assert b is not None and "test/entry-a" in b.links
def test_link_artifacts_nonexistent_source_raises(
self, tmp_path: Path, scope_config: ScopeConfig, sample_entries: list[IndexEntry]
) -> None:
"""link_artifacts() mit nicht-existierender Source wirft ValueError."""
store = KnowledgeStore(tmp_path, scope_config)
store.index.update_entry(sample_entries[0])
with pytest.raises(ValueError, match="Quell-Artefakt"):
store.link_artifacts("nonexistent", sample_entries[0].id, "references")
def test_link_artifacts_nonexistent_target_raises(
self, tmp_path: Path, scope_config: ScopeConfig, sample_entries: list[IndexEntry]
) -> None:
"""link_artifacts() mit nicht-existierendem Target wirft ValueError."""
store = KnowledgeStore(tmp_path, scope_config)
store.index.update_entry(sample_entries[0])
with pytest.raises(ValueError, match="Ziel-Artefakt"):
store.link_artifacts(sample_entries[0].id, "nonexistent", "references")
def test_link_artifacts_idempotent(
self, tmp_path: Path, scope_config: ScopeConfig
) -> None:
"""link_artifacts() doppelt aufgerufen erzeugt keine Duplikat-Links."""
store = KnowledgeStore(tmp_path, scope_config)
entry_a = IndexEntry(id="a", title="A", type="note", scope="privat")
entry_b = IndexEntry(id="b", title="B", type="note", scope="privat")
store.index.update_entry(entry_a)
store.index.update_entry(entry_b)
store.link_artifacts("a", "b", "references")
store.link_artifacts("a", "b", "references")
a = store.index.get_entry("a")
assert a is not None
assert a.links.count("b") == 1
def test_ingest_empty_directory_returns_zero_processed(
self, tmp_path: Path, scope_config: ScopeConfig
) -> None:
"""ingest() mit leerem Verzeichnis verarbeitet null Artefakte."""
from monorepo.knowledge.sources.markdown import MarkdownSource
store = KnowledgeStore(tmp_path, scope_config)
empty_dir = tmp_path / "empty_source"
empty_dir.mkdir()
source = MarkdownSource(directory=empty_dir)
result = store.ingest(source, "bahn")
assert result.processed == 0
assert result.updated == 0
assert result.skipped == 0
def test_link_artifacts_updates_frontmatter_bidirectional(
self, tmp_path: Path, scope_config: ScopeConfig
) -> None:
"""link_artifacts() aktualisiert das YAML-Frontmatter beider Artefakt-Dateien."""
store = KnowledgeStore(tmp_path, scope_config)
# Artefakt-Dateien erstellen
source_dir = tmp_path / "bahn" / "decision"
source_dir.mkdir(parents=True)
source_file = source_dir / "source-doc.md"
source_file.write_text(
"---\ntype: decision\ntitle: Source Doc\nsource_context: bahn\nshareable: false\n---\n\n# Source\n",
encoding="utf-8",
)
target_dir = tmp_path / "privat" / "note"
target_dir.mkdir(parents=True)
target_file = target_dir / "target-doc.md"
target_file.write_text(
"---\ntype: note\ntitle: Target Doc\nsource_context: privat\nshareable: false\n---\n\n# Target\n",
encoding="utf-8",
)
# Index-Einträge mit korrekten Pfaden erstellen
entry_a = IndexEntry(
id="source",
title="Source Doc",
type="decision",
scope="bahn",
path="bahn/decision/source-doc.md",
)
entry_b = IndexEntry(
id="target",
title="Target Doc",
type="note",
scope="privat",
path="privat/note/target-doc.md",
)
store.index.update_entry(entry_a)
store.index.update_entry(entry_b)
store.link_artifacts("source", "target", "references")
# Source-Datei prüfen
from monorepo.knowledge.artifact import parse_frontmatter
source_artifact = parse_frontmatter(source_file)
assert len(source_artifact.metadata.links) == 1
assert source_artifact.metadata.links[0].target == "privat/note/target-doc.md"
assert source_artifact.metadata.links[0].relation == "references"
# Target-Datei prüfen
target_artifact = parse_frontmatter(target_file)
assert len(target_artifact.metadata.links) == 1
assert target_artifact.metadata.links[0].target == "bahn/decision/source-doc.md"
assert target_artifact.metadata.links[0].relation == "references"
def test_link_artifacts_frontmatter_idempotent(
self, tmp_path: Path, scope_config: ScopeConfig
) -> None:
"""link_artifacts() zweimal aufgerufen erzeugt keine Duplikat-Links im Frontmatter."""
store = KnowledgeStore(tmp_path, scope_config)
# Artefakt-Datei erstellen
source_dir = tmp_path / "bahn" / "decision"
source_dir.mkdir(parents=True)
source_file = source_dir / "idempotent.md"
source_file.write_text(
"---\ntype: decision\ntitle: Idempotent\nsource_context: bahn\nshareable: false\n---\n\nContent\n",
encoding="utf-8",
)
target_dir = tmp_path / "privat" / "note"
target_dir.mkdir(parents=True)
target_file = target_dir / "other.md"
target_file.write_text(
"---\ntype: note\ntitle: Other\nsource_context: privat\nshareable: false\n---\n\nContent\n",
encoding="utf-8",
)
entry_a = IndexEntry(
id="a", title="Idempotent", type="decision", scope="bahn",
path="bahn/decision/idempotent.md",
)
entry_b = IndexEntry(
id="b", title="Other", type="note", scope="privat",
path="privat/note/other.md",
)
store.index.update_entry(entry_a)
store.index.update_entry(entry_b)
# Zweimal verlinken
store.link_artifacts("a", "b", "references")
store.link_artifacts("a", "b", "references")
# Frontmatter prüfen: Kein Duplikat
from monorepo.knowledge.artifact import parse_frontmatter
source_artifact = parse_frontmatter(source_file)
matching_links = [
lnk for lnk in source_artifact.metadata.links
if lnk.target == "privat/note/other.md" and lnk.relation == "references"
]
assert len(matching_links) == 1
def test_link_artifacts_missing_files_only_updates_index(
self, tmp_path: Path, scope_config: ScopeConfig
) -> None:
"""link_artifacts() aktualisiert nur den Index wenn Artefakt-Dateien fehlen."""
store = KnowledgeStore(tmp_path, scope_config)
# Index-Einträge mit Pfaden, aber ohne tatsächliche Dateien
entry_a = IndexEntry(
id="missing-a", title="Missing A", type="note", scope="privat",
path="privat/note/missing-a.md",
)
entry_b = IndexEntry(
id="missing-b", title="Missing B", type="note", scope="bahn",
path="bahn/note/missing-b.md",
)
store.index.update_entry(entry_a)
store.index.update_entry(entry_b)
# Soll nicht fehlschlagen, auch wenn Dateien nicht existieren
store.link_artifacts("missing-a", "missing-b", "references")
# Index wurde trotzdem aktualisiert
a = store.index.get_entry("missing-a")
b = store.index.get_entry("missing-b")
assert a is not None and "missing-b" in a.links
assert b is not None and "missing-a" in b.links
def test_link_artifacts_no_path_skips_frontmatter(
self, tmp_path: Path, scope_config: ScopeConfig
) -> None:
"""link_artifacts() überspringt Frontmatter-Update wenn kein Pfad im Index."""
store = KnowledgeStore(tmp_path, scope_config)
# Einträge ohne Pfad
entry_a = IndexEntry(id="no-path-a", title="A", type="note", scope="privat")
entry_b = IndexEntry(id="no-path-b", title="B", type="note", scope="bahn")
store.index.update_entry(entry_a)
store.index.update_entry(entry_b)
# Soll nicht fehlschlagen
store.link_artifacts("no-path-a", "no-path-b", "references")
# Index trotzdem aktualisiert
a = store.index.get_entry("no-path-a")
assert a is not None and "no-path-b" in a.links
@@ -0,0 +1,346 @@
"""Property-basierte Tests für MCP-Konfiguration-Merge mit Kontext-Vorrang.
**Validates: Requirements 8.4, 8.6**
Property 21: MCP-Konfiguration-Merge mit Kontext-Vorrang
- For any MCP-Server-Konfiguration, bei der sowohl eine shared-Konfiguration als auch
eine kontextspezifische Konfiguration existiert, muss die effektive Konfiguration die
kontextspezifischen Werte bevorzugen (Merge mit Override). Konflikte müssen mit
ausreichend Detail protokolliert werden.
"""
from __future__ import annotations
from pathlib import Path
import yaml
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.shared_config import ConfigMerger, MergeConflict
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CONTEXTS = ["privat", "dhive", "bahn"]
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
# Generate valid server names (simple identifiers)
server_name_st = st.from_regex(r"[a-z][a-z0-9\-]{1,20}", fullmatch=True)
# Generate command strings (non-empty)
command_st = st.from_regex(r"[a-z][a-z0-9/\-\.]{0,30}", fullmatch=True)
# Generate simple arg lists
args_st = st.lists(
st.from_regex(r"[a-z0-9\-\./@]{1,20}", fullmatch=True),
min_size=0,
max_size=5,
)
# Generate env vars (key-value pairs with simple identifiers)
env_key_st = st.from_regex(r"[A-Z][A-Z0-9_]{1,15}", fullmatch=True)
env_val_st = st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "P")),
min_size=1,
max_size=30,
)
env_st = st.dictionaries(env_key_st, env_val_st, min_size=0, max_size=5)
# Generate settings dictionaries
settings_key_st = st.from_regex(r"[a-z][a-z0-9_]{1,15}", fullmatch=True)
settings_val_st = st.one_of(
st.text(min_size=1, max_size=20),
st.integers(min_value=0, max_value=1000),
st.booleans(),
)
settings_st = st.dictionaries(settings_key_st, settings_val_st, min_size=0, max_size=4)
@st.composite
def mcp_server_config(draw: st.DrawFn) -> dict:
"""Generates a single MCP server configuration dict."""
config: dict = {}
# Always include a command
config["command"] = draw(command_st)
# Optionally include args
if draw(st.booleans()):
config["args"] = draw(args_st)
# Optionally include env
if draw(st.booleans()):
config["env"] = draw(env_st)
# Optionally include settings
if draw(st.booleans()):
config["settings"] = draw(settings_st)
return config
@st.composite
def mcp_config_dict(draw: st.DrawFn, min_servers: int = 1, max_servers: int = 5) -> dict[str, dict]:
"""Generates a dictionary of server_name -> server_config."""
num_servers = draw(st.integers(min_value=min_servers, max_value=max_servers))
servers = {}
names = draw(
st.lists(server_name_st, min_size=num_servers, max_size=num_servers, unique=True)
)
for name in names:
servers[name] = draw(mcp_server_config())
return servers
@st.composite
def shared_and_context_configs(draw: st.DrawFn) -> tuple[dict[str, dict], dict[str, dict], str]:
"""Generates shared config, context config, and a context name.
Ensures there is at least one overlapping server (for conflict testing)
and potentially servers unique to each side.
"""
context = draw(st.sampled_from(CONTEXTS))
# Generate shared servers
shared_servers = draw(mcp_config_dict(min_servers=1, max_servers=4))
# Generate context servers - ensure at least one overlapping key
shared_names = list(shared_servers.keys())
overlap_name = draw(st.sampled_from(shared_names))
# The overlapping server gets a distinct context config
context_servers: dict[str, dict] = {}
context_servers[overlap_name] = draw(mcp_server_config())
# Optionally add more context-only servers
extra_ctx_servers = draw(mcp_config_dict(min_servers=0, max_servers=3))
for name, cfg in extra_ctx_servers.items():
if name not in shared_servers:
context_servers[name] = cfg
return shared_servers, context_servers, context
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _setup_monorepo(
tmp_path: Path,
shared_servers: dict[str, dict],
context_servers: dict[str, dict],
context: str,
) -> ConfigMerger:
"""Creates the monorepo filesystem structure and writes configs."""
# Shared area
shared_mcp_dir = tmp_path / "shared" / "mcp-servers"
shared_mcp_dir.mkdir(parents=True)
(tmp_path / "shared" / "tools").mkdir(parents=True)
(tmp_path / "shared" / "powers").mkdir(parents=True)
(tmp_path / "shared" / "config").mkdir(parents=True)
# Contexts
for ctx in CONTEXTS:
(tmp_path / ctx).mkdir(exist_ok=True)
# Write shared config
shared_data = {"servers": shared_servers}
(shared_mcp_dir / "mcp-servers.yaml").write_text(
yaml.dump(shared_data, default_flow_style=False), encoding="utf-8"
)
# Write context config (if any)
if context_servers:
ctx_mcp_dir = tmp_path / context / ".mcp-servers"
ctx_mcp_dir.mkdir(parents=True, exist_ok=True)
ctx_data = {"servers": context_servers}
(ctx_mcp_dir / "mcp-servers.yaml").write_text(
yaml.dump(ctx_data, default_flow_style=False), encoding="utf-8"
)
return ConfigMerger(tmp_path)
# ---------------------------------------------------------------------------
# Property Tests
# ---------------------------------------------------------------------------
class TestMCPMergeContextPrecedence:
"""Property 21: MCP-Konfiguration-Merge mit Kontext-Vorrang.
**Validates: Requirements 8.4, 8.6**
"""
@settings(max_examples=100)
@given(data=shared_and_context_configs())
def test_context_overrides_shared_for_overlapping_servers(
self, data: tuple[dict, dict, str], tmp_path_factory
) -> None:
"""For any server defined in both shared and context configs,
the merged result must contain the context value for all overridden fields.
**Validates: Requirements 8.4**
"""
shared_servers, context_servers, context = data
tmp_path = tmp_path_factory.mktemp("merge")
merger = _setup_monorepo(tmp_path, shared_servers, context_servers, context)
result = merger.merge_mcp_config(context)
# For servers in both, context values must take precedence
for server_name in context_servers:
if server_name in shared_servers:
merged_server = result.servers[server_name]
ctx_cfg = context_servers[server_name]
# Command: context value must win
if ctx_cfg.get("command"):
assert merged_server.command == ctx_cfg["command"]
# Args: context value must win (if provided)
if ctx_cfg.get("args"):
assert merged_server.args == ctx_cfg["args"]
# Env: context values must override shared for same keys
if ctx_cfg.get("env"):
for key, val in ctx_cfg["env"].items():
assert merged_server.env[key] == val
# Settings: context values must override shared for same keys
if ctx_cfg.get("settings"):
for key, val in ctx_cfg["settings"].items():
assert merged_server.settings[key] == val
@settings(max_examples=100)
@given(data=shared_and_context_configs())
def test_shared_only_servers_appear_unchanged(
self, data: tuple[dict, dict, str], tmp_path_factory
) -> None:
"""For any server only in shared, it must appear in the merged result unchanged.
**Validates: Requirements 8.4**
"""
shared_servers, context_servers, context = data
tmp_path = tmp_path_factory.mktemp("merge")
merger = _setup_monorepo(tmp_path, shared_servers, context_servers, context)
result = merger.merge_mcp_config(context)
# Servers only in shared must be present unchanged
for server_name, shared_cfg in shared_servers.items():
if server_name not in context_servers:
assert server_name in result.servers
merged = result.servers[server_name]
assert merged.command == shared_cfg.get("command", "")
assert merged.args == shared_cfg.get("args", [])
assert merged.env == shared_cfg.get("env", {})
assert merged.settings == shared_cfg.get("settings", {})
@settings(max_examples=100)
@given(data=shared_and_context_configs())
def test_context_only_servers_appear_in_result(
self, data: tuple[dict, dict, str], tmp_path_factory
) -> None:
"""For any server only in context, it must appear in the merged result.
**Validates: Requirements 8.4**
"""
shared_servers, context_servers, context = data
tmp_path = tmp_path_factory.mktemp("merge")
merger = _setup_monorepo(tmp_path, shared_servers, context_servers, context)
result = merger.merge_mcp_config(context)
# Context-only servers must appear
for server_name in context_servers:
assert server_name in result.servers
@settings(max_examples=100)
@given(data=shared_and_context_configs())
def test_conflicts_logged_when_values_differ(
self, data: tuple[dict, dict, str], tmp_path_factory
) -> None:
"""When shared and context have different values for the same key,
a conflict must be logged.
**Validates: Requirements 8.6**
"""
shared_servers, context_servers, context = data
tmp_path = tmp_path_factory.mktemp("merge")
merger = _setup_monorepo(tmp_path, shared_servers, context_servers, context)
result = merger.merge_mcp_config(context)
# Check that for every overlapping server with differing values,
# conflicts are reported
for server_name in context_servers:
if server_name not in shared_servers:
continue
shared_cfg = shared_servers[server_name]
ctx_cfg = context_servers[server_name]
# Command conflict
shared_cmd = shared_cfg.get("command", "")
ctx_cmd = ctx_cfg.get("command", "")
if shared_cmd and ctx_cmd and shared_cmd != ctx_cmd:
cmd_conflicts = [
c for c in result.conflicts
if c.server_name == server_name and c.key == "command"
]
assert len(cmd_conflicts) == 1, (
f"Expected command conflict for server '{server_name}' "
f"(shared={shared_cmd!r}, context={ctx_cmd!r})"
)
# Env conflicts
shared_env = shared_cfg.get("env", {})
ctx_env = ctx_cfg.get("env", {})
for env_key in ctx_env:
if env_key in shared_env and shared_env[env_key] != ctx_env[env_key]:
env_conflicts = [
c for c in result.conflicts
if c.server_name == server_name and c.key == f"env.{env_key}"
]
assert len(env_conflicts) == 1, (
f"Expected env conflict for server '{server_name}', "
f"key 'env.{env_key}'"
)
@settings(max_examples=100)
@given(data=shared_and_context_configs())
def test_conflict_log_contains_required_details(
self, data: tuple[dict, dict, str], tmp_path_factory
) -> None:
"""The conflict log must contain: context name, key name,
shared value, context value, resolution.
**Validates: Requirements 8.6**
"""
shared_servers, context_servers, context = data
tmp_path = tmp_path_factory.mktemp("merge")
merger = _setup_monorepo(tmp_path, shared_servers, context_servers, context)
result = merger.merge_mcp_config(context)
# Every conflict must have all required fields
for conflict in result.conflicts:
assert isinstance(conflict, MergeConflict)
# Context name
assert conflict.context == context
# Key name (non-empty)
assert conflict.key, "Conflict key must not be empty"
# Shared value (must be present, can be any value)
assert conflict.shared_value is not None or conflict.shared_value == ""
# Context value (must be present)
assert conflict.context_value is not None or conflict.context_value == ""
# Resolution (always context-wins for MCP merge)
assert conflict.resolution == "context-wins"
# Server name (identifies which server)
assert conflict.server_name, "Conflict server_name must not be empty"
# Timestamp
assert conflict.timestamp is not None
@@ -0,0 +1,985 @@
"""Unit-Tests für die MigrationEngine.
Testet Konflikterkennung, Registry-Verwaltung und Migration via
gemockte Git-Operationen.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from monorepo.migration import (
MigrationConflict,
MigrationEngine,
MigrationError,
MigrationRegistryEntry,
MigrationResult,
ValidationResult,
)
from monorepo.models import MigrationPlan
@pytest.fixture
def monorepo_root(tmp_path: Path) -> Path:
"""Erstellt ein temporäres Monorepo-Root ohne git init."""
root = tmp_path / "monorepo"
root.mkdir()
(root / "privat").mkdir()
(root / "dhive").mkdir()
(root / "bahn").mkdir()
(root / "shared" / "config").mkdir(parents=True)
return root
@pytest.fixture
def engine(monorepo_root: Path) -> MigrationEngine:
"""Erstellt eine MigrationEngine-Instanz."""
return MigrationEngine(monorepo_root)
@pytest.fixture
def sample_plan() -> MigrationPlan:
"""Erstellt einen Beispiel-Migrationsplan."""
return MigrationPlan(
source_repo="https://github.com/example/my-project.git",
target_context="dhive",
target_name="my-project",
mode="subtree",
dependencies=[],
order=1,
)
# ---------------------------------------------------------------------------
# Konflikterkennung: Namenskonvention
# ---------------------------------------------------------------------------
class TestNamingConventionConflict:
"""Tests für Namenskonventions-Konflikterkennung."""
def test_valid_kebab_case_no_conflict(self, engine: MigrationEngine) -> None:
"""Gültiger kebab-case Name erzeugt keinen Namenskonflikt."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="valid-name",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
conflicts = engine._detect_conflicts(plan)
naming = [c for c in conflicts if c.conflict_type == "naming_convention"]
assert len(naming) == 0
def test_uppercase_name_detected(self, engine: MigrationEngine) -> None:
"""Großbuchstaben im Namen werden als Verletzung erkannt."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="MyProject",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
conflicts = engine._detect_conflicts(plan)
naming = [c for c in conflicts if c.conflict_type == "naming_convention"]
assert len(naming) == 1
assert "kebab-case" in naming[0].description
def test_underscore_name_detected(self, engine: MigrationEngine) -> None:
"""Unterstriche im Namen werden als Verletzung erkannt."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="my_project",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
conflicts = engine._detect_conflicts(plan)
naming = [c for c in conflicts if c.conflict_type == "naming_convention"]
assert len(naming) == 1
def test_leading_hyphen_detected(self, engine: MigrationEngine) -> None:
"""Führender Bindestrich wird als Verletzung erkannt."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="-invalid",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
conflicts = engine._detect_conflicts(plan)
naming = [c for c in conflicts if c.conflict_type == "naming_convention"]
assert len(naming) == 1
def test_single_char_name_detected(self, engine: MigrationEngine) -> None:
"""Einbuchstabiger Name ist zu kurz (min. 2 Zeichen)."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="x",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
conflicts = engine._detect_conflicts(plan)
naming = [c for c in conflicts if c.conflict_type == "naming_convention"]
assert len(naming) == 1
def test_empty_name_detected(self, engine: MigrationEngine) -> None:
"""Leerer Name wird als Verletzung erkannt."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
conflicts = engine._detect_conflicts(plan)
naming = [c for c in conflicts if c.conflict_type == "naming_convention"]
assert len(naming) == 1
# ---------------------------------------------------------------------------
# Konflikterkennung: Pfadkollision
# ---------------------------------------------------------------------------
class TestPathCollisionConflict:
"""Tests für Pfadkollisions-Konflikterkennung."""
def test_no_collision_when_path_free(self, engine: MigrationEngine) -> None:
"""Kein Konflikt wenn Zielpfad nicht existiert."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="new-project",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
conflicts = engine._detect_conflicts(plan)
path_conflicts = [c for c in conflicts if c.conflict_type == "path_collision"]
assert len(path_conflicts) == 0
def test_collision_detected_when_path_exists(
self, engine: MigrationEngine, monorepo_root: Path
) -> None:
"""Pfadkollision wird erkannt wenn Zielpfad bereits existiert."""
(monorepo_root / "dhive" / "existing-project").mkdir(parents=True)
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="existing-project",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
conflicts = engine._detect_conflicts(plan)
path_conflicts = [c for c in conflicts if c.conflict_type == "path_collision"]
assert len(path_conflicts) == 1
assert "existiert bereits" in path_conflicts[0].description
# ---------------------------------------------------------------------------
# Konflikterkennung: Branch-Namenskonflikt
# ---------------------------------------------------------------------------
class TestBranchConflict:
"""Tests für Branch-Namens-Konflikterkennung."""
def test_no_branch_conflict_for_main(self, engine: MigrationEngine) -> None:
"""main/master Branches werden nicht als Konflikte gewertet."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="new-project",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=["main", "master"]):
with patch.object(engine, "_get_local_branches", return_value=["main", "master"]):
conflicts = engine._detect_conflicts(plan)
branch = [c for c in conflicts if c.conflict_type == "branch_name_conflict"]
assert len(branch) == 0
def test_branch_conflict_detected(self, engine: MigrationEngine) -> None:
"""Branch-Namenskonflikt wird erkannt bei gleichnamigem Branch."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="new-project",
mode="direct",
)
with patch.object(
engine, "_get_remote_branches", return_value=["main", "feature-x"]
):
with patch.object(
engine, "_get_local_branches", return_value=["main", "feature-x"]
):
conflicts = engine._detect_conflicts(plan)
branch = [c for c in conflicts if c.conflict_type == "branch_name_conflict"]
assert len(branch) == 1
assert "feature-x" in branch[0].description
# ---------------------------------------------------------------------------
# Migration pausiert bei Konflikten
# ---------------------------------------------------------------------------
class TestMigrationPauses:
"""Tests dass Migration bei Konflikten pausiert."""
def test_migration_pauses_on_naming_conflict(self, engine: MigrationEngine) -> None:
"""Migration pausiert und gibt Konflikte zurück bei Namensverstoß."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="Invalid_Name",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
result = engine.migrate(plan)
assert result.success is False
assert len(result.conflicts) >= 1
assert "pausiert" in result.error_message
def test_migration_pauses_on_path_collision(
self, engine: MigrationEngine, monorepo_root: Path
) -> None:
"""Migration pausiert bei Pfadkollision."""
(monorepo_root / "dhive" / "existing-repo").mkdir()
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="existing-repo",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
result = engine.migrate(plan)
assert result.success is False
path_conflicts = [c for c in result.conflicts if c.conflict_type == "path_collision"]
assert len(path_conflicts) == 1
# ---------------------------------------------------------------------------
# Registry-Verwaltung
# ---------------------------------------------------------------------------
class TestMigrationRegistry:
"""Tests für die Migrations-Registry."""
def test_empty_registry_on_init(self, engine: MigrationEngine) -> None:
"""Neue Engine hat leere Registry."""
assert engine.get_registry() == []
def test_registry_updated_on_conflict(self, engine: MigrationEngine) -> None:
"""Registry wird mit 'failed' Status bei Konflikten aktualisiert."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="Invalid_Name",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
engine.migrate(plan)
registry = engine.get_registry()
assert len(registry) == 1
assert registry[0].status == "failed"
def test_registry_persisted_to_yaml(
self, engine: MigrationEngine, monorepo_root: Path
) -> None:
"""Registry wird als YAML-Datei persistiert."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="Invalid_Name",
mode="direct",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
engine.migrate(plan)
assert engine.registry_path.exists()
content = engine.registry_path.read_text(encoding="utf-8")
assert "Invalid_Name" in content
assert "failed" in content
def test_registry_loaded_on_init(self, monorepo_root: Path) -> None:
"""Registry wird beim Initialisieren aus vorhandener Datei geladen."""
registry_path = monorepo_root / "shared" / "config" / "migrations.yaml"
registry_path.write_text(
"version: '1.0'\n"
"migrations:\n"
"- repo_name: old-project\n"
" source_repo: /tmp/old\n"
" target_context: privat\n"
" target_name: old-project\n"
" target_path: privat/old-project\n"
" mode: direct\n"
" status: completed\n"
" migrated_at: '2025-01-01T00:00:00'\n"
" backup_ref: pre-migration/old-project/20250101\n"
" commits_count: 42\n"
" branches: [main, develop]\n"
" tags: [v1.0, v2.0]\n",
encoding="utf-8",
)
engine = MigrationEngine(monorepo_root)
registry = engine.get_registry()
assert len(registry) == 1
assert registry[0].repo_name == "old-project"
assert registry[0].status == "completed"
assert registry[0].commits_count == 42
def test_is_migrated_returns_true_for_completed(
self, monorepo_root: Path
) -> None:
"""is_migrated gibt True für abgeschlossene Migrationen."""
registry_path = monorepo_root / "shared" / "config" / "migrations.yaml"
registry_path.write_text(
"version: '1.0'\n"
"migrations:\n"
"- repo_name: done-project\n"
" source_repo: /tmp/done\n"
" target_context: privat\n"
" target_name: done-project\n"
" target_path: privat/done-project\n"
" mode: direct\n"
" status: completed\n"
" migrated_at: '2025-01-01T00:00:00'\n",
encoding="utf-8",
)
engine = MigrationEngine(monorepo_root)
assert engine.is_migrated("done-project") is True
assert engine.is_migrated("unknown-project") is False
# ---------------------------------------------------------------------------
# Erfolgreiche Migration (mit gemocktem Git)
# ---------------------------------------------------------------------------
class TestSuccessfulMigration:
"""Tests für eine erfolgreiche Migration mit gemockten Git-Operationen."""
def test_successful_migration_returns_result(
self, engine: MigrationEngine
) -> None:
"""Erfolgreiche Migration gibt MigrationResult mit success=True."""
plan = MigrationPlan(
source_repo="/tmp/source-repo",
target_context="dhive",
target_name="new-project",
mode="subtree",
)
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
with patch.object(engine, "_run_git", return_value=mock_result):
with patch.object(engine, "_get_remote_branches", return_value=[]):
with patch.object(engine, "_detect_main_branch", return_value="main"):
with patch.object(
engine, "_get_fetched_branches",
return_value=["main", "develop"],
):
with patch.object(
engine, "_get_fetched_tags",
return_value=["v1.0", "v2.0"],
):
with patch.object(
engine, "_count_commits_in_subtree",
return_value=25,
):
result = engine.migrate(plan)
assert result.success is True
assert result.repo_name == "new-project"
assert result.target_path == "dhive/new-project"
assert result.commits_migrated == 25
assert result.branches_migrated == ["main", "develop"]
assert result.tags_migrated == ["v1.0", "v2.0"]
def test_successful_migration_updates_registry(
self, engine: MigrationEngine
) -> None:
"""Erfolgreiche Migration aktualisiert Registry mit 'completed'."""
plan = MigrationPlan(
source_repo="/tmp/source-repo",
target_context="dhive",
target_name="new-project",
mode="subtree",
)
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
with patch.object(engine, "_run_git", return_value=mock_result):
with patch.object(engine, "_get_remote_branches", return_value=[]):
with patch.object(engine, "_detect_main_branch", return_value="main"):
with patch.object(
engine, "_get_fetched_branches", return_value=["main"]
):
with patch.object(engine, "_get_fetched_tags", return_value=[]):
with patch.object(
engine, "_count_commits_in_subtree", return_value=10
):
engine.migrate(plan)
assert engine.is_migrated("new-project") is True
registry = engine.get_registry()
assert any(
e.repo_name == "new-project" and e.status == "completed"
for e in registry
)
# ---------------------------------------------------------------------------
# Fehlerbehandlung
# ---------------------------------------------------------------------------
class TestMigrationErrors:
"""Tests für Fehlerbehandlung bei der Migration."""
def test_git_error_returns_failed_result(self, engine: MigrationEngine) -> None:
"""Git-Fehler führt zu MigrationResult mit success=False."""
plan = MigrationPlan(
source_repo="/tmp/broken-repo",
target_context="dhive",
target_name="broken-project",
mode="subtree",
)
call_count = {"n": 0}
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
call_count["n"] += 1
# Let the backup tag call succeed, then fail on subtree add
if "subtree" in args:
err = subprocess.CalledProcessError(128, "git")
err.stdout = ""
err.stderr = "fatal: refusing to merge"
raise err
result = MagicMock()
result.stdout = ""
result.stderr = ""
return result
with patch.object(engine, "_run_git", side_effect=mock_run_git):
with patch.object(engine, "_get_remote_branches", return_value=[]):
with patch.object(engine, "_detect_main_branch", return_value="main"):
result = engine.migrate(plan)
assert result.success is False
assert result.error_message != ""
def test_failed_migration_in_registry(self, engine: MigrationEngine) -> None:
"""Fehlgeschlagene Migration wird mit 'failed' in Registry gespeichert."""
plan = MigrationPlan(
source_repo="/tmp/broken-repo",
target_context="dhive",
target_name="broken-project",
mode="subtree",
)
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
if "subtree" in args:
err = subprocess.CalledProcessError(1, "git")
err.stdout = ""
err.stderr = "fatal: refusing to merge"
raise err
result = MagicMock()
result.stdout = ""
result.stderr = ""
return result
with patch.object(engine, "_run_git", side_effect=mock_run_git):
with patch.object(engine, "_get_remote_branches", return_value=[]):
with patch.object(engine, "_detect_main_branch", return_value="main"):
engine.migrate(plan)
registry = engine.get_registry()
failed = [e for e in registry if e.status == "failed"]
assert len(failed) >= 1
# ---------------------------------------------------------------------------
# Inkrementelle Migration
# ---------------------------------------------------------------------------
class TestIncrementalMigration:
"""Tests für inkrementelle Migration."""
def _run_successful_migration(
self, engine: MigrationEngine, plan: MigrationPlan
) -> MigrationResult:
"""Hilfsmethode: führt eine erfolgreiche Migration mit Mocks durch."""
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
with patch.object(engine, "_run_git", return_value=mock_result):
with patch.object(engine, "_get_remote_branches", return_value=[]):
with patch.object(engine, "_detect_main_branch", return_value="main"):
with patch.object(
engine, "_get_fetched_branches", return_value=["main"]
):
with patch.object(engine, "_get_fetched_tags", return_value=[]):
with patch.object(
engine, "_count_commits_in_subtree", return_value=5
):
return engine.migrate(plan)
def test_independent_migrations_dont_interfere(
self, engine: MigrationEngine
) -> None:
"""Zwei unabhängige Migrationen beeinflussen sich nicht."""
plan_a = MigrationPlan(
source_repo="/tmp/repo-a",
target_context="dhive",
target_name="repo-a",
mode="subtree",
)
plan_b = MigrationPlan(
source_repo="/tmp/repo-b",
target_context="bahn",
target_name="repo-b",
mode="subtree",
)
result_a = self._run_successful_migration(engine, plan_a)
result_b = self._run_successful_migration(engine, plan_b)
assert result_a.success is True
assert result_b.success is True
assert engine.is_migrated("repo-a") is True
assert engine.is_migrated("repo-b") is True
def test_failed_migration_doesnt_affect_previous(
self, engine: MigrationEngine
) -> None:
"""Eine fehlgeschlagene Migration beeinträchtigt vorherige nicht."""
plan_a = MigrationPlan(
source_repo="/tmp/repo-a",
target_context="dhive",
target_name="repo-a",
mode="subtree",
)
self._run_successful_migration(engine, plan_a)
# Zweite Migration schlägt fehl (Namenskonvention)
plan_b = MigrationPlan(
source_repo="/tmp/repo-b",
target_context="bahn",
target_name="Invalid_B",
mode="subtree",
)
with patch.object(engine, "_get_remote_branches", return_value=[]):
engine.migrate(plan_b)
# Erste Migration ist weiterhin erfolgreich
assert engine.is_migrated("repo-a") is True
registry = engine.get_registry()
repo_a_entry = next(e for e in registry if e.repo_name == "repo-a")
assert repo_a_entry.status == "completed"
# ---------------------------------------------------------------------------
# Backup-Referenz
# ---------------------------------------------------------------------------
class TestBackupRef:
"""Tests für Backup-Referenz-Erstellung."""
def test_backup_tag_created_before_migration(
self, engine: MigrationEngine
) -> None:
"""Ein Backup-Tag wird vor der Migration erstellt."""
plan = MigrationPlan(
source_repo="/tmp/source",
target_context="dhive",
target_name="backup-test",
mode="subtree",
)
git_calls: list[list[str]] = []
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
def tracking_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
git_calls.append(args)
return mock_result
with patch.object(engine, "_run_git", side_effect=tracking_run_git):
with patch.object(engine, "_get_remote_branches", return_value=[]):
with patch.object(engine, "_detect_main_branch", return_value="main"):
with patch.object(
engine, "_get_fetched_branches", return_value=["main"]
):
with patch.object(engine, "_get_fetched_tags", return_value=[]):
with patch.object(
engine, "_count_commits_in_subtree", return_value=1
):
engine.migrate(plan)
# Find the tag call
tag_calls = [c for c in git_calls if c[0] == "tag" and "pre-migration" in c[1]]
assert len(tag_calls) == 1
assert "backup-test" in tag_calls[0][1]
# ---------------------------------------------------------------------------
# Validierung (validate)
# ---------------------------------------------------------------------------
class TestValidation:
"""Tests für die Migrations-Validierung."""
def _setup_registry_with_completed_migration(
self, monorepo_root: Path, *, create_files: bool = True
) -> MigrationEngine:
"""Erstellt Engine mit einem abgeschlossenen Migrations-Eintrag."""
registry_path = monorepo_root / "shared" / "config" / "migrations.yaml"
registry_path.write_text(
"version: '1.0'\n"
"migrations:\n"
"- repo_name: my-project\n"
" source_repo: /tmp/source\n"
" target_context: dhive\n"
" target_name: my-project\n"
" target_path: dhive/my-project\n"
" mode: subtree\n"
" status: completed\n"
" migrated_at: '2025-01-01T00:00:00'\n"
" backup_ref: pre-migration/my-project/20250101-120000\n"
" commits_count: 10\n"
" branches: [main, develop]\n"
" tags: [v1.0, v2.0]\n",
encoding="utf-8",
)
if create_files:
project_dir = monorepo_root / "dhive" / "my-project"
project_dir.mkdir(parents=True, exist_ok=True)
(project_dir / "README.md").write_text("# My Project\n")
return MigrationEngine(monorepo_root)
def test_validate_unknown_repo(self, monorepo_root: Path) -> None:
"""Validierung eines unbekannten Repos gibt Fehler zurück."""
engine = MigrationEngine(monorepo_root)
result = engine.validate("unknown-repo")
assert result.success is False
assert "nicht in der Migrations-Registry" in result.error_message
def test_validate_successful_all_checks_pass(self, monorepo_root: Path) -> None:
"""Validierung besteht wenn alle Checks erfolgreich sind."""
engine = self._setup_registry_with_completed_migration(monorepo_root)
mock_git_result = MagicMock()
mock_git_result.stdout = "\n".join([f"commit{i}" for i in range(10)])
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
result = MagicMock()
if args[0] == "log":
result.stdout = "\n".join([f"abc{i} Commit msg {i}" for i in range(10)])
elif args[0] == "branch":
result.stdout = "main\ndevelop\n"
elif args[0] == "tag":
result.stdout = "v1.0\nv2.0\npre-migration/my-project/20250101-120000\n"
else:
result.stdout = ""
result.stderr = ""
return result
with patch.object(engine, "_run_git", side_effect=mock_run_git):
result = engine.validate("my-project")
assert result.success is True
assert result.checks["commits"] is True
assert result.checks["branches"] is True
assert result.checks["tags"] is True
assert result.checks["files"] is True
assert result.checks["tests"] is True
assert len(result.details) == 5
def test_validate_fails_when_commits_missing(self, monorepo_root: Path) -> None:
"""Validierung schlägt fehl wenn zu wenige Commits vorhanden."""
engine = self._setup_registry_with_completed_migration(monorepo_root)
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
result = MagicMock()
if args[0] == "log":
# Nur 3 Commits statt erwartet 10
result.stdout = "abc1 msg\nabc2 msg\nabc3 msg\n"
elif args[0] == "branch":
result.stdout = "main\ndevelop\n"
elif args[0] == "tag":
result.stdout = "v1.0\nv2.0\n"
else:
result.stdout = ""
result.stderr = ""
return result
with patch.object(engine, "_run_git", side_effect=mock_run_git):
result = engine.validate("my-project")
assert result.success is False
assert result.checks["commits"] is False
def test_validate_fails_when_tags_missing(self, monorepo_root: Path) -> None:
"""Validierung schlägt fehl wenn Tags fehlen."""
engine = self._setup_registry_with_completed_migration(monorepo_root)
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
result = MagicMock()
if args[0] == "log":
result.stdout = "\n".join([f"abc{i} msg" for i in range(10)])
elif args[0] == "branch":
result.stdout = "main\ndevelop\n"
elif args[0] == "tag":
# v2.0 fehlt
result.stdout = "v1.0\n"
else:
result.stdout = ""
result.stderr = ""
return result
with patch.object(engine, "_run_git", side_effect=mock_run_git):
result = engine.validate("my-project")
assert result.success is False
assert result.checks["tags"] is False
def test_validate_fails_when_directory_missing(self, monorepo_root: Path) -> None:
"""Validierung schlägt fehl wenn das Zielverzeichnis nicht existiert."""
engine = self._setup_registry_with_completed_migration(
monorepo_root, create_files=False
)
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
result = MagicMock()
if args[0] == "log":
result.stdout = "\n".join([f"abc{i} msg" for i in range(10)])
elif args[0] == "branch":
result.stdout = "main\ndevelop\n"
elif args[0] == "tag":
result.stdout = "v1.0\nv2.0\n"
else:
result.stdout = ""
result.stderr = ""
return result
with patch.object(engine, "_run_git", side_effect=mock_run_git):
result = engine.validate("my-project")
assert result.success is False
assert result.checks["files"] is False
def test_validate_no_branches_registered_passes(self, monorepo_root: Path) -> None:
"""Validierung besteht wenn keine Branches in der Registry registriert sind."""
registry_path = monorepo_root / "shared" / "config" / "migrations.yaml"
registry_path.write_text(
"version: '1.0'\n"
"migrations:\n"
"- repo_name: simple-project\n"
" source_repo: /tmp/source\n"
" target_context: privat\n"
" target_name: simple-project\n"
" target_path: privat/simple-project\n"
" mode: direct\n"
" status: completed\n"
" migrated_at: '2025-01-01T00:00:00'\n"
" commits_count: 5\n"
" branches: []\n"
" tags: []\n",
encoding="utf-8",
)
project_dir = monorepo_root / "privat" / "simple-project"
project_dir.mkdir(parents=True)
(project_dir / "main.py").write_text("print('hello')\n")
engine = MigrationEngine(monorepo_root)
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
result = MagicMock()
if args[0] == "log":
result.stdout = "\n".join([f"abc{i} msg" for i in range(5)])
else:
result.stdout = ""
result.stderr = ""
return result
with patch.object(engine, "_run_git", side_effect=mock_run_git):
result = engine.validate("simple-project")
assert result.success is True
assert result.checks["branches"] is True
assert result.checks["tags"] is True
# ---------------------------------------------------------------------------
# Rollback
# ---------------------------------------------------------------------------
class TestRollback:
"""Tests für den Migrations-Rollback."""
def _setup_engine_with_migration(
self, monorepo_root: Path, *, status: str = "completed"
) -> MigrationEngine:
"""Erstellt Engine mit einer Migration im gegebenen Status."""
registry_path = monorepo_root / "shared" / "config" / "migrations.yaml"
registry_path.write_text(
"version: '1.0'\n"
"migrations:\n"
f"- repo_name: rollback-project\n"
f" source_repo: /tmp/source\n"
f" target_context: dhive\n"
f" target_name: rollback-project\n"
f" target_path: dhive/rollback-project\n"
f" mode: subtree\n"
f" status: {status}\n"
f" migrated_at: '2025-01-01T00:00:00'\n"
f" backup_ref: pre-migration/rollback-project/20250101-120000\n"
f" commits_count: 15\n"
f" branches: [main]\n"
f" tags: [v1.0]\n",
encoding="utf-8",
)
# Erstelle das migrierte Verzeichnis
project_dir = monorepo_root / "dhive" / "rollback-project"
project_dir.mkdir(parents=True, exist_ok=True)
(project_dir / "README.md").write_text("# Rollback Project\n")
return MigrationEngine(monorepo_root)
def test_rollback_unknown_repo_raises_error(self, monorepo_root: Path) -> None:
"""Rollback eines unbekannten Repos wirft MigrationError."""
engine = MigrationEngine(monorepo_root)
with pytest.raises(MigrationError, match="nicht in der Migrations-Registry"):
engine.rollback("unknown-repo")
def test_rollback_already_rolled_back_raises_error(
self, monorepo_root: Path
) -> None:
"""Rollback eines bereits zurückgerollten Repos wirft MigrationError."""
engine = self._setup_engine_with_migration(monorepo_root, status="rolled_back")
with pytest.raises(MigrationError, match="bereits zurückgerollt"):
engine.rollback("rollback-project")
def test_rollback_removes_directory_and_updates_registry(
self, monorepo_root: Path
) -> None:
"""Rollback entfernt das Verzeichnis und setzt Registry auf 'rolled_back'."""
engine = self._setup_engine_with_migration(monorepo_root)
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
with patch.object(engine, "_run_git", return_value=mock_result):
engine.rollback("rollback-project")
registry = engine.get_registry()
entry = next(e for e in registry if e.repo_name == "rollback-project")
assert entry.status == "rolled_back"
def test_rollback_calls_git_rm_and_commit(self, monorepo_root: Path) -> None:
"""Rollback ruft git rm -rf und git commit auf."""
engine = self._setup_engine_with_migration(monorepo_root)
git_calls: list[list[str]] = []
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
def tracking_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
git_calls.append(args)
return mock_result
with patch.object(engine, "_run_git", side_effect=tracking_run_git):
engine.rollback("rollback-project")
# Prüfe git rm -rf
rm_calls = [c for c in git_calls if c[0] == "rm"]
assert len(rm_calls) == 1
assert "-rf" in rm_calls[0]
assert "dhive/rollback-project" in rm_calls[0]
# Prüfe git commit
commit_calls = [c for c in git_calls if c[0] == "commit"]
assert len(commit_calls) == 1
assert "Rollback" in commit_calls[0][2]
def test_rollback_does_not_affect_other_repos(self, monorepo_root: Path) -> None:
"""Rollback eines Repos beeinflusst andere migrierte Repos nicht."""
registry_path = monorepo_root / "shared" / "config" / "migrations.yaml"
registry_path.write_text(
"version: '1.0'\n"
"migrations:\n"
"- repo_name: rollback-project\n"
" source_repo: /tmp/source-a\n"
" target_context: dhive\n"
" target_name: rollback-project\n"
" target_path: dhive/rollback-project\n"
" mode: subtree\n"
" status: completed\n"
" migrated_at: '2025-01-01T00:00:00'\n"
" backup_ref: pre-migration/rollback-project/20250101-120000\n"
" commits_count: 10\n"
" branches: [main]\n"
" tags: []\n"
"- repo_name: other-project\n"
" source_repo: /tmp/source-b\n"
" target_context: bahn\n"
" target_name: other-project\n"
" target_path: bahn/other-project\n"
" mode: subtree\n"
" status: completed\n"
" migrated_at: '2025-01-01T00:00:00'\n"
" backup_ref: pre-migration/other-project/20250101-120000\n"
" commits_count: 20\n"
" branches: [main]\n"
" tags: [v2.0]\n",
encoding="utf-8",
)
# Erstelle Verzeichnisse
(monorepo_root / "dhive" / "rollback-project").mkdir(parents=True)
(monorepo_root / "dhive" / "rollback-project" / "file.txt").write_text("x")
(monorepo_root / "bahn" / "other-project").mkdir(parents=True)
(monorepo_root / "bahn" / "other-project" / "file.txt").write_text("y")
engine = MigrationEngine(monorepo_root)
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
with patch.object(engine, "_run_git", return_value=mock_result):
engine.rollback("rollback-project")
# other-project bleibt unverändert
registry = engine.get_registry()
other = next(e for e in registry if e.repo_name == "other-project")
assert other.status == "completed"
def test_rollback_git_rm_failure_raises_error(self, monorepo_root: Path) -> None:
"""Wenn git rm fehlschlägt, wird MigrationError geworfen."""
engine = self._setup_engine_with_migration(monorepo_root)
def failing_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
if args[0] == "rm":
err = subprocess.CalledProcessError(1, "git")
err.stdout = ""
err.stderr = "fatal: pathspec not found"
raise err
result = MagicMock()
result.stdout = ""
result.stderr = ""
return result
with patch.object(engine, "_run_git", side_effect=failing_git):
with pytest.raises(MigrationError, match="Rollback fehlgeschlagen"):
engine.rollback("rollback-project")
@@ -0,0 +1,278 @@
"""Property-Based Tests für Migrations-Rollback (Property 18).
**Validates: Requirements 6.7**
Property 18: Migrations-Rollback stellt Vor-Zustand wieder her
- Für jedes migrierte Repository, wenn ein Rollback durchgeführt wird, muss:
1. Der Status in der Registry auf "rolled_back" gesetzt werden
2. Das Zielverzeichnis entfernt werden (oder der Subtree bereinigt)
3. Bereits erfolgreich migrierte andere Repositories dürfen NICHT beeinflusst werden
4. Die Migration desselben Repos danach erneut möglich sein
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.migration import MigrationEngine, MigrationRegistryEntry
from monorepo.models import MigrationPlan
# ---------------------------------------------------------------------------
# Strategien: Generieren gültiger Migrationspläne
# ---------------------------------------------------------------------------
# Gültige kebab-case Namen: 2-50 Zeichen, [a-z0-9], Bindestriche innen
_VALID_CHARS = st.sampled_from(
list("abcdefghijklmnopqrstuvwxyz0123456789")
)
_VALID_INNER_CHARS = st.sampled_from(
list("abcdefghijklmnopqrstuvwxyz0123456789-")
)
_valid_name_strategy = st.builds(
lambda first, middle, last: first + middle + last,
first=_VALID_CHARS,
middle=st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789-", min_size=0, max_size=10)
.filter(lambda s: "--" not in s),
last=_VALID_CHARS,
).filter(lambda name: 2 <= len(name) <= 50 and "--" not in name)
_context_strategy = st.sampled_from(["privat", "dhive", "bahn"])
_migration_plan_strategy = st.builds(
lambda name, context: MigrationPlan(
source_repo=f"/tmp/source-{name}",
target_context=context,
target_name=name,
mode="subtree",
dependencies=[],
order=0,
),
name=_valid_name_strategy,
context=_context_strategy,
)
# Strategie für eine Liste von 2-5 unabhängigen Migrationsplänen
_multi_plan_strategy = st.lists(
_migration_plan_strategy,
min_size=2,
max_size=5,
unique_by=lambda p: p.target_name,
)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _create_engine_with_monorepo(tmp_path: Path) -> tuple[MigrationEngine, Path]:
"""Erstellt eine MigrationEngine mit temporärem Monorepo-Root."""
root = tmp_path / "monorepo"
root.mkdir(exist_ok=True)
for ctx in ("privat", "dhive", "bahn", "shared"):
(root / ctx).mkdir(exist_ok=True)
(root / "shared" / "config").mkdir(parents=True, exist_ok=True)
return MigrationEngine(root), root
def _simulate_successful_migration(
engine: MigrationEngine, plan: MigrationPlan, monorepo_root: Path
) -> None:
"""Simuliert eine erfolgreiche Migration mit gemockten Git-Operationen.
Erstellt das Zielverzeichnis und aktualisiert die Registry.
"""
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
with patch.object(engine, "_run_git", return_value=mock_result):
with patch.object(engine, "_get_remote_branches", return_value=[]):
with patch.object(engine, "_detect_main_branch", return_value="main"):
with patch.object(
engine, "_get_fetched_branches", return_value=["main"]
):
with patch.object(engine, "_get_fetched_tags", return_value=[]):
with patch.object(
engine, "_count_commits_in_subtree", return_value=10
):
result = engine.migrate(plan)
assert result.success is True, (
f"Migration von '{plan.target_name}' hätte erfolgreich sein sollen, "
f"Fehler: {result.error_message}"
)
# Sicherstellen, dass das Zielverzeichnis existiert (wie nach echter Migration)
target_dir = monorepo_root / plan.target_context / plan.target_name
target_dir.mkdir(parents=True, exist_ok=True)
# Simulierte Dateien im Zielverzeichnis
(target_dir / "README.md").write_text(f"# {plan.target_name}", encoding="utf-8")
# ---------------------------------------------------------------------------
# Property Tests
# ---------------------------------------------------------------------------
class TestMigrationRollbackProperty:
"""Property 18: Migrations-Rollback stellt Vor-Zustand wieder her.
**Validates: Requirements 6.7**
"""
@settings(max_examples=100)
@given(plan=_migration_plan_strategy)
def test_rollback_sets_status_to_rolled_back(
self, plan: MigrationPlan, tmp_path_factory
) -> None:
"""Nach Rollback muss der Status in der Registry auf 'rolled_back' stehen.
**Validates: Requirements 6.7**
"""
tmp_path = tmp_path_factory.mktemp("rollback_status")
engine, root = _create_engine_with_monorepo(tmp_path)
# Migration durchführen
_simulate_successful_migration(engine, plan, root)
assert engine.is_migrated(plan.target_name) is True
# Rollback durchführen
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
with patch.object(engine, "_run_git", return_value=mock_result):
engine.rollback(plan.target_name)
# Status muss "rolled_back" sein
registry = engine.get_registry()
entry = next(
(e for e in registry if e.repo_name == plan.target_name), None
)
assert entry is not None, (
f"Registry-Eintrag für '{plan.target_name}' muss nach Rollback existieren"
)
assert entry.status == "rolled_back", (
f"Status muss 'rolled_back' sein, ist aber '{entry.status}'"
)
@settings(max_examples=100)
@given(plan=_migration_plan_strategy)
def test_rollback_removes_target_directory(
self, plan: MigrationPlan, tmp_path_factory
) -> None:
"""Nach Rollback muss das Zielverzeichnis entfernt sein.
**Validates: Requirements 6.7**
"""
tmp_path = tmp_path_factory.mktemp("rollback_dir")
engine, root = _create_engine_with_monorepo(tmp_path)
# Migration durchführen
_simulate_successful_migration(engine, plan, root)
target_dir = root / plan.target_context / plan.target_name
assert target_dir.exists(), "Zielverzeichnis muss nach Migration existieren"
# Rollback durchführen
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
with patch.object(engine, "_run_git", return_value=mock_result):
engine.rollback(plan.target_name)
# Zielverzeichnis muss entfernt sein
assert not target_dir.exists(), (
f"Zielverzeichnis '{target_dir}' muss nach Rollback entfernt sein"
)
@settings(max_examples=50)
@given(plans=_multi_plan_strategy)
def test_rollback_does_not_affect_other_repos(
self, plans: list[MigrationPlan], tmp_path_factory
) -> None:
"""Rollback eines Repos darf andere erfolgreich migrierte Repos NICHT beeinflussen.
**Validates: Requirements 6.7**
"""
assume(len(plans) >= 2)
# Sicherstellen, dass alle target_names unterschiedlich sind
names = [p.target_name for p in plans]
assume(len(set(names)) == len(names))
tmp_path = tmp_path_factory.mktemp("rollback_isolation")
engine, root = _create_engine_with_monorepo(tmp_path)
# Alle Repos migrieren
for plan in plans:
_simulate_successful_migration(engine, plan, root)
# Rollback nur für das erste Repo
rollback_target = plans[0]
other_plans = plans[1:]
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
with patch.object(engine, "_run_git", return_value=mock_result):
engine.rollback(rollback_target.target_name)
# Prüfe: Andere Repos bleiben "completed"
registry = engine.get_registry()
for other_plan in other_plans:
entry = next(
(e for e in registry if e.repo_name == other_plan.target_name), None
)
assert entry is not None, (
f"Registry-Eintrag für '{other_plan.target_name}' darf nicht fehlen"
)
assert entry.status == "completed", (
f"Status von '{other_plan.target_name}' muss 'completed' bleiben, "
f"ist aber '{entry.status}' nach Rollback von '{rollback_target.target_name}'"
)
# Prüfe: Zielverzeichnisse der anderen Repos existieren noch
for other_plan in other_plans:
other_dir = root / other_plan.target_context / other_plan.target_name
assert other_dir.exists(), (
f"Verzeichnis '{other_dir}' muss nach Rollback eines anderen Repos erhalten bleiben"
)
@settings(max_examples=50)
@given(plan=_migration_plan_strategy)
def test_remigration_after_rollback_succeeds(
self, plan: MigrationPlan, tmp_path_factory
) -> None:
"""Nach Rollback muss eine erneute Migration desselben Repos möglich sein.
**Validates: Requirements 6.7**
"""
tmp_path = tmp_path_factory.mktemp("rollback_remigrate")
engine, root = _create_engine_with_monorepo(tmp_path)
# Erste Migration
_simulate_successful_migration(engine, plan, root)
assert engine.is_migrated(plan.target_name) is True
# Rollback
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
with patch.object(engine, "_run_git", return_value=mock_result):
engine.rollback(plan.target_name)
# Nach Rollback: is_migrated muss False sein
assert engine.is_migrated(plan.target_name) is False, (
f"'{plan.target_name}' darf nach Rollback nicht als migriert gelten"
)
# Erneute Migration muss erfolgreich sein
_simulate_successful_migration(engine, plan, root)
assert engine.is_migrated(plan.target_name) is True, (
f"Erneute Migration von '{plan.target_name}' nach Rollback muss erfolgreich sein"
)
@@ -0,0 +1,931 @@
"""Unit-Tests für den OrchestratorAdapter.
Testet die Kernfunktionalität:
- Kontextauflösung aus Task-Metadaten (Labels/Projekt) (Req 7.1, 7.6)
- Workspace-Erstellung im korrekten Kontextordner (Req 7.2)
- Env-Isolation (nur eigene .env, via Encryption entschlüsselt) (Req 7.3, 7.4)
- Wissensinjektion in Prompt (Req 7.4)
- Task-Abbruch bei Out-of-Context-Zugriff (Req 7.5)
- Multi-Harness-Dispatch (Konfiguration, Startsequenz, Fehlerbehandlung) (Req 7.7, 7.9, 7.10)
Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.9, 7.10
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import yaml
from monorepo.audit import AuditLogger
from monorepo.encryption import SecretEncryptionManager
from monorepo.models import MachineContext
from monorepo.orchestrator import (
ALL_CONTEXTS,
ContextResolutionError,
ContextViolationError,
OrchestratorAdapter,
Task,
)
from monorepo.security import ContextGuard
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def monorepo_root(tmp_path: Path) -> Path:
"""Erstellt eine minimale Monorepo-Verzeichnisstruktur."""
root = tmp_path / "monorepo"
root.mkdir()
# Kontextordner
for ctx in ["privat", "dhive", "bahn", "shared"]:
(root / ctx).mkdir()
# Shared-Config-Verzeichnis
config_dir = root / "shared" / "config"
config_dir.mkdir(parents=True)
# Access-Config erstellen
access_config = {
"contexts": {
"privat": {
"env_file": "privat/.env",
"allowed_shared": ["shared/tools/", "shared/config/"],
},
"dhive": {
"env_file": "dhive/.env",
"allowed_shared": ["shared/tools/", "shared/config/"],
},
"bahn": {
"env_file": "bahn/.env",
"allowed_shared": ["shared/tools/", "shared/config/"],
},
"shared": {
"env_file": "shared/.env",
"allowed_shared": ["*"],
},
}
}
with open(config_dir / "access-config.yaml", "w", encoding="utf-8") as f:
yaml.dump(access_config, f)
# .env-Dateien erstellen
for ctx in ["privat", "dhive", "bahn"]:
env_path = root / ctx / ".env"
env_path.write_text(
f"{ctx.upper()}_SECRET=geheim_{ctx}\n", encoding="utf-8"
)
return root
@pytest.fixture
def context_guard(monorepo_root: Path) -> ContextGuard:
"""Erstellt einen ContextGuard mit der Test-Konfiguration."""
return ContextGuard(monorepo_root)
@pytest.fixture
def audit_logger(monorepo_root: Path) -> AuditLogger:
"""Erstellt einen AuditLogger für Tests."""
return AuditLogger(monorepo_root / ".audit" / "test-access.log")
@pytest.fixture
def adapter(
monorepo_root: Path, context_guard: ContextGuard, audit_logger: AuditLogger
) -> OrchestratorAdapter:
"""Erstellt einen OrchestratorAdapter für Tests."""
return OrchestratorAdapter(
root_path=monorepo_root,
context_guard=context_guard,
audit_logger=audit_logger,
)
@pytest.fixture
def knowledge_index(monorepo_root: Path) -> Path:
"""Erstellt einen Test-Wissens-Index."""
knowledge_dir = monorepo_root / "shared" / "knowledge-store"
knowledge_dir.mkdir(parents=True, exist_ok=True)
index_data = {
"version": "1.0",
"last_updated": "2025-01-15T10:30:00Z",
"artifacts": [
{
"id": "bahn/decisions/api-design",
"title": "API-Design-Entscheidung",
"type": "decision",
"tags": ["api", "architektur"],
"scope": "bahn",
"summary": "REST vs GraphQL Entscheidung",
"path": "bahn/decisions/api-design.md",
"content_hash": "sha256:abc123",
"links": [],
},
{
"id": "shared/patterns/error-handling",
"title": "Error-Handling-Pattern",
"type": "pattern",
"tags": ["pattern", "errors"],
"scope": "shared",
"summary": "Einheitliches Error-Handling",
"path": "shared/patterns/error-handling.md",
"content_hash": "sha256:def456",
"links": [],
},
{
"id": "privat/notes/project-ideas",
"title": "Projektideen",
"type": "note",
"tags": ["ideen"],
"scope": "privat",
"summary": "Private Projektideen",
"path": "privat/notes/project-ideas.md",
"content_hash": "sha256:ghi789",
"links": [],
},
],
}
index_path = knowledge_dir / "_index.yaml"
with open(index_path, "w", encoding="utf-8") as f:
yaml.dump(index_data, f, allow_unicode=True)
return index_path
@pytest.fixture
def machine_context_full() -> MachineContext:
"""Maschinenkontext mit vollem Zugriff auf alle Kontexte."""
return MachineContext(
name="andre-hauptrechner",
description="Hauptrechner mit vollem Zugriff",
authorized_contexts=["privat", "dhive", "bahn"],
key_source="keyring",
)
@pytest.fixture
def machine_context_dhive_only() -> MachineContext:
"""Maschinenkontext nur für dhive autorisiert."""
return MachineContext(
name="dhive-laptop",
description="dhive-Arbeitsrechner",
authorized_contexts=["dhive"],
key_source="keyring",
)
@pytest.fixture
def encryption_manager(
monorepo_root: Path, machine_context_full: MachineContext
) -> SecretEncryptionManager:
"""SecretEncryptionManager mit vollem Zugriff."""
return SecretEncryptionManager(monorepo_root, machine_context_full)
@pytest.fixture
def harness_config(monorepo_root: Path) -> Path:
"""Erstellt eine harness-config.yaml für Multi-Harness-Dispatch Tests."""
config = {
"contexts": {
"dhive": {
"harness": "codex",
"start_sequence": {
"type": "cli",
"command": "codex",
"args": ["--model", "o3-pro"],
},
"config": {"model": "o3-pro", "sandbox": True},
},
"bahn": {
"harness": "kiro",
"start_sequence": {
"type": "api",
"endpoint": "http://localhost:3000/kiro",
},
"config": {"powers_path": "shared/powers"},
},
"privat": {
"harness": "claude-code",
"start_sequence": {
"type": "cli",
"command": "claude",
"args": ["--project", "."],
},
"config": {"model": "sonnet"},
},
}
}
config_path = monorepo_root / "shared" / "config" / "harness-config.yaml"
config_path.write_text(yaml.dump(config), encoding="utf-8")
return config_path
# ---------------------------------------------------------------------------
# Tests: resolve_context (Req 7.1, 7.6)
# ---------------------------------------------------------------------------
class TestResolveContext:
"""Tests für die Kontextauflösung aus Task-Metadaten."""
def test_context_from_label_dhive(self, adapter: OrchestratorAdapter) -> None:
"""Kontext wird korrekt aus Label 'dhive' ermittelt."""
task = Task(id="TASK-1", title="Test", labels=["dhive"])
assert adapter.resolve_context(task) == "dhive"
def test_context_from_label_bahn(self, adapter: OrchestratorAdapter) -> None:
"""Kontext wird korrekt aus Label 'bahn' ermittelt."""
task = Task(id="TASK-2", title="Test", labels=["bahn"])
assert adapter.resolve_context(task) == "bahn"
def test_context_from_label_privat(self, adapter: OrchestratorAdapter) -> None:
"""Kontext wird korrekt aus Label 'privat' ermittelt."""
task = Task(id="TASK-3", title="Test", labels=["privat"])
assert adapter.resolve_context(task) == "privat"
def test_context_from_label_shared(self, adapter: OrchestratorAdapter) -> None:
"""Shared ist ein gültiger Kontext für Tasks."""
task = Task(id="TASK-4", title="Test", labels=["shared"])
assert adapter.resolve_context(task) == "shared"
def test_context_from_label_case_insensitive(
self, adapter: OrchestratorAdapter
) -> None:
"""Labels werden case-insensitiv verarbeitet."""
task = Task(id="TASK-5", title="Test", labels=["Dhive"])
assert adapter.resolve_context(task) == "dhive"
def test_context_from_multiple_labels_first_match(
self, adapter: OrchestratorAdapter
) -> None:
"""Bei mehreren passenden Labels wird das erste verwendet."""
task = Task(id="TASK-6", title="Test", labels=["bug", "dhive", "bahn"])
assert adapter.resolve_context(task) == "dhive"
def test_context_from_project_path(
self, adapter: OrchestratorAdapter
) -> None:
"""Kontext aus Projektzuordnung 'bahn/my-project' ermitteln."""
task = Task(id="TASK-7", title="Test", project="bahn/api-service")
assert adapter.resolve_context(task) == "bahn"
def test_context_from_project_name_only(
self, adapter: OrchestratorAdapter
) -> None:
"""Kontext aus einfachem Projektnamen, wenn es ein Kontextname ist."""
task = Task(id="TASK-8", title="Test", project="privat")
assert adapter.resolve_context(task) == "privat"
def test_label_takes_priority_over_project(
self, adapter: OrchestratorAdapter
) -> None:
"""Labels haben Vorrang vor Projektzuordnung."""
task = Task(id="TASK-9", title="Test", labels=["bahn"], project="dhive/other")
assert adapter.resolve_context(task) == "bahn"
def test_no_context_raises_error(self, adapter: OrchestratorAdapter) -> None:
"""ContextResolutionError wenn kein Kontext ermittelbar (Req 7.6)."""
task = Task(id="TASK-10", title="Test", labels=["bug", "feature"])
with pytest.raises(ContextResolutionError) as exc_info:
adapter.resolve_context(task)
assert "TASK-10" in str(exc_info.value)
assert "Kein Arbeitskontext" in exc_info.value.message
def test_empty_labels_and_no_project_raises_error(
self, adapter: OrchestratorAdapter
) -> None:
"""ContextResolutionError bei komplett leeren Metadaten."""
task = Task(id="TASK-11", title="Test")
with pytest.raises(ContextResolutionError) as exc_info:
adapter.resolve_context(task)
assert exc_info.value.task_id == "TASK-11"
def test_unknown_project_prefix_raises_error(
self, adapter: OrchestratorAdapter
) -> None:
"""ContextResolutionError wenn Projektpräfix kein bekannter Kontext."""
task = Task(id="TASK-12", title="Test", project="unknown/something")
with pytest.raises(ContextResolutionError):
adapter.resolve_context(task)
# ---------------------------------------------------------------------------
# Tests: create_workspace (Req 7.2)
# ---------------------------------------------------------------------------
class TestCreateWorkspace:
"""Tests für die Workspace-Erstellung."""
def test_creates_workspace_under_context(
self, adapter: OrchestratorAdapter
) -> None:
"""Workspace wird unter dem korrekten Kontextordner erstellt (Req 7.2)."""
task = Task(id="TASK-1", title="Test")
path = adapter.create_workspace(task, "dhive")
assert path.exists()
assert "dhive" in str(path)
assert ".workspaces" in str(path)
assert "TASK-1" in str(path)
def test_workspace_path_contains_task_id(
self, adapter: OrchestratorAdapter
) -> None:
"""Workspace-Pfad enthält die Task-ID."""
task = Task(id="my-task-42", title="Test")
path = adapter.create_workspace(task, "bahn")
assert "my-task-42" in str(path)
def test_workspace_under_correct_context_root(
self, adapter: OrchestratorAdapter, monorepo_root: Path
) -> None:
"""Workspace liegt innerhalb des Kontextordners (Req 7.2)."""
task = Task(id="TASK-X", title="Test")
path = adapter.create_workspace(task, "privat")
assert str(path).startswith(str(monorepo_root / "privat"))
def test_invalid_context_raises_error(
self, adapter: OrchestratorAdapter
) -> None:
"""Ungültiger Kontext führt zu ContextResolutionError."""
task = Task(id="TASK-X", title="Test")
with pytest.raises(ContextResolutionError):
adapter.create_workspace(task, "invalid_context")
def test_workspace_idempotent(self, adapter: OrchestratorAdapter) -> None:
"""Mehrfaches Erstellen des gleichen Workspace ist sicher."""
task = Task(id="TASK-SAME", title="Test")
path1 = adapter.create_workspace(task, "dhive")
path2 = adapter.create_workspace(task, "dhive")
assert path1 == path2
assert path1.exists()
def test_sanitizes_task_id_for_dirname(
self, adapter: OrchestratorAdapter
) -> None:
"""Sonderzeichen in der Task-ID werden für Verzeichnisnamen bereinigt."""
task = Task(id="TASK/with:special chars!", title="Test")
path = adapter.create_workspace(task, "bahn")
assert path.exists()
dir_name = path.name
assert "/" not in dir_name
assert ":" not in dir_name
assert "!" not in dir_name
# ---------------------------------------------------------------------------
# Tests: build_prompt (Req 7.1, 7.3, 7.4)
# ---------------------------------------------------------------------------
class TestBuildPrompt:
"""Tests für den Prompt-Aufbau."""
def test_prompt_contains_task_title(
self, adapter: OrchestratorAdapter
) -> None:
"""Prompt enthält den Task-Titel."""
task = Task(id="TASK-1", title="API implementieren")
prompt = adapter.build_prompt(task, "bahn")
assert "API implementieren" in prompt
def test_prompt_contains_context_info(
self, adapter: OrchestratorAdapter
) -> None:
"""Prompt enthält Kontextinformationen."""
task = Task(id="TASK-1", title="Test")
prompt = adapter.build_prompt(task, "dhive")
assert "dhive" in prompt
assert "Kontext" in prompt
def test_prompt_contains_security_constraints(
self, adapter: OrchestratorAdapter
) -> None:
"""Prompt enthält Sicherheitsbeschränkungen (Req 7.1, 7.3)."""
task = Task(id="TASK-1", title="Test")
prompt = adapter.build_prompt(task, "bahn")
assert "Sicherheitsbeschränkungen" in prompt
assert "VERBOTEN" in prompt or "nicht erlaubt" in prompt.lower()
def test_prompt_mentions_env_restriction(
self, adapter: OrchestratorAdapter
) -> None:
"""Prompt erwähnt die .env-Einschränkung (Req 7.3)."""
task = Task(id="TASK-1", title="Test")
prompt = adapter.build_prompt(task, "bahn")
assert ".env" in prompt
def test_prompt_contains_task_description(
self, adapter: OrchestratorAdapter
) -> None:
"""Prompt enthält die Aufgabenbeschreibung."""
task = Task(
id="TASK-1",
title="Test",
description="Implementiere einen REST-Endpunkt für /users",
)
prompt = adapter.build_prompt(task, "bahn")
assert "REST-Endpunkt" in prompt
assert "/users" in prompt
def test_prompt_without_description(
self, adapter: OrchestratorAdapter
) -> None:
"""Prompt funktioniert auch ohne Beschreibung."""
task = Task(id="TASK-1", title="Test")
prompt = adapter.build_prompt(task, "bahn")
assert "Test" in prompt
def test_prompt_with_knowledge_index(
self,
monorepo_root: Path,
context_guard: ContextGuard,
audit_logger: AuditLogger,
knowledge_index: Path,
) -> None:
"""Prompt enthält Wissensartefakte aus dem Index (Req 7.4)."""
adapter = OrchestratorAdapter(
root_path=monorepo_root,
context_guard=context_guard,
audit_logger=audit_logger,
knowledge_index_path=knowledge_index,
)
task = Task(id="TASK-1", title="Test")
prompt = adapter.build_prompt(task, "bahn")
# Bahn-Artefakt und shared-Artefakt sollen enthalten sein
assert "API-Design-Entscheidung" in prompt
assert "Error-Handling-Pattern" in prompt
# privat-Artefakt soll NICHT enthalten sein
assert "Projektideen" not in prompt
# ---------------------------------------------------------------------------
# Tests: inject_knowledge (Req 7.4)
# ---------------------------------------------------------------------------
class TestInjectKnowledge:
"""Tests für die Wissens-Injection in den Prompt."""
def test_no_index_returns_empty(self, adapter: OrchestratorAdapter) -> None:
"""Leerer String wenn kein Index vorhanden."""
result = adapter.inject_knowledge("bahn")
assert result == ""
def test_filters_by_context_and_shared(
self,
monorepo_root: Path,
context_guard: ContextGuard,
audit_logger: AuditLogger,
knowledge_index: Path,
) -> None:
"""Nur Artefakte des eigenen Kontexts und shared werden geliefert."""
adapter = OrchestratorAdapter(
root_path=monorepo_root,
context_guard=context_guard,
audit_logger=audit_logger,
knowledge_index_path=knowledge_index,
)
result = adapter.inject_knowledge("bahn")
assert "API-Design-Entscheidung" in result
assert "Error-Handling-Pattern" in result
assert "Projektideen" not in result
def test_privat_context_sees_own_and_shared(
self,
monorepo_root: Path,
context_guard: ContextGuard,
audit_logger: AuditLogger,
knowledge_index: Path,
) -> None:
"""Privat-Kontext sieht nur eigene und shared-Artefakte."""
adapter = OrchestratorAdapter(
root_path=monorepo_root,
context_guard=context_guard,
audit_logger=audit_logger,
knowledge_index_path=knowledge_index,
)
result = adapter.inject_knowledge("privat")
assert "Projektideen" in result
assert "Error-Handling-Pattern" in result
# Bahn-Artefakt darf NICHT sichtbar sein
assert "API-Design-Entscheidung" not in result
def test_malformed_index_returns_empty(
self,
monorepo_root: Path,
context_guard: ContextGuard,
audit_logger: AuditLogger,
) -> None:
"""Fehlerhafter YAML-Index liefert leeren String statt Exception."""
knowledge_dir = monorepo_root / "shared" / "knowledge-store"
knowledge_dir.mkdir(parents=True, exist_ok=True)
index_path = knowledge_dir / "_index.yaml"
index_path.write_text("invalid: [yaml: content: {broken", encoding="utf-8")
adapter = OrchestratorAdapter(
root_path=monorepo_root,
context_guard=context_guard,
audit_logger=audit_logger,
knowledge_index_path=index_path,
)
result = adapter.inject_knowledge("bahn")
assert result == ""
# ---------------------------------------------------------------------------
# Tests: validate_access Out-of-Context-Zugriff (Req 7.5)
# ---------------------------------------------------------------------------
class TestValidateAccess:
"""Tests für die Zugriffsprüfung und Task-Abbruch bei Verletzung."""
def test_access_within_own_context_allowed(
self, adapter: OrchestratorAdapter, monorepo_root: Path
) -> None:
"""Zugriff auf Dateien im eigenen Kontext ist erlaubt."""
task = Task(id="TASK-1", title="Test")
target = monorepo_root / "dhive" / "project" / "file.py"
# Soll keine Exception werfen
adapter.validate_access(task, "dhive", target)
def test_access_to_shared_allowed(
self, adapter: OrchestratorAdapter, monorepo_root: Path
) -> None:
"""Zugriff auf shared-Bereich ist erlaubt (wenn in allowed_shared)."""
task = Task(id="TASK-1", title="Test")
target = monorepo_root / "shared" / "tools" / "helper.py"
adapter.validate_access(task, "dhive", target)
def test_cross_context_access_raises_violation(
self, adapter: OrchestratorAdapter, monorepo_root: Path
) -> None:
"""Zugriff auf anderen Kontext wirft ContextViolationError (Req 7.5)."""
task = Task(id="TASK-1", title="Test")
target = monorepo_root / "privat" / "secrets" / "private.key"
with pytest.raises(ContextViolationError) as exc_info:
adapter.validate_access(task, "dhive", target)
assert exc_info.value.task_id == "TASK-1"
assert exc_info.value.context == "dhive"
assert "privat" in exc_info.value.access_path
def test_violation_is_logged(
self, adapter: OrchestratorAdapter, monorepo_root: Path
) -> None:
"""Kontextverletzung wird im Audit-Log protokolliert (Req 7.5)."""
task = Task(id="TASK-AUDIT", title="Test")
target = monorepo_root / "bahn" / "secret.env"
with pytest.raises(ContextViolationError):
adapter.validate_access(task, "privat", target)
# Audit-Log prüfen
log_content = adapter.audit_logger.log_path.read_text(encoding="utf-8")
assert "DENIED" in log_content
assert "privat" in log_content
assert "bahn" in log_content
def test_violation_contains_task_identifier(
self, adapter: OrchestratorAdapter, monorepo_root: Path
) -> None:
"""Kontextverletzung enthält den Task-Identifier im Fehler."""
task = Task(id="TASK-TRACKED", title="Tracked Task")
target = monorepo_root / "dhive" / "internal.py"
with pytest.raises(ContextViolationError) as exc_info:
adapter.validate_access(task, "bahn", target)
assert exc_info.value.task_id == "TASK-TRACKED"
assert "bahn" in str(exc_info.value)
assert "dhive" in exc_info.value.access_path
# ---------------------------------------------------------------------------
# Tests: load_context_env Env-Isolation (Req 7.3)
# ---------------------------------------------------------------------------
class TestLoadContextEnv:
"""Tests für das Laden kontextspezifischer Umgebungsvariablen.
Stellt sicher, dass nur die .env des zugewiesenen Kontexts geladen wird
und keine Umgebungsvariablen anderer Kontexte sichtbar sind.
"""
def test_loads_correct_context_env(
self, adapter: OrchestratorAdapter
) -> None:
"""Lädt die .env-Datei des angegebenen Kontexts (Req 7.3)."""
env = adapter.load_context_env("dhive")
assert "DHIVE_SECRET" in env
assert env["DHIVE_SECRET"] == "geheim_dhive"
def test_does_not_load_other_context_env(
self, adapter: OrchestratorAdapter
) -> None:
"""Liefert KEINE Variablen aus anderen Kontexten."""
env = adapter.load_context_env("bahn")
assert "DHIVE_SECRET" not in env
assert "PRIVAT_SECRET" not in env
assert "BAHN_SECRET" in env
def test_invalid_context_raises_error(
self, adapter: OrchestratorAdapter
) -> None:
"""Ungültiger Kontext führt zu ValueError."""
with pytest.raises(ValueError, match="Unbekannter Kontext"):
adapter.load_context_env("invalid")
def test_privat_env_isolated_from_bahn(
self, adapter: OrchestratorAdapter
) -> None:
"""Privat-Env ist vollständig von Bahn-Env isoliert."""
privat_env = adapter.load_context_env("privat")
assert "PRIVAT_SECRET" in privat_env
assert "BAHN_SECRET" not in privat_env
assert "DHIVE_SECRET" not in privat_env
def test_env_with_encryption_manager_dispatches_to_decryption(
self,
monorepo_root: Path,
context_guard: ContextGuard,
audit_logger: AuditLogger,
encryption_manager: SecretEncryptionManager,
) -> None:
"""Mit EncryptionManager: load_context_env nutzt Entschlüsselung (Req 7.3).
Wenn ein EncryptionManager konfiguriert ist, wird der Entschlüsselungs-
Pfad aktiviert statt context_guard.load_env direkt zu verwenden.
"""
adapter = OrchestratorAdapter(
root_path=monorepo_root,
context_guard=context_guard,
audit_logger=audit_logger,
encryption_manager=encryption_manager,
)
# Verifiziere, dass der encryption_manager korrekt gesetzt ist
assert adapter.encryption_manager is not None
assert adapter.encryption_manager is encryption_manager
# Wenn _load_env_with_decryption implementiert ist (Task 12.2),
# wird dieser Pfad genutzt. Bis dahin testen wir den Routing-
# Mechanismus: encryption_manager vorhanden → anderer Code-Pfad.
# Wir mocken _load_env_with_decryption als Methode auf dem Adapter.
expected_env = {"DHIVE_KEY": "encrypted_value_123"}
adapter._load_env_with_decryption = MagicMock(return_value=expected_env) # type: ignore[attr-defined]
env = adapter.load_context_env("dhive")
adapter._load_env_with_decryption.assert_called_once_with("dhive") # type: ignore[attr-defined]
assert env == expected_env
def test_env_without_encryption_manager_uses_guard(
self, adapter: OrchestratorAdapter
) -> None:
"""Ohne EncryptionManager: context_guard.load_env wird direkt genutzt."""
# adapter hat keinen encryption_manager (None)
assert adapter.encryption_manager is None
env = adapter.load_context_env("dhive")
# Ergebnis kommt direkt vom ContextGuard
assert "DHIVE_SECRET" in env
def test_env_decryption_only_for_own_context(
self,
monorepo_root: Path,
context_guard: ContextGuard,
audit_logger: AuditLogger,
machine_context_dhive_only: MachineContext,
) -> None:
"""EncryptionManager mit beschränktem Maschinenkontext entschlüsselt
nur eigene .env-Dateien.
"""
enc_mgr = SecretEncryptionManager(monorepo_root, machine_context_dhive_only)
adapter = OrchestratorAdapter(
root_path=monorepo_root,
context_guard=context_guard,
audit_logger=audit_logger,
encryption_manager=enc_mgr,
)
# dhive ist autorisiert
assert enc_mgr.is_authorized("dhive") is True
# bahn ist NICHT autorisiert
assert enc_mgr.is_authorized("bahn") is False
# privat ist NICHT autorisiert
assert enc_mgr.is_authorized("privat") is False
# ---------------------------------------------------------------------------
# Tests: Multi-Harness-Dispatch (Req 7.7, 7.9, 7.10)
# ---------------------------------------------------------------------------
class TestMultiHarnessDispatch:
"""Tests für Multi-Harness-Dispatch Konfiguration und Fehlerbehandlung.
Testet die Integration des OrchestratorAdapters mit der
Harness-Konfiguration aus shared/config/harness-config.yaml.
Die eigentliche Dispatch-Logik liegt im ConfigMerger (shared_config.py),
aber der OrchestratorAdapter nutzt diese Konfiguration pro Task.
"""
def test_harness_config_per_context(
self, monorepo_root: Path, harness_config: Path
) -> None:
"""Harness-Konfiguration ist pro Kontext definiert (Req 7.7)."""
from monorepo.shared_config import ConfigMerger
merger = ConfigMerger(monorepo_root)
dhive_cfg = merger.get_harness_config("dhive")
assert dhive_cfg["harness"] == "codex"
bahn_cfg = merger.get_harness_config("bahn")
assert bahn_cfg["harness"] == "kiro"
privat_cfg = merger.get_harness_config("privat")
assert privat_cfg["harness"] == "claude-code"
def test_harness_start_sequence_defined(
self, monorepo_root: Path, harness_config: Path
) -> None:
"""Startsequenz ist pro Harness in der Konfiguration definiert (Req 7.9)."""
from monorepo.shared_config import ConfigMerger
merger = ConfigMerger(monorepo_root)
dhive_cfg = merger.get_harness_config("dhive")
assert "start_sequence" in dhive_cfg
assert dhive_cfg["start_sequence"]["type"] == "cli"
assert dhive_cfg["start_sequence"]["command"] == "codex"
bahn_cfg = merger.get_harness_config("bahn")
assert "start_sequence" in bahn_cfg
assert bahn_cfg["start_sequence"]["type"] == "api"
assert "endpoint" in bahn_cfg["start_sequence"]
def test_harness_not_configured_returns_empty(
self, monorepo_root: Path, harness_config: Path
) -> None:
"""Nicht konfigurierter Kontext → leeres Dict (Req 7.10)."""
from monorepo.shared_config import ConfigMerger
merger = ConfigMerger(monorepo_root)
unknown_cfg = merger.get_harness_config("nonexistent")
assert unknown_cfg == {}
def test_harness_unavailable_detection(
self, monorepo_root: Path, harness_config: Path
) -> None:
"""Nicht verfügbarer Harness erkennbar über leere Konfiguration (Req 7.10).
Wenn kein Harness für den Kontext konfiguriert ist, soll der Task
als nicht-ausführbar markiert werden können.
"""
from monorepo.shared_config import ConfigMerger
merger = ConfigMerger(monorepo_root)
# shared-Kontext hat keinen dedizierten Harness konfiguriert
shared_cfg = merger.get_harness_config("shared")
# Leeres Dict signalisiert: kein Harness verfügbar
assert shared_cfg == {}
def test_harness_config_contains_necessary_fields(
self, monorepo_root: Path, harness_config: Path
) -> None:
"""Harness-Konfiguration enthält alle nötigen Felder für Dispatch."""
from monorepo.shared_config import ConfigMerger
merger = ConfigMerger(monorepo_root)
dhive_cfg = merger.get_harness_config("dhive")
# Pflichtfelder für Dispatch (Req 7.7, 7.9)
assert "harness" in dhive_cfg # Welcher Harness
assert "start_sequence" in dhive_cfg # Wie starten
assert "config" in dhive_cfg # Harness-spezifische Config
def test_harness_cli_start_sequence_has_command_and_args(
self, monorepo_root: Path, harness_config: Path
) -> None:
"""CLI-Startsequenz hat command und args Felder (Req 7.9)."""
from monorepo.shared_config import ConfigMerger
merger = ConfigMerger(monorepo_root)
privat_cfg = merger.get_harness_config("privat")
seq = privat_cfg["start_sequence"]
assert seq["type"] == "cli"
assert "command" in seq
assert "args" in seq
assert isinstance(seq["args"], list)
def test_harness_api_start_sequence_has_endpoint(
self, monorepo_root: Path, harness_config: Path
) -> None:
"""API-Startsequenz hat endpoint Feld (Req 7.9)."""
from monorepo.shared_config import ConfigMerger
merger = ConfigMerger(monorepo_root)
bahn_cfg = merger.get_harness_config("bahn")
seq = bahn_cfg["start_sequence"]
assert seq["type"] == "api"
assert "endpoint" in seq
assert seq["endpoint"].startswith("http")
def test_no_harness_config_file_returns_empty(
self,
monorepo_root: Path,
context_guard: ContextGuard,
audit_logger: AuditLogger,
) -> None:
"""Fehlende harness-config.yaml → get_harness_config gibt leeres Dict."""
from monorepo.shared_config import ConfigMerger
# Keine harness-config.yaml erstellt
merger = ConfigMerger(monorepo_root)
assert merger.get_harness_config("dhive") == {}
assert merger.get_harness_config("bahn") == {}
def test_malformed_harness_config_returns_empty(
self, monorepo_root: Path
) -> None:
"""Fehlerhafte harness-config.yaml → leeres Dict statt Absturz."""
from monorepo.shared_config import ConfigMerger
config_path = monorepo_root / "shared" / "config" / "harness-config.yaml"
config_path.write_text(
"invalid: [yaml: content: {broken", encoding="utf-8"
)
merger = ConfigMerger(monorepo_root)
# Soll nicht abstürzen, sondern leeres Dict zurückgeben
assert merger.get_harness_config("dhive") == {}
# ---------------------------------------------------------------------------
# Tests: Integration Vollständiger Workflow
# ---------------------------------------------------------------------------
class TestOrchestratorWorkflow:
"""Integration-Tests: Vollständiger Task-Workflow im OrchestratorAdapter.
Testet den Zusammenhang: resolve_context create_workspace build_prompt.
"""
def test_full_task_workflow(
self,
monorepo_root: Path,
context_guard: ContextGuard,
audit_logger: AuditLogger,
knowledge_index: Path,
) -> None:
"""Vollständiger Workflow: Kontext auflösen → Workspace → Prompt."""
adapter = OrchestratorAdapter(
root_path=monorepo_root,
context_guard=context_guard,
audit_logger=audit_logger,
knowledge_index_path=knowledge_index,
)
task = Task(
id="FULL-1",
title="API-Endpoint implementieren",
labels=["bahn"],
description="Erstelle GET /api/v1/trains",
)
# 1. Kontext auflösen
context = adapter.resolve_context(task)
assert context == "bahn"
# 2. Workspace erstellen
workspace = adapter.create_workspace(task, context)
assert workspace.exists()
assert "bahn" in str(workspace)
# 3. Prompt erstellen
prompt = adapter.build_prompt(task, context)
assert "API-Endpoint implementieren" in prompt
assert "bahn" in prompt
assert "GET /api/v1/trains" in prompt
# Wissensartefakte aus dem bahn-Kontext
assert "API-Design-Entscheidung" in prompt
def test_workflow_with_env_loading(
self, adapter: OrchestratorAdapter
) -> None:
"""Workflow mit Env-Loading: Nur eigene Env-Variablen."""
task = Task(id="ENV-1", title="Deploy", labels=["dhive"])
context = adapter.resolve_context(task)
assert context == "dhive"
env = adapter.load_context_env(context)
assert "DHIVE_SECRET" in env
assert "BAHN_SECRET" not in env
assert "PRIVAT_SECRET" not in env
@@ -0,0 +1,341 @@
"""Property-basierte Tests für den OrchestratorAdapter: Kontextauflösung und Workspace-Isolation.
**Validates: Requirements 7.1, 7.2, 7.3, 7.6**
Property 19: Orchestrator-Kontextauflösung und Workspace-Isolation
- For any Task mit gültigen Kontext-Metadaten muss der Orchestrator (a) das Workspace unter
dem korrekten Kontextordner anlegen und (b) ausschließlich die .env-Datei dieses Kontexts laden.
Für Tasks ohne gültigen Kontext muss der Start verweigert werden.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import yaml
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.audit import AuditLogger
from monorepo.models import Context
from monorepo.orchestrator import (
ALL_CONTEXTS,
ContextResolutionError,
OrchestratorAdapter,
Task,
)
from monorepo.security import ContextGuard
# --- Constants ---
VALID_CONTEXTS = [c.value for c in Context] # privat, dhive, bahn, shared
# Labels that are NOT valid contexts (for generating tasks without context)
NON_CONTEXT_LABELS = [
"bug", "feature", "urgent", "low-priority", "frontend", "backend",
"refactor", "documentation", "spike", "tech-debt", "improvement",
]
# --- Strategies ---
@st.composite
def valid_task_with_context_label(draw: st.DrawFn) -> tuple[Task, str]:
"""Generates a Task with a valid context label and returns (task, expected_context).
The task has at least one label matching a valid context.
"""
context = draw(st.sampled_from(VALID_CONTEXTS))
task_id = draw(st.from_regex(r"TASK-[A-Z0-9]{1,8}", fullmatch=True))
# Mix with other non-context labels, but ensure context label is present
extra_labels = draw(st.lists(st.sampled_from(NON_CONTEXT_LABELS), max_size=3))
# Insert context label at a random position
insert_pos = draw(st.integers(min_value=0, max_value=len(extra_labels)))
labels = extra_labels[:insert_pos] + [context] + extra_labels[insert_pos:]
title = draw(st.text(min_size=1, max_size=50, alphabet=st.characters(
whitelist_categories=("L", "N", "Z"),
)))
task = Task(id=task_id, title=title, labels=labels)
return task, context
@st.composite
def valid_task_with_context_project(draw: st.DrawFn) -> tuple[Task, str]:
"""Generates a Task with a valid context from project path (no valid context label).
The task has a project field like "dhive/some-project" and NO context labels.
"""
context = draw(st.sampled_from(VALID_CONTEXTS))
task_id = draw(st.from_regex(r"TASK-[A-Z0-9]{1,8}", fullmatch=True))
project_name = draw(st.from_regex(r"[a-z][a-z0-9\-]{1,20}", fullmatch=True))
project = f"{context}/{project_name}"
# Only non-context labels
labels = draw(st.lists(st.sampled_from(NON_CONTEXT_LABELS), max_size=3))
title = draw(st.text(min_size=1, max_size=50, alphabet=st.characters(
whitelist_categories=("L", "N", "Z"),
)))
task = Task(id=task_id, title=title, labels=labels, project=project)
return task, context
@st.composite
def task_without_valid_context(draw: st.DrawFn) -> Task:
"""Generates a Task that has NO valid context in labels or project."""
task_id = draw(st.from_regex(r"TASK-[A-Z0-9]{1,8}", fullmatch=True))
# Only non-context labels
labels = draw(st.lists(st.sampled_from(NON_CONTEXT_LABELS), min_size=0, max_size=4))
# Project that doesn't start with a valid context
project = draw(st.sampled_from([
"", "unknown/something", "misc-project", "team/frontend",
"xyz/test", "other", "none/task",
]))
title = draw(st.text(min_size=1, max_size=50, alphabet=st.characters(
whitelist_categories=("L", "N", "Z"),
)))
task = Task(id=task_id, title=title, labels=labels, project=project)
return task
# --- Helpers ---
def _create_test_environment() -> tuple[Path, OrchestratorAdapter]:
"""Creates a temporary monorepo structure with OrchestratorAdapter.
Returns a tuple of (tmp_dir_path, adapter).
"""
tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_orch_test_"))
# Create the monorepo directory structure
for ctx in VALID_CONTEXTS:
(tmp_dir / ctx).mkdir(parents=True, exist_ok=True)
# Create .env files per context with distinct variables
env_contents = {
"privat": "PRIVAT_API_KEY=secret_privat_123\nPRIVAT_DB_URL=postgres://privat\n",
"dhive": "DHIVE_TOKEN=token_dhive_456\nDHIVE_ENDPOINT=https://dhive.io\n",
"bahn": "BAHN_SECRET=geheim_bahn_789\nBAHN_API_URL=https://bahn.de/api\n",
"shared": "SHARED_CONFIG=global_config\n",
}
for ctx, content in env_contents.items():
(tmp_dir / ctx / ".env").write_text(content, encoding="utf-8")
# Create access-config.yaml
config_dir = tmp_dir / "shared" / "config"
config_dir.mkdir(parents=True, exist_ok=True)
access_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": {
"env_file": "shared/.env",
"allowed_shared": ["*"],
},
}
}
config_file = config_dir / "access-config.yaml"
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(access_config, f)
# Create ContextGuard and AuditLogger
guard = ContextGuard(root_path=tmp_dir, access_config_path=config_file)
audit_logger = AuditLogger(log_path=tmp_dir / ".audit" / "access.log")
adapter = OrchestratorAdapter(
root_path=tmp_dir,
context_guard=guard,
audit_logger=audit_logger,
)
return tmp_dir, adapter
# --- Tests ---
class TestProperty19ContextResolutionAndWorkspaceIsolation:
"""Property 19: Orchestrator-Kontextauflösung und Workspace-Isolation.
**Validates: Requirements 7.1, 7.2, 7.3, 7.6**
For any Task with valid context metadata, the OrchestratorAdapter must:
(a) resolve the correct working context
(b) create a workspace under the correct context folder
(c) load env vars only from the assigned context
For Tasks without valid context, the start must be refused.
"""
@given(data=st.data())
@settings(max_examples=200)
def test_resolve_context_returns_valid_context_from_label(
self, data: st.DataObject
) -> None:
"""For any task with a valid context label, resolve_context returns
one of the valid contexts (privat, dhive, bahn, shared).
Requirement 7.1: Kontext aus Task-Metadaten (Labels) ermitteln.
"""
task, expected_context = data.draw(valid_task_with_context_label())
_, adapter = _create_test_environment()
resolved = adapter.resolve_context(task)
assert resolved in VALID_CONTEXTS, (
f"resolve_context returned '{resolved}' which is not a valid context. "
f"Expected one of {VALID_CONTEXTS}"
)
assert resolved == expected_context, (
f"resolve_context returned '{resolved}' but expected '{expected_context}' "
f"from labels {task.labels}"
)
@given(data=st.data())
@settings(max_examples=200)
def test_resolve_context_returns_valid_context_from_project(
self, data: st.DataObject
) -> None:
"""For any task with a valid context from project path, resolve_context
returns the correct context.
Requirement 7.1: Kontext aus Task-Metadaten (Projektzuordnung) ermitteln.
"""
task, expected_context = data.draw(valid_task_with_context_project())
_, adapter = _create_test_environment()
resolved = adapter.resolve_context(task)
assert resolved in VALID_CONTEXTS, (
f"resolve_context returned '{resolved}' which is not a valid context."
)
assert resolved == expected_context, (
f"resolve_context returned '{resolved}' but expected '{expected_context}' "
f"from project '{task.project}'"
)
@given(data=st.data())
@settings(max_examples=200)
def test_create_workspace_under_correct_context_folder(
self, data: st.DataObject
) -> None:
"""For any task with valid context, create_workspace always creates
a path under the resolved context folder.
Requirement 7.2: Workspace-Root pro Task unter dem Kontextordner.
"""
task, expected_context = data.draw(valid_task_with_context_label())
tmp_dir, adapter = _create_test_environment()
context = adapter.resolve_context(task)
workspace = adapter.create_workspace(task, context)
# Workspace must exist
assert workspace.exists(), (
f"Workspace was not created at '{workspace}'"
)
# Workspace must be under the correct context folder
# Use resolve() on both to normalize Windows short path names (e.g. ANDREK~1 vs AndreKnie)
workspace_resolved = workspace.resolve()
context_root_resolved = (tmp_dir / context).resolve()
assert str(workspace_resolved).startswith(str(context_root_resolved)), (
f"Workspace '{workspace_resolved}' is not under context folder '{context_root_resolved}'. "
f"Expected workspace under '{context}/' for task with context '{context}'"
)
@given(data=st.data())
@settings(max_examples=200)
def test_load_env_contains_only_assigned_context_vars(
self, data: st.DataObject
) -> None:
"""For any task with valid context, the loaded env must contain only
variables from the assigned context.
Requirement 7.3: Ausschließlich .env des zugewiesenen Kontexts laden.
"""
task, expected_context = data.draw(valid_task_with_context_label())
_, adapter = _create_test_environment()
context = adapter.resolve_context(task)
env_vars = adapter.load_context_env(context)
# env_vars must not be empty (each context has a .env file)
assert len(env_vars) > 0, (
f"No env vars loaded for context '{context}'"
)
# Define which env var prefixes belong to which context
context_prefixes = {
"privat": "PRIVAT_",
"dhive": "DHIVE_",
"bahn": "BAHN_",
"shared": "SHARED_",
}
# All loaded env vars must have the prefix of the assigned context
expected_prefix = context_prefixes[context]
for key in env_vars:
assert key.startswith(expected_prefix), (
f"Env var '{key}' loaded for context '{context}' does not have "
f"expected prefix '{expected_prefix}'. This indicates env vars "
f"from another context leaked into '{context}'."
)
# Vars from OTHER contexts must NOT be present
other_prefixes = [
prefix for ctx, prefix in context_prefixes.items() if ctx != context
]
for key in env_vars:
for other_prefix in other_prefixes:
assert not key.startswith(other_prefix), (
f"Env var '{key}' from another context found in '{context}' env! "
f"This violates workspace isolation."
)
@given(task=task_without_valid_context())
@settings(max_examples=200)
def test_no_context_refuses_task_start(self, task: Task) -> None:
"""For tasks without a valid context, the start must be refused
with a ContextResolutionError.
Requirement 7.6: Kein Kontext Task nicht starten, Fehlermeldung.
"""
_, adapter = _create_test_environment()
try:
adapter.resolve_context(task)
# If we get here, the task had an unexpected valid context
assert False, (
f"Expected ContextResolutionError for task without valid context. "
f"Task: id={task.id}, labels={task.labels}, project='{task.project}'"
)
except ContextResolutionError as e:
# This is the expected behavior
assert task.id in str(e), (
f"ContextResolutionError should contain task_id '{task.id}'"
)
assert e.task_id == task.id
@@ -0,0 +1,355 @@
"""Property-basierte Tests für den OrchestratorAdapter: Kontextverletzung bricht Task ab.
**Validates: Requirements 7.5**
Property 20: Orchestrator-Kontextverletzung bricht Task ab
- For any Zugriff des Orchestrators auf Dateien oder Secrets außerhalb des zugewiesenen
Kontextordners muss der laufende Task abgebrochen, der Vorfall protokolliert und der
Nutzer benachrichtigt werden.
Key properties tested:
1. For any combination of assigned context and attempted access path outside that context,
the adapter must raise an error / abort.
2. The error/log must contain the task identifier and the violating path.
3. Access to the own context and shared (if allowed) must succeed.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import yaml
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.audit import AuditLogger
from monorepo.models import Context
from monorepo.orchestrator import (
ContextViolationError,
OrchestratorAdapter,
Task,
)
from monorepo.security import ContextGuard
# --- Constants ---
ALL_CONTEXTS = [c.value for c in Context]
NON_SHARED_CONTEXTS = [c.value for c in Context if c != Context.SHARED]
# File names that are typical targets for out-of-context access
TARGET_FILES = [
".env",
"credentials.pem",
"private.key",
"api-token.txt",
"secret-config.yaml",
"src/main.py",
"config/settings.yaml",
"data/users.json",
]
# Subdirectories within a context
SUBDIRS = ["", "project-a", "module-x", "config", "keys", "src", "data"]
# --- Strategies ---
@st.composite
def out_of_context_access(draw: st.DrawFn) -> tuple[str, str, str]:
"""Generates (assigned_context, target_context, target_file) where the access is
outside the assigned context (target_context != assigned_context AND target_context != shared).
Returns:
Tuple of (assigned_context, target_context, target_file).
"""
assigned = draw(st.sampled_from(ALL_CONTEXTS))
target_ctx = draw(st.sampled_from(NON_SHARED_CONTEXTS))
assume(target_ctx != assigned)
subdir = draw(st.sampled_from(SUBDIRS))
file_name = draw(st.sampled_from(TARGET_FILES))
if subdir:
target_file = f"{target_ctx}/{subdir}/{file_name}"
else:
target_file = f"{target_ctx}/{file_name}"
return assigned, target_ctx, target_file
@st.composite
def own_context_access(draw: st.DrawFn) -> tuple[str, str]:
"""Generates (context, file_path) where access is within the own context.
Returns:
Tuple of (context, relative_file_path_within_context).
"""
context = draw(st.sampled_from(ALL_CONTEXTS))
subdir = draw(st.sampled_from(SUBDIRS))
file_name = draw(st.sampled_from(TARGET_FILES))
if subdir:
file_path = f"{context}/{subdir}/{file_name}"
else:
file_path = f"{context}/{file_name}"
return context, file_path
@st.composite
def shared_access_from_any_context(draw: st.DrawFn) -> tuple[str, str]:
"""Generates (context, shared_path) where any non-shared context accesses allowed shared paths.
Returns:
Tuple of (requesting_context, shared_file_path).
"""
context = draw(st.sampled_from(NON_SHARED_CONTEXTS))
# Allowed shared paths based on our test config
allowed_shared_dirs = ["shared/tools", "shared/powers", "shared/config"]
shared_dir = draw(st.sampled_from(allowed_shared_dirs))
file_name = draw(st.sampled_from(["helper.py", "config.yaml", "utils.sh", "README.md"]))
return context, f"{shared_dir}/{file_name}"
@st.composite
def task_ids(draw: st.DrawFn) -> str:
"""Generates realistic task identifiers."""
prefix = draw(st.sampled_from(["TASK", "ORG", "DEV", "OPS"]))
number = draw(st.integers(min_value=1, max_value=9999))
return f"{prefix}-{number}"
# --- Helpers ---
def _create_test_environment() -> tuple[Path, OrchestratorAdapter]:
"""Creates a temporary monorepo structure with OrchestratorAdapter.
Returns a tuple of (tmp_dir_path, adapter).
"""
tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_orch_test_"))
# Create the monorepo directory structure
for ctx in ALL_CONTEXTS:
(tmp_dir / ctx).mkdir(parents=True, exist_ok=True)
# Create shared subdirectories
for subdir in ["tools", "powers", "config", "knowledge-store", "mcp-servers"]:
(tmp_dir / "shared" / subdir).mkdir(parents=True, exist_ok=True)
# Create access-config.yaml
config_dir = tmp_dir / "shared" / "config"
access_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_file = config_dir / "access-config.yaml"
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(access_config, f)
# Create .env files for contexts
for ctx in NON_SHARED_CONTEXTS:
env_path = tmp_dir / ctx / ".env"
env_path.write_text(f"{ctx.upper()}_SECRET=test_{ctx}\n", encoding="utf-8")
guard = ContextGuard(root_path=tmp_dir, access_config_path=config_file)
audit_logger = AuditLogger(log_path=tmp_dir / ".audit" / "access.log")
adapter = OrchestratorAdapter(
root_path=tmp_dir,
context_guard=guard,
audit_logger=audit_logger,
)
return tmp_dir, adapter
# --- Tests ---
class TestProperty20ContextViolationAbortsTask:
"""Property 20: Orchestrator-Kontextverletzung bricht Task ab.
**Validates: Requirements 7.5**
For any access by the Orchestrator to files or secrets outside the assigned
context folder, the running task must be aborted, the incident logged, and
the user notified.
"""
@given(data=st.data())
@settings(max_examples=200)
def test_out_of_context_access_raises_violation(self, data: st.DataObject) -> None:
"""Any file access outside the assigned context must raise ContextViolationError.
This verifies that validate_access aborts (raises) for any path that
belongs to a different non-shared context.
"""
assigned, target_ctx, target_file = data.draw(out_of_context_access())
task_id = data.draw(task_ids())
tmp_dir, adapter = _create_test_environment()
task = Task(id=task_id, title="Test-Task", labels=[assigned])
target_path = tmp_dir / target_file
try:
adapter.validate_access(task, assigned, target_path)
# If we reach here, the access was NOT denied → property violated
assert False, (
f"Expected ContextViolationError but access was allowed: "
f"context='{assigned}', target='{target_file}'"
)
except ContextViolationError as exc:
# Access correctly denied → verify exception contains required info
assert exc.task_id == task_id, (
f"Exception must contain task_id '{task_id}', got '{exc.task_id}'"
)
assert exc.context == assigned, (
f"Exception must contain context '{assigned}', got '{exc.context}'"
)
@given(data=st.data())
@settings(max_examples=200)
def test_violation_error_contains_task_id_and_path(self, data: st.DataObject) -> None:
"""The ContextViolationError must contain the task identifier AND the violating access path.
Requirement 7.5: "den Vorfall mit Task-Identifier und Zugriffspfad protokollieren"
"""
assigned, target_ctx, target_file = data.draw(out_of_context_access())
task_id = data.draw(task_ids())
tmp_dir, adapter = _create_test_environment()
task = Task(id=task_id, title="Test-Task", labels=[assigned])
target_path = tmp_dir / target_file
try:
adapter.validate_access(task, assigned, target_path)
assert False, "Expected ContextViolationError"
except ContextViolationError as exc:
# Task identifier must be in the error
assert task_id in str(exc), (
f"ContextViolationError message must contain task_id '{task_id}': {exc}"
)
# The access path must be identifiable from the error
assert exc.access_path != "", (
"ContextViolationError must contain a non-empty access_path"
)
# The path in the error must refer to the attempted target
assert target_ctx in exc.access_path or target_file.split("/")[-1] in exc.access_path, (
f"ContextViolationError access_path should reference the target: "
f"expected context '{target_ctx}' or file in '{exc.access_path}'"
)
@given(data=st.data())
@settings(max_examples=200)
def test_violation_is_logged_with_task_id_and_path(self, data: st.DataObject) -> None:
"""When a context violation occurs, the audit log must contain the task identifier
and access path.
Requirement 7.5: "den Vorfall mit Task-Identifier und Zugriffspfad protokollieren"
"""
assigned, target_ctx, target_file = data.draw(out_of_context_access())
task_id = data.draw(task_ids())
tmp_dir, adapter = _create_test_environment()
task = Task(id=task_id, title="Test-Task", labels=[assigned])
target_path = tmp_dir / target_file
try:
adapter.validate_access(task, assigned, target_path)
assert False, "Expected ContextViolationError"
except ContextViolationError:
pass
# Verify audit log was written
log_path = tmp_dir / ".audit" / "access.log"
assert log_path.exists(), "Audit log file must exist after violation"
log_content = log_path.read_text(encoding="utf-8")
assert log_content.strip() != "", "Audit log must not be empty after violation"
# The log must contain the requesting context
assert assigned in log_content, (
f"Audit log must contain requesting context '{assigned}': {log_content}"
)
# The log must contain the target context
assert target_ctx in log_content, (
f"Audit log must contain target context '{target_ctx}': {log_content}"
)
# The log must indicate the access was denied
assert "DENIED" in log_content, (
f"Audit log must indicate DENIED outcome: {log_content}"
)
@given(data=st.data())
@settings(max_examples=200)
def test_own_context_access_succeeds(self, data: st.DataObject) -> None:
"""Access to files within the own assigned context must always succeed (no abort).
This is the positive counterpart: validate_access must NOT raise for paths
within the assigned context.
"""
context, file_path = data.draw(own_context_access())
task_id = data.draw(task_ids())
tmp_dir, adapter = _create_test_environment()
task = Task(id=task_id, title="Test-Task", labels=[context])
target_path = tmp_dir / file_path
# Must NOT raise any exception
adapter.validate_access(task, context, target_path)
@given(data=st.data())
@settings(max_examples=100)
def test_shared_access_from_any_context_succeeds(self, data: st.DataObject) -> None:
"""Access to allowed shared paths must succeed from any context.
Shared paths listed in allowed_shared config must not trigger a violation.
"""
context, shared_path = data.draw(shared_access_from_any_context())
task_id = data.draw(task_ids())
tmp_dir, adapter = _create_test_environment()
task = Task(id=task_id, title="Test-Task", labels=[context])
target_path = tmp_dir / shared_path
# Must NOT raise any exception
adapter.validate_access(task, context, target_path)
@@ -0,0 +1,491 @@
"""Unit-Tests für den RepoManager.
Testet die Einbindung und Synchronisation externer Repositories:
- Subtree-Add und Submodule-Add
- Sync-Ergebnismeldungen (Erfolg, Fehler, unbekannt)
- Bidirektionale Sync für Upstream-Repos
- Abbruch bei Merge-Konflikten
- Konfigurationsmanagement (Laden/Speichern)
Validates: Requirements 4.1, 4.3, 4.4, 4.6, 4.7, 4.8
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import yaml
from monorepo.models import RepoEntry
from monorepo.repos import RepoManager
@pytest.fixture
def repos_config(tmp_path: Path) -> Path:
"""Erstellt eine temporäre repos.yaml mit Testdaten."""
config_path = tmp_path / "config" / "repos.yaml"
config_path.parent.mkdir(parents=True)
data = {
"repos": [
{
"name": "symphony-spec",
"url": "https://github.com/openai/symphony",
"mode": "read-only",
"target": "shared/references/symphony",
"pinned": "v1.0.0",
"mechanism": "subtree",
},
{
"name": "db-wissensdatenbank",
"url": "https://gitlab.example.com/db-wissen",
"mode": "upstream",
"target": "bahn/db-wissensdatenbank",
"pinned": "main",
"mechanism": "subtree",
},
]
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f)
return config_path
@pytest.fixture
def empty_config(tmp_path: Path) -> Path:
"""Erstellt eine leere repos.yaml."""
config_path = tmp_path / "config" / "repos.yaml"
config_path.parent.mkdir(parents=True)
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump({"repos": []}, f)
return config_path
@pytest.fixture
def monorepo_root(tmp_path: Path) -> Path:
"""Erstellt ein temporäres Monorepo-Verzeichnis."""
root = tmp_path / "monorepo"
root.mkdir()
return root
@pytest.fixture
def manager(repos_config: Path, monorepo_root: Path) -> RepoManager:
"""Erstellt einen RepoManager mit Testkonfiguration."""
return RepoManager(config_path=repos_config, monorepo_root=monorepo_root)
# ---------------------------------------------------------------------------
# Konfiguration laden
# ---------------------------------------------------------------------------
class TestLoadConfig:
"""Testet das Laden der Repo-Konfiguration."""
def test_loads_repos_from_yaml(self, manager: RepoManager) -> None:
"""Lädt alle Repos aus der Konfigurationsdatei."""
assert len(manager.repos) == 2
def test_first_repo_is_readonly(self, manager: RepoManager) -> None:
"""Erstes Repo ist als read-only konfiguriert."""
assert manager.repos[0].name == "symphony-spec"
assert manager.repos[0].mode == "read-only"
def test_second_repo_is_upstream(self, manager: RepoManager) -> None:
"""Zweites Repo ist als upstream konfiguriert."""
assert manager.repos[1].name == "db-wissensdatenbank"
assert manager.repos[1].mode == "upstream"
def test_mechanism_defaults_to_subtree(self, tmp_path: Path) -> None:
"""Mechanismus fällt auf 'subtree' zurück wenn nicht angegeben."""
config_path = tmp_path / "repos.yaml"
data = {
"repos": [
{
"name": "test-repo",
"url": "https://example.com/repo",
"mode": "read-only",
"target": "shared/test",
"pinned": "main",
}
]
}
with open(config_path, "w") as f:
yaml.dump(data, f)
mgr = RepoManager(config_path=config_path, monorepo_root=tmp_path)
assert mgr.repos[0].mechanism == "subtree"
def test_missing_config_file_returns_empty_list(self, tmp_path: Path) -> None:
"""Fehlende Konfigurationsdatei ergibt leere Repo-Liste."""
config_path = tmp_path / "nonexistent.yaml"
mgr = RepoManager(config_path=config_path, monorepo_root=tmp_path)
assert mgr.repos == []
# ---------------------------------------------------------------------------
# add_repo: Subtree
# ---------------------------------------------------------------------------
class TestAddRepoSubtree:
"""Testet das Einbinden eines Repos via Git-Subtree."""
@patch("monorepo.repos.RepoManager._run_git")
def test_subtree_add_calls_git_correctly(
self, mock_git: MagicMock, empty_config: Path, monorepo_root: Path
) -> None:
"""Subtree-Add ruft git subtree add mit korrekten Argumenten auf."""
mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root)
entry = RepoEntry(
name="test-repo",
url="https://example.com/repo.git",
mode="read-only",
target="shared/refs/test",
pinned="v2.0.0",
mechanism="subtree",
)
mock_git.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr=""
)
mgr.add_repo(entry)
mock_git.assert_called_once_with([
"subtree",
"add",
"--prefix=shared/refs/test",
"https://example.com/repo.git",
"v2.0.0",
"--squash",
])
@patch("monorepo.repos.RepoManager._run_git")
def test_subtree_add_saves_to_config(
self, mock_git: MagicMock, empty_config: Path, monorepo_root: Path
) -> None:
"""Nach dem Hinzufügen wird das Repo in repos.yaml gespeichert."""
mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root)
entry = RepoEntry(
name="new-repo",
url="https://example.com/new.git",
mode="read-only",
target="shared/new",
pinned="main",
mechanism="subtree",
)
mock_git.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr=""
)
mgr.add_repo(entry)
# Konfigurationsdatei prüfen
with open(empty_config, encoding="utf-8") as f:
saved = yaml.safe_load(f)
assert len(saved["repos"]) == 1
assert saved["repos"][0]["name"] == "new-repo"
def test_add_repo_fails_if_target_exists(
self, empty_config: Path, monorepo_root: Path
) -> None:
"""Fehler wenn das Zielverzeichnis bereits existiert."""
mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root)
target = monorepo_root / "shared" / "existing"
target.mkdir(parents=True)
entry = RepoEntry(
name="conflict-repo",
url="https://example.com/repo.git",
mode="read-only",
target="shared/existing",
pinned="main",
mechanism="subtree",
)
with pytest.raises(ValueError, match="existiert bereits"):
mgr.add_repo(entry)
# ---------------------------------------------------------------------------
# add_repo: Submodule
# ---------------------------------------------------------------------------
class TestAddRepoSubmodule:
"""Testet das Einbinden eines Repos via Git-Submodule."""
@patch("monorepo.repos.RepoManager._run_git")
def test_submodule_add_calls_git_correctly(
self, mock_git: MagicMock, empty_config: Path, monorepo_root: Path
) -> None:
"""Submodule-Add ruft git submodule add und checkout auf."""
mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root)
entry = RepoEntry(
name="sub-repo",
url="https://example.com/sub.git",
mode="read-only",
target="shared/refs/sub",
pinned="v1.0.0",
mechanism="submodule",
)
# Target-Verzeichnis darf NICHT existieren (wird von git erstellt)
# Aber _add_submodule braucht es für den checkout-Aufruf.
# Wir simulieren, dass git submodule add das Verzeichnis erstellt:
def side_effect(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
if args[0] == "submodule":
# git submodule add erstellt das Verzeichnis
(monorepo_root / "shared" / "refs" / "sub").mkdir(parents=True)
return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
mock_git.side_effect = side_effect
mgr.add_repo(entry)
# Erstes Call: submodule add
assert mock_git.call_count == 2
first_call = mock_git.call_args_list[0]
assert first_call[0][0] == [
"submodule",
"add",
"https://example.com/sub.git",
"shared/refs/sub",
]
# Zweites Call: checkout pinned version
second_call = mock_git.call_args_list[1]
assert second_call[0][0] == ["checkout", "v1.0.0"]
def test_unknown_mechanism_raises_error(
self, empty_config: Path, monorepo_root: Path
) -> None:
"""Unbekannter Mechanismus führt zu ValueError."""
mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root)
entry = RepoEntry(
name="bad-repo",
url="https://example.com/bad.git",
mode="read-only",
target="shared/bad",
pinned="main",
mechanism="unknown", # type: ignore[arg-type]
)
with pytest.raises(ValueError, match="Unbekannter Mechanismus"):
mgr.add_repo(entry)
# ---------------------------------------------------------------------------
# sync: Read-Only
# ---------------------------------------------------------------------------
class TestSyncReadOnly:
"""Testet die Synchronisation von Read-Only-Repos."""
@patch("monorepo.repos.RepoManager._run_git")
def test_sync_readonly_success(
self, mock_git: MagicMock, manager: RepoManager
) -> None:
"""Erfolgreiche Synchronisation eines Read-Only-Repos."""
mock_git.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout="1 file changed, 10 insertions(+)", stderr=""
)
result = manager.sync("symphony-spec")
assert result.success is True
assert result.direction == "pull"
assert result.commits_synced >= 1
assert result.conflicts == []
@patch("monorepo.repos.RepoManager._run_git")
def test_sync_readonly_already_up_to_date(
self, mock_git: MagicMock, manager: RepoManager
) -> None:
"""Repo ist bereits auf dem neuesten Stand."""
mock_git.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout="Already up to date.\n", stderr=""
)
result = manager.sync("symphony-spec")
assert result.success is True
assert result.commits_synced == 0
@patch("monorepo.repos.RepoManager._run_git")
def test_sync_readonly_network_error(
self, mock_git: MagicMock, manager: RepoManager
) -> None:
"""Netzwerkfehler bei Synchronisation liefert Fehlerergebnis."""
mock_git.side_effect = subprocess.CalledProcessError(
returncode=128,
cmd=["git", "subtree", "pull"],
output="",
stderr="fatal: unable to access 'https://...': Could not resolve host",
)
result = manager.sync("symphony-spec")
assert result.success is False
assert result.direction == "pull"
assert len(result.conflicts) == 1
assert "fehlgeschlagen" in result.conflicts[0].details
def test_sync_unknown_repo_returns_failure(self, manager: RepoManager) -> None:
"""Sync eines unbekannten Repos gibt Fehlermeldung zurück."""
result = manager.sync("nonexistent-repo")
assert result.success is False
assert "nicht in Konfiguration" in result.conflicts[0].details
# ---------------------------------------------------------------------------
# sync: Upstream (bidirektional)
# ---------------------------------------------------------------------------
class TestSyncUpstream:
"""Testet die bidirektionale Synchronisation für Upstream-Repos."""
@patch("monorepo.repos.RepoManager._run_git")
def test_sync_upstream_success_pull_and_push(
self, mock_git: MagicMock, manager: RepoManager
) -> None:
"""Erfolgreiche bidirektionale Sync (Pull + Push)."""
mock_git.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout="1 file changed", stderr=""
)
result = manager.sync("db-wissensdatenbank")
assert result.success is True
assert result.direction == "full"
# Pull + Push → mindestens 2 Aufrufe
assert mock_git.call_count == 2
@patch("monorepo.repos.RepoManager._run_git")
def test_sync_upstream_merge_conflict_aborts(
self, mock_git: MagicMock, manager: RepoManager
) -> None:
"""Merge-Konflikt bei Upstream-Sync bricht ab."""
# Erster Call (Pull) schlägt mit Merge-Konflikt fehl
mock_git.side_effect = subprocess.CalledProcessError(
returncode=1,
cmd=["git", "subtree", "pull"],
output="CONFLICT (content): Merge conflict in src/main.py\n"
"Automatic merge failed; fix conflicts and then commit the result.",
stderr="",
)
result = manager.sync("db-wissensdatenbank")
assert result.success is False
assert len(result.conflicts) >= 1
# Merge-Abort wurde aufgerufen
assert mock_git.call_count >= 2 # pull + merge --abort
@patch("monorepo.repos.RepoManager._run_git")
def test_sync_upstream_push_failure(
self, mock_git: MagicMock, manager: RepoManager
) -> None:
"""Push-Fehler bei Upstream-Sync liefert Fehlerergebnis."""
# Erstes Call (Pull) erfolgreich, zweites (Push) schlägt fehl
success = subprocess.CompletedProcess(
args=[], returncode=0, stdout="Already up to date.\n", stderr=""
)
push_error = subprocess.CalledProcessError(
returncode=128,
cmd=["git", "subtree", "push"],
output="",
stderr="fatal: Authentication failed",
)
mock_git.side_effect = [success, push_error]
result = manager.sync("db-wissensdatenbank")
assert result.success is False
assert "fehlgeschlagen" in result.conflicts[0].details
# ---------------------------------------------------------------------------
# Merge-Konflikt-Handling
# ---------------------------------------------------------------------------
class TestMergeConflictHandling:
"""Testet das Verhalten bei Merge-Konflikten."""
def test_is_merge_conflict_detects_conflict(self, manager: RepoManager) -> None:
"""Erkennt Merge-Konflikte anhand typischer Git-Meldungen."""
error = subprocess.CalledProcessError(
returncode=1,
cmd=["git"],
output="CONFLICT (content): Merge conflict in file.py",
stderr="",
)
assert manager._is_merge_conflict(error) is True
def test_is_merge_conflict_not_a_conflict(self, manager: RepoManager) -> None:
"""Erkennt Nicht-Konflikte korrekt."""
error = subprocess.CalledProcessError(
returncode=128,
cmd=["git"],
output="",
stderr="fatal: unable to access remote",
)
assert manager._is_merge_conflict(error) is False
def test_extract_conflict_files(self, manager: RepoManager) -> None:
"""Extrahiert Dateinamen aus Konfliktausgabe."""
output = (
"CONFLICT (content): Merge conflict in src/main.py\n"
"CONFLICT (content): Merge conflict in tests/test_app.py\n"
"Automatic merge failed"
)
files = manager._extract_conflict_files(output)
assert "src/main.py" in files
assert "tests/test_app.py" in files
def test_extract_conflict_files_empty_output(self, manager: RepoManager) -> None:
"""Leere Ausgabe ergibt leere Dateiliste."""
assert manager._extract_conflict_files("") == []
# ---------------------------------------------------------------------------
# Hilfsmethoden
# ---------------------------------------------------------------------------
class TestHelperMethods:
"""Testet interne Hilfsmethoden."""
def test_count_commits_files_changed(self, manager: RepoManager) -> None:
"""Erkennt Commits anhand 'files changed' in Ausgabe."""
output = " 3 files changed, 20 insertions(+), 5 deletions(-)"
assert manager._count_commits_from_output(output) == 1
def test_count_commits_already_up_to_date(self, manager: RepoManager) -> None:
"""'Already up to date' bedeutet 0 Commits."""
output = "Already up to date.\n"
assert manager._count_commits_from_output(output) == 0
def test_count_commits_empty_output(self, manager: RepoManager) -> None:
"""Leere Ausgabe ergibt 0 Commits."""
assert manager._count_commits_from_output("") == 0
def test_find_repo_exists(self, manager: RepoManager) -> None:
"""Findet ein existierendes Repo."""
entry = manager._find_repo("symphony-spec")
assert entry is not None
assert entry.url == "https://github.com/openai/symphony"
def test_find_repo_not_found(self, manager: RepoManager) -> None:
"""Gibt None zurück wenn Repo nicht gefunden."""
assert manager._find_repo("nonexistent") is None
@@ -0,0 +1,438 @@
"""Unit-Tests für den Read-Only-Schutzmechanismus und Sync-Fehlerprotokollierung.
Testet:
- protect_readonly installiert pre-commit Hook
- Hook verhindert Commits auf geschützte Verzeichnisse
- Fehlermeldung enthält Repo-Name und Read-Only-Status
- install_all_readonly_hooks schützt alle read-only Repos
- log_sync_error protokolliert Fehler in Audit-Datei
- Bestehende Hooks werden nicht überschrieben
Validates: Requirements 4.2, 4.5, 4.9
"""
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
import yaml
from monorepo.models import RepoEntry
from monorepo.repos import RepoManager, _HOOK_END_MARKER, _HOOK_START_MARKER
@pytest.fixture
def repos_config(tmp_path: Path) -> Path:
"""Erstellt eine repos.yaml mit Read-Only- und Upstream-Repos."""
config_path = tmp_path / "config" / "repos.yaml"
config_path.parent.mkdir(parents=True)
data = {
"repos": [
{
"name": "symphony-spec",
"url": "https://github.com/openai/symphony",
"mode": "read-only",
"target": "shared/references/symphony",
"pinned": "v1.0.0",
"mechanism": "subtree",
},
{
"name": "another-readonly",
"url": "https://github.com/example/lib",
"mode": "read-only",
"target": "shared/references/lib",
"pinned": "main",
"mechanism": "subtree",
},
{
"name": "db-wissensdatenbank",
"url": "https://gitlab.example.com/db-wissen",
"mode": "upstream",
"target": "bahn/db-wissensdatenbank",
"pinned": "main",
"mechanism": "subtree",
},
]
}
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 Monorepo-Verzeichnis mit .git/hooks Struktur."""
root = tmp_path / "monorepo"
root.mkdir()
(root / ".git" / "hooks").mkdir(parents=True)
return root
@pytest.fixture
def manager(repos_config: Path, monorepo_root: Path) -> RepoManager:
"""Erstellt einen RepoManager mit Testkonfiguration."""
return RepoManager(config_path=repos_config, monorepo_root=monorepo_root)
# ---------------------------------------------------------------------------
# protect_readonly: Hook-Installation
# ---------------------------------------------------------------------------
class TestProtectReadonly:
"""Testet die Installation des Read-Only-Schutzmechanismus."""
def test_creates_pre_commit_hook(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""protect_readonly erstellt einen pre-commit Hook."""
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
assert hook_path.exists()
def test_hook_contains_protected_path(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Der Hook enthält den geschützten Pfad."""
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
assert "shared/references/symphony" in content
def test_hook_contains_error_message_template(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Der Hook enthält die Fehlermeldung mit Read-Only-Status."""
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
assert "FEHLER:" in content
assert "read-only" in content
assert "Schreibzugriff" in content
def test_hook_is_executable(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Der Hook hat Ausführungsrechte (nur relevant auf Unix-Systemen)."""
import os
import sys
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
if sys.platform != "win32":
mode = hook_path.stat().st_mode
# Prüfe Owner-Execute-Bit
assert mode & 0o100
else:
# Auf Windows existiert das Execute-Bit-Konzept nicht,
# aber die chmod-Operation soll keinen Fehler werfen.
assert hook_path.exists()
def test_hook_uses_git_diff_cached(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Der Hook verwendet 'git diff --cached --name-only'."""
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
assert "git diff --cached --name-only" in content
def test_hook_starts_with_shebang(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Der Hook beginnt mit einem Shell-Shebang."""
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
assert content.startswith("#!/bin/sh")
def test_raises_error_for_path_outside_monorepo(
self, manager: RepoManager, tmp_path: Path
) -> None:
"""ValueError wenn Pfad außerhalb des Monorepo-Roots liegt."""
outside_path = tmp_path / "outside" / "some-repo"
outside_path.mkdir(parents=True)
with pytest.raises(ValueError, match="liegt nicht innerhalb"):
manager.protect_readonly(outside_path)
# ---------------------------------------------------------------------------
# protect_readonly: Bestehenden Hook aktualisieren
# ---------------------------------------------------------------------------
class TestProtectReadonlyUpdate:
"""Testet das Aktualisieren eines bestehenden pre-commit Hooks."""
def test_appends_to_existing_hook(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Fügt Schutzabschnitt an bestehenden Hook an."""
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
hook_path.write_text("#!/bin/sh\necho 'existing hook'\n", encoding="utf-8")
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
content = hook_path.read_text(encoding="utf-8")
assert "existing hook" in content
assert "PROTECTED_PATHS" in content
def test_updates_existing_readonly_section(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Ersetzt bestehenden Read-Only-Abschnitt statt ihn zu duplizieren."""
target1 = monorepo_root / "shared" / "references" / "symphony"
target1.mkdir(parents=True)
target2 = monorepo_root / "shared" / "references" / "lib"
target2.mkdir(parents=True)
# Erster Aufruf: Symphony schützen
manager.protect_readonly(target1)
# Zweiter Aufruf: Lib schützen (erweitert den bestehenden Abschnitt)
manager.protect_readonly(target2)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
# Beide Pfade sollen im Hook sein
assert "shared/references/symphony" in content
assert "shared/references/lib" in content
# Nur ein Start-Marker (kein Duplizieren)
assert content.count(_HOOK_START_MARKER) == 1
assert content.count(_HOOK_END_MARKER) == 1
def test_does_not_duplicate_path(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Fügt denselben Pfad nicht doppelt hinzu."""
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
# Der Pfad soll nur einmal im PROTECTED_PATHS-Array vorkommen
assert content.count('"shared/references/symphony"') == 1
# ---------------------------------------------------------------------------
# install_all_readonly_hooks
# ---------------------------------------------------------------------------
class TestInstallAllReadonlyHooks:
"""Testet die Installation für alle read-only Repos."""
def test_installs_hooks_for_all_readonly_repos(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Installiert Schutz für alle read-only konfigurierten Repos."""
# Erstelle die Target-Verzeichnisse
(monorepo_root / "shared" / "references" / "symphony").mkdir(parents=True)
(monorepo_root / "shared" / "references" / "lib").mkdir(parents=True)
manager.install_all_readonly_hooks()
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
assert hook_path.exists()
content = hook_path.read_text(encoding="utf-8")
# Beide read-only Repos sollen geschützt sein
assert "shared/references/symphony" in content
assert "shared/references/lib" in content
def test_does_not_protect_upstream_repos(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Upstream-Repos werden NICHT als read-only geschützt."""
(monorepo_root / "shared" / "references" / "symphony").mkdir(parents=True)
(monorepo_root / "shared" / "references" / "lib").mkdir(parents=True)
manager.install_all_readonly_hooks()
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
# Upstream-Repo darf NICHT im Schutzbereich sein
assert "bahn/db-wissensdatenbank" not in content
def test_no_hook_when_no_readonly_repos(
self, tmp_path: Path
) -> None:
"""Kein Hook wenn keine read-only Repos konfiguriert sind."""
config_path = tmp_path / "config" / "repos.yaml"
config_path.parent.mkdir(parents=True)
data = {
"repos": [
{
"name": "upstream-only",
"url": "https://example.com/repo.git",
"mode": "upstream",
"target": "bahn/repo",
"pinned": "main",
"mechanism": "subtree",
}
]
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f)
root = tmp_path / "monorepo"
root.mkdir()
(root / ".git" / "hooks").mkdir(parents=True)
mgr = RepoManager(config_path=config_path, monorepo_root=root)
mgr.install_all_readonly_hooks()
hook_path = root / ".git" / "hooks" / "pre-commit"
assert not hook_path.exists()
# ---------------------------------------------------------------------------
# log_sync_error: Fehlerprotokollierung (Req 4.9)
# ---------------------------------------------------------------------------
class TestLogSyncError:
"""Testet die Sync-Fehlerprotokollierung."""
def test_creates_audit_directory(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Erstellt das .audit-Verzeichnis falls nicht vorhanden."""
manager.log_sync_error("test-repo", "Network timeout", "pull")
assert (monorepo_root / ".audit").exists()
def test_creates_sync_errors_log(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Erstellt die sync-errors.log Datei."""
manager.log_sync_error("test-repo", "Connection refused", "push")
log_path = monorepo_root / ".audit" / "sync-errors.log"
assert log_path.exists()
def test_log_contains_repo_name(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Log-Eintrag enthält den Repo-Namen."""
manager.log_sync_error("symphony-spec", "Auth failed", "pull")
log_path = monorepo_root / ".audit" / "sync-errors.log"
content = log_path.read_text(encoding="utf-8")
assert "repo=symphony-spec" in content
def test_log_contains_direction(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Log-Eintrag enthält die Sync-Richtung."""
manager.log_sync_error("test-repo", "Merge conflict", "full")
log_path = monorepo_root / ".audit" / "sync-errors.log"
content = log_path.read_text(encoding="utf-8")
assert "direction=full" in content
def test_log_contains_error_reason(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Log-Eintrag enthält den Fehlergrund."""
manager.log_sync_error("test-repo", "fatal: unable to access", "pull")
log_path = monorepo_root / ".audit" / "sync-errors.log"
content = log_path.read_text(encoding="utf-8")
assert "error=fatal: unable to access" in content
def test_log_contains_timestamp(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Log-Eintrag enthält einen ISO-Zeitstempel."""
manager.log_sync_error("test-repo", "Network error", "pull")
log_path = monorepo_root / ".audit" / "sync-errors.log"
content = log_path.read_text(encoding="utf-8")
# ISO-Timestamp Format: [YYYY-MM-DDTHH:MM:SS]
assert content.startswith("[20")
assert "T" in content.split("]")[0]
def test_appends_to_existing_log(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Mehrere Fehler werden untereinander protokolliert."""
manager.log_sync_error("repo-1", "Error 1", "pull")
manager.log_sync_error("repo-2", "Error 2", "push")
log_path = monorepo_root / ".audit" / "sync-errors.log"
content = log_path.read_text(encoding="utf-8")
lines = [line for line in content.strip().splitlines() if line]
assert len(lines) == 2
assert "repo-1" in lines[0]
assert "repo-2" in lines[1]
# ---------------------------------------------------------------------------
# Integration: _handle_sync_error schreibt auch in Audit-Log
# ---------------------------------------------------------------------------
class TestSyncErrorIntegration:
"""Testet dass Sync-Fehler automatisch in die Audit-Datei geschrieben werden."""
def test_sync_error_writes_to_audit_log(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Ein Netzwerkfehler bei Sync wird in sync-errors.log protokolliert."""
from unittest.mock import patch
with patch("monorepo.repos.RepoManager._run_git") as mock_git:
mock_git.side_effect = subprocess.CalledProcessError(
returncode=128,
cmd=["git", "subtree", "pull"],
output="",
stderr="fatal: unable to access 'https://...': Could not resolve host",
)
result = manager.sync("symphony-spec")
assert result.success is False
log_path = monorepo_root / ".audit" / "sync-errors.log"
assert log_path.exists()
content = log_path.read_text(encoding="utf-8")
assert "repo=symphony-spec" in content
assert "Could not resolve host" in content
@@ -0,0 +1,349 @@
"""Property-basierte Tests für Read-Only-Repos: Schreibzugriffe werden blockiert.
**Validates: Requirements 4.2, 4.5**
Property 12: Read-Only-Repos blockieren Schreibzugriffe
- For any Kombination aus einem Read-Only-Repo und einer Dateiänderung innerhalb
des geschützten Verzeichnisses, muss der Schreibzugriff blockiert werden und eine
Fehlermeldung ausgegeben werden, die den Read-Only-Status und den Namen des
betroffenen Repos benennt.
"""
from __future__ import annotations
import subprocess
import tempfile
from pathlib import Path
import yaml
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.models import RepoEntry
from monorepo.repos import RepoManager
# --- Strategies ---
# Generates valid kebab-case repo names (3-22 chars, lowercase + digits + hyphens)
repo_name_strategy = st.from_regex(r"[a-z][a-z0-9\-]{1,20}[a-z0-9]", fullmatch=True).filter(
lambda s: "--" not in s # Avoid double hyphens for cleaner names
)
# Generates context prefixes for target paths
context_strategy = st.sampled_from(["shared/references", "bahn", "dhive", "privat", "shared/tools"])
# Generates path segments (subdirectory names)
path_segment_strategy = st.from_regex(r"[a-z][a-z0-9\-]{0,12}[a-z0-9]", fullmatch=True).filter(
lambda s: "--" not in s
)
# Generates filenames that might be changed
filename_strategy = st.sampled_from([
"README.md",
"config.yaml",
"main.py",
"index.md",
"package.json",
"setup.py",
"utils.py",
"test_app.py",
".env",
"Makefile",
])
@st.composite
def readonly_repo_config(draw: st.DrawFn) -> tuple[RepoEntry, str]:
"""Generates a random read-only RepoEntry and a file path within its target.
Returns:
Tuple of (RepoEntry, file_path_within_target).
"""
name = draw(repo_name_strategy)
context = draw(context_strategy)
subdir = draw(path_segment_strategy)
target = f"{context}/{subdir}"
pinned = draw(st.sampled_from(["main", "v1.0.0", "v2.3.1", "develop"]))
mechanism = draw(st.sampled_from(["subtree", "submodule"]))
entry = RepoEntry(
name=name,
url=f"https://github.com/example/{name}.git",
mode="read-only",
target=target,
pinned=pinned,
mechanism=mechanism,
)
# Generate a file path within the protected directory
filename = draw(filename_strategy)
add_subdir = draw(st.booleans())
if add_subdir:
inner_subdir = draw(path_segment_strategy)
file_path = f"{target}/{inner_subdir}/{filename}"
else:
file_path = f"{target}/{filename}"
return entry, file_path
@st.composite
def readonly_repo_with_outside_path(draw: st.DrawFn) -> tuple[RepoEntry, str]:
"""Generates a read-only RepoEntry and a file path OUTSIDE its target.
Returns:
Tuple of (RepoEntry, file_path_outside_target).
"""
name = draw(repo_name_strategy)
context = draw(context_strategy)
subdir = draw(path_segment_strategy)
target = f"{context}/{subdir}"
entry = RepoEntry(
name=name,
url=f"https://github.com/example/{name}.git",
mode="read-only",
target=target,
pinned="main",
mechanism="subtree",
)
# Generate a file path that is definitely OUTSIDE the target
outside_prefix = draw(st.sampled_from(["other-project", "unrelated", "docs", "scripts"]))
filename = draw(filename_strategy)
outside_path = f"{outside_prefix}/{filename}"
# Make sure it doesn't accidentally match the target
assume(not outside_path.startswith(target + "/"))
assume(not outside_path.startswith(target))
return entry, outside_path
# --- Helpers ---
def _create_manager_with_repo(entry: RepoEntry) -> tuple[RepoManager, Path]:
"""Creates a RepoManager with a read-only repo and a .git/hooks directory.
Returns:
Tuple of (RepoManager instance, monorepo_root path).
"""
tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_ro_test_"))
config_dir = tmp_dir / "config"
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "repos.yaml"
data = {
"repos": [
{
"name": entry.name,
"url": entry.url,
"mode": entry.mode,
"target": entry.target,
"pinned": entry.pinned,
"mechanism": entry.mechanism,
}
]
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f)
monorepo_root = tmp_dir / "monorepo"
monorepo_root.mkdir()
# Create .git/hooks directory (simulates a git repo)
git_hooks = monorepo_root / ".git" / "hooks"
git_hooks.mkdir(parents=True, exist_ok=True)
return RepoManager(config_path=config_path, monorepo_root=monorepo_root), monorepo_root
def _get_hook_content(monorepo_root: Path) -> str:
"""Reads the generated pre-commit hook content."""
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
if hook_path.exists():
return hook_path.read_text(encoding="utf-8")
return ""
def _simulate_hook_blocking(hook_content: str, file_path: str) -> tuple[bool, str]:
"""Simulates the hook script logic to check if a file would be blocked.
Parses the PROTECTED_PATHS array from the hook script and checks
whether the given file_path matches any of the protected paths using
the same logic as the shell `case` statement (prefix matching).
Returns:
Tuple of (would_block: bool, error_message: str).
"""
if not hook_content:
return False, ""
# Extract protected paths from the PROTECTED_PATHS=(...) array
protected_paths: list[str] = []
for line in hook_content.splitlines():
if "PROTECTED_PATHS=(" in line:
# Parse the array: PROTECTED_PATHS=("path1" "path2")
inner = line.split("(", 1)[1].rstrip(")")
for token in inner.split():
cleaned = token.strip('"').strip("'")
if cleaned:
protected_paths.append(cleaned)
# Simulate the shell case statement: case "$file" in "$protected"/*)
for protected in protected_paths:
if file_path.startswith(protected + "/"):
# Build the error message that the hook would output
error_msg = (
f"FEHLER: Repo '{protected}' ist read-only. "
f"Schreibzugriff auf '{file_path}' nicht erlaubt."
)
return True, error_msg
return False, ""
# --- Tests ---
class TestProperty12ReadOnlyReposBlockWriteAccess:
"""Property 12: Read-Only-Repos blockieren Schreibzugriffe.
**Validates: Requirements 4.2, 4.5**
For any combination of a read-only repo and a file change within the
protected directory, the write access must be blocked and an error
message must be issued that names the read-only status and the repo name.
"""
@given(data=st.data())
@settings(max_examples=200)
def test_hook_blocks_writes_in_protected_directory(self, data: st.DataObject) -> None:
"""Any file change within a read-only repo's target directory is blocked.
**Validates: Requirements 4.2**
"""
entry, file_path = data.draw(readonly_repo_config())
manager, monorepo_root = _create_manager_with_repo(entry)
# Install the read-only protection hook
repo_path = monorepo_root / entry.target
manager.protect_readonly(repo_path)
# Read the generated hook
hook_content = _get_hook_content(monorepo_root)
# Simulate whether the hook would block this file
would_block, _ = _simulate_hook_blocking(hook_content, file_path)
assert would_block is True, (
f"Hook should block write to '{file_path}' in read-only repo '{entry.name}' "
f"(target: '{entry.target}'), but it did not. "
f"Hook content:\n{hook_content}"
)
@given(data=st.data())
@settings(max_examples=200)
def test_error_message_contains_protected_path(self, data: st.DataObject) -> None:
"""Error message when blocking must contain the protected path (identifies the repo).
**Validates: Requirements 4.5**
"""
entry, file_path = data.draw(readonly_repo_config())
manager, monorepo_root = _create_manager_with_repo(entry)
# Install the read-only protection hook
repo_path = monorepo_root / entry.target
manager.protect_readonly(repo_path)
# Read the generated hook
hook_content = _get_hook_content(monorepo_root)
# Simulate whether the hook would block this file
would_block, error_msg = _simulate_hook_blocking(hook_content, file_path)
assert would_block is True
# The error message must contain the protected path which identifies the repo
assert entry.target in error_msg, (
f"Error message should contain the repo target path '{entry.target}' "
f"to identify the repo, but got: {error_msg}"
)
@given(data=st.data())
@settings(max_examples=200)
def test_error_message_mentions_readonly_status(self, data: st.DataObject) -> None:
"""Error message when blocking must reference the read-only status.
**Validates: Requirements 4.5**
"""
entry, file_path = data.draw(readonly_repo_config())
manager, monorepo_root = _create_manager_with_repo(entry)
# Install the read-only protection hook
repo_path = monorepo_root / entry.target
manager.protect_readonly(repo_path)
# Read the generated hook
hook_content = _get_hook_content(monorepo_root)
# Simulate whether the hook would block this file
would_block, error_msg = _simulate_hook_blocking(hook_content, file_path)
assert would_block is True
assert "read-only" in error_msg.lower() or "read_only" in error_msg.lower(), (
f"Error message should mention read-only status, "
f"but got: {error_msg}"
)
@given(data=st.data())
@settings(max_examples=200)
def test_hook_does_not_block_writes_outside_protected_directory(
self, data: st.DataObject
) -> None:
"""Files outside the protected directory are NOT blocked.
This is the inverse property: modifications to files outside the
read-only repo's target path must be allowed through.
"""
entry, outside_path = data.draw(readonly_repo_with_outside_path())
manager, monorepo_root = _create_manager_with_repo(entry)
# Install the read-only protection hook
repo_path = monorepo_root / entry.target
manager.protect_readonly(repo_path)
# Read the generated hook
hook_content = _get_hook_content(monorepo_root)
# Simulate whether the hook would block this file
would_block, _ = _simulate_hook_blocking(hook_content, outside_path)
assert would_block is False, (
f"Hook should NOT block write to '{outside_path}' which is outside "
f"the protected directory '{entry.target}/' of repo '{entry.name}'."
)
@given(data=st.data())
@settings(max_examples=100)
def test_hook_script_is_generated_with_correct_protected_path(
self, data: st.DataObject
) -> None:
"""The generated hook script must contain the target directory in PROTECTED_PATHS."""
entry, _ = data.draw(readonly_repo_config())
manager, monorepo_root = _create_manager_with_repo(entry)
# Install the read-only protection hook
repo_path = monorepo_root / entry.target
manager.protect_readonly(repo_path)
# Read the generated hook
hook_content = _get_hook_content(monorepo_root)
# The hook script must reference the target directory
assert entry.target in hook_content, (
f"Hook script should contain protected path '{entry.target}', "
f"but it does not. Hook content:\n{hook_content}"
)
@@ -0,0 +1,462 @@
"""Property-basierte Tests für fehlgeschlagene Synchronisation.
**Validates: Requirements 4.9**
Property 13: Fehlgeschlagene Synchronisation bewahrt lokalen Stand
- For any fehlgeschlagene Synchronisation mit einem Upstream-Repository
(Netzwerkfehler, Authentifizierungsfehler, Merge-Konflikt) muss der
lokale Dateizustand identisch zum Zustand vor dem Sync-Versuch sein.
Invarianten bei fehlgeschlagener Sync:
1. SyncResult.success ist False
2. SyncResult.conflicts enthält mindestens einen Eintrag mit Fehlerdetails
3. Die Repo-Konfiguration bleibt unverändert
4. Bei Merge-Konflikten wird merge --abort aufgerufen (kein partieller Zustand)
"""
from __future__ import annotations
import copy
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, call, patch
import yaml
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.models import RepoEntry, SyncResult
from monorepo.repos import RepoManager
# --- Strategies ---
# Error types that can occur during sync
ERROR_TYPES = st.sampled_from([
"network",
"auth",
"merge_conflict",
])
# Network error messages from git
NETWORK_ERRORS = st.sampled_from([
"fatal: unable to access 'https://example.com/repo.git/': Could not resolve host",
"fatal: unable to access 'https://example.com/repo.git/': Connection timed out",
"fatal: unable to access 'https://example.com/repo.git/': Connection refused",
"fatal: Could not read from remote repository.",
])
# Auth error messages from git
AUTH_ERRORS = st.sampled_from([
"fatal: Authentication failed for 'https://example.com/repo.git/'",
"fatal: could not read Username for 'https://example.com/repo.git': terminal prompts disabled",
"remote: Invalid credentials",
])
# Merge conflict messages from git
MERGE_CONFLICT_ERRORS = st.sampled_from([
"CONFLICT (content): Merge conflict in src/main.py\nAutomatic merge failed",
"CONFLICT (content): Merge conflict in README.md\nAutomatic merge failed; fix conflicts",
"CONFLICT (modify/delete): config.yaml deleted in HEAD and modified in upstream",
])
# Repo modes
REPO_MODES = st.sampled_from(["read-only", "upstream"])
# Repo mechanisms
REPO_MECHANISMS = st.sampled_from(["subtree", "submodule"])
# Valid repo names (kebab-case)
REPO_NAMES = st.from_regex(r"[a-z][a-z0-9\-]{2,15}[a-z0-9]", fullmatch=True)
# Valid URLs
REPO_URLS = st.builds(
lambda name: f"https://example.com/{name}.git",
REPO_NAMES,
)
# Valid target paths
REPO_TARGETS = st.builds(
lambda ctx, name: f"{ctx}/{name}",
st.sampled_from(["privat", "dhive", "bahn", "shared"]),
REPO_NAMES,
)
# Pinned versions
PINNED_VERSIONS = st.sampled_from(["main", "develop", "v1.0.0", "v2.1.3", "release/1.0"])
@st.composite
def repo_entries(draw: st.DrawFn) -> RepoEntry:
"""Generates valid RepoEntry instances."""
return RepoEntry(
name=draw(REPO_NAMES),
url=draw(REPO_URLS),
mode=draw(REPO_MODES),
target=draw(REPO_TARGETS),
pinned=draw(PINNED_VERSIONS),
mechanism=draw(REPO_MECHANISMS),
)
@st.composite
def sync_error_scenarios(draw: st.DrawFn) -> tuple[str, subprocess.CalledProcessError]:
"""Generates (error_type, CalledProcessError) pairs for sync failures."""
error_type = draw(ERROR_TYPES)
if error_type == "network":
stderr = draw(NETWORK_ERRORS)
error = subprocess.CalledProcessError(
returncode=128,
cmd=["git", "subtree", "pull"],
output="",
stderr=stderr,
)
elif error_type == "auth":
stderr = draw(AUTH_ERRORS)
error = subprocess.CalledProcessError(
returncode=128,
cmd=["git", "subtree", "pull"],
output="",
stderr=stderr,
)
else: # merge_conflict
stdout = draw(MERGE_CONFLICT_ERRORS)
error = subprocess.CalledProcessError(
returncode=1,
cmd=["git", "subtree", "pull"],
output=stdout,
stderr="",
)
return error_type, error
# --- Helpers ---
def _create_repos_config(tmp_path: Path, entries: list[RepoEntry]) -> Path:
"""Creates a repos.yaml config file with the given entries."""
config_path = tmp_path / "config" / "repos.yaml"
config_path.parent.mkdir(parents=True, exist_ok=True)
data = {
"repos": [
{
"name": entry.name,
"url": entry.url,
"mode": entry.mode,
"target": entry.target,
"pinned": entry.pinned,
"mechanism": entry.mechanism,
}
for entry in entries
]
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f)
return config_path
# --- Property Tests ---
class TestProperty13FailedSyncPreservesLocalState:
"""Property 13: Fehlgeschlagene Synchronisation bewahrt lokalen Stand.
**Validates: Requirements 4.9**
For any Synchronisationsversuch der fehlschlägt (Netzwerkfehler,
Authentifizierungsfehler, Merge-Konflikt), muss der lokale Stand
unverändert bleiben, der Fehlergrund protokolliert werden und der
Nutzer über den fehlgeschlagenen Vorgang informiert werden (via SyncResult).
"""
@given(
entry=repo_entries(),
scenario=sync_error_scenarios(),
)
@settings(max_examples=200)
def test_failed_sync_returns_failure_result(
self,
entry: RepoEntry,
scenario: tuple[str, subprocess.CalledProcessError],
tmp_path_factory: object,
) -> None:
"""After any sync failure, SyncResult.success must be False."""
error_type, error = scenario
tmp_path = Path(f"{id(entry)}_{id(error)}")
# Create a temporary config with the entry
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
monorepo_root = tmp / "monorepo"
monorepo_root.mkdir()
config_path = _create_repos_config(tmp, [entry])
manager = RepoManager(config_path=config_path, monorepo_root=monorepo_root)
with patch.object(manager, "_run_git") as mock_git:
# For merge conflicts, the first call fails, then merge --abort succeeds
if error_type == "merge_conflict":
mock_git.side_effect = [
error,
subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""),
]
else:
mock_git.side_effect = error
result = manager.sync(entry.name)
assert result.success is False, (
f"SyncResult.success should be False for {error_type} error, "
f"got True for repo '{entry.name}'"
)
@given(
entry=repo_entries(),
scenario=sync_error_scenarios(),
)
@settings(max_examples=200)
def test_failed_sync_contains_error_details_in_conflicts(
self,
entry: RepoEntry,
scenario: tuple[str, subprocess.CalledProcessError],
) -> None:
"""After any sync failure, SyncResult.conflicts must contain at least one entry."""
error_type, error = scenario
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
monorepo_root = tmp / "monorepo"
monorepo_root.mkdir()
config_path = _create_repos_config(tmp, [entry])
manager = RepoManager(config_path=config_path, monorepo_root=monorepo_root)
with patch.object(manager, "_run_git") as mock_git:
if error_type == "merge_conflict":
mock_git.side_effect = [
error,
subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""),
]
else:
mock_git.side_effect = error
result = manager.sync(entry.name)
assert len(result.conflicts) >= 1, (
f"SyncResult.conflicts should contain at least one entry for {error_type} error, "
f"got empty list for repo '{entry.name}'"
)
# Each conflict must have non-empty details
for conflict in result.conflicts:
assert conflict.details, (
f"ConflictInfo.details should not be empty for {error_type} error"
)
@given(
entry=repo_entries(),
scenario=sync_error_scenarios(),
)
@settings(max_examples=200)
def test_failed_sync_preserves_repo_configuration(
self,
entry: RepoEntry,
scenario: tuple[str, subprocess.CalledProcessError],
) -> None:
"""After any sync failure, the repo configuration (repos list) must be unchanged."""
error_type, error = scenario
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
monorepo_root = tmp / "monorepo"
monorepo_root.mkdir()
config_path = _create_repos_config(tmp, [entry])
manager = RepoManager(config_path=config_path, monorepo_root=monorepo_root)
# Capture state before sync
repos_before = copy.deepcopy(manager.repos)
with patch.object(manager, "_run_git") as mock_git:
if error_type == "merge_conflict":
mock_git.side_effect = [
error,
subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""),
]
else:
mock_git.side_effect = error
manager.sync(entry.name)
# Verify repos list is identical after sync failure
assert len(manager.repos) == len(repos_before), (
f"Repo count changed after failed sync: "
f"before={len(repos_before)}, after={len(manager.repos)}"
)
for before, after in zip(repos_before, manager.repos):
assert before.name == after.name
assert before.url == after.url
assert before.mode == after.mode
assert before.target == after.target
assert before.pinned == after.pinned
assert before.mechanism == after.mechanism
@given(
entry=repo_entries(),
conflict_msg=MERGE_CONFLICT_ERRORS,
)
@settings(max_examples=150)
def test_merge_conflict_triggers_abort(
self,
entry: RepoEntry,
conflict_msg: str,
) -> None:
"""For merge conflicts, git merge --abort must be called to prevent partial state."""
# Force upstream mode so we exercise the merge-conflict code path
entry = RepoEntry(
name=entry.name,
url=entry.url,
mode="upstream",
target=entry.target,
pinned=entry.pinned,
mechanism=entry.mechanism,
)
merge_error = subprocess.CalledProcessError(
returncode=1,
cmd=["git", "subtree", "pull"],
output=conflict_msg,
stderr="",
)
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
monorepo_root = tmp / "monorepo"
monorepo_root.mkdir()
config_path = _create_repos_config(tmp, [entry])
manager = RepoManager(config_path=config_path, monorepo_root=monorepo_root)
with patch.object(manager, "_run_git") as mock_git:
# First call: subtree pull fails with merge conflict
# Second call: merge --abort succeeds
mock_git.side_effect = [
merge_error,
subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""),
]
result = manager.sync(entry.name)
# Verify merge --abort was called
abort_calls = [
c for c in mock_git.call_args_list
if c[0][0] == ["merge", "--abort"]
]
assert len(abort_calls) >= 1, (
f"git merge --abort was not called after merge conflict. "
f"Calls made: {[c[0][0] for c in mock_git.call_args_list]}"
)
# Also verify the result indicates failure
assert result.success is False
@given(
entry=repo_entries(),
scenario=sync_error_scenarios(),
)
@settings(max_examples=150)
def test_failed_sync_has_valid_timestamp(
self,
entry: RepoEntry,
scenario: tuple[str, subprocess.CalledProcessError],
) -> None:
"""SyncResult from a failed sync must have a valid timestamp."""
from datetime import datetime
error_type, error = scenario
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
monorepo_root = tmp / "monorepo"
monorepo_root.mkdir()
config_path = _create_repos_config(tmp, [entry])
manager = RepoManager(config_path=config_path, monorepo_root=monorepo_root)
before = datetime.now()
with patch.object(manager, "_run_git") as mock_git:
if error_type == "merge_conflict":
mock_git.side_effect = [
error,
subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""),
]
else:
mock_git.side_effect = error
result = manager.sync(entry.name)
after = datetime.now()
assert result.timestamp is not None, "SyncResult.timestamp should not be None"
assert before <= result.timestamp <= after, (
f"SyncResult.timestamp {result.timestamp} not between "
f"{before} and {after}"
)
@given(
entry=repo_entries(),
scenario=sync_error_scenarios(),
)
@settings(max_examples=150)
def test_failed_sync_config_file_unchanged(
self,
entry: RepoEntry,
scenario: tuple[str, subprocess.CalledProcessError],
) -> None:
"""After any sync failure, the config file on disk must be byte-for-byte identical."""
error_type, error = scenario
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
monorepo_root = tmp / "monorepo"
monorepo_root.mkdir()
config_path = _create_repos_config(tmp, [entry])
# Read the config file content before sync
config_before = config_path.read_bytes()
manager = RepoManager(config_path=config_path, monorepo_root=monorepo_root)
with patch.object(manager, "_run_git") as mock_git:
if error_type == "merge_conflict":
mock_git.side_effect = [
error,
subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""),
]
else:
mock_git.side_effect = error
manager.sync(entry.name)
# Read the config file content after sync
config_after = config_path.read_bytes()
assert config_before == config_after, (
"repos.yaml was modified after a failed sync operation. "
"The config file must remain unchanged on sync failure."
)
@@ -0,0 +1,548 @@
"""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
@@ -0,0 +1,256 @@
"""Property-basierte Tests für den ContextGuard: Kontextübergreifender Secret-Zugriff.
**Validates: Requirements 2.1, 2.3, 2.5, 2.7**
Property 3: Kontextübergreifender Secret-Zugriff wird verweigert
- For any Kombination aus anfragendem Kontext A und Zielkontext B (wobei A B und B shared),
muss der Zugriff auf .env-Dateien und Secret-Dateien von B verweigert werden, und es muss ein
Audit-Log-Eintrag mit Zeitstempel, anfragendem Kontext, Zielkontext und Ressource erzeugt werden.
"""
from __future__ import annotations
import tempfile
from datetime import datetime
from pathlib import Path
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.audit import AuditLogger
from monorepo.models import Context, SecurityEvent
from monorepo.security import ContextGuard
# --- Constants ---
# Non-shared contexts that can be requesting or target contexts
NON_SHARED_CONTEXTS = [c.value for c in Context if c != Context.SHARED]
ALL_CONTEXTS = [c.value for c in Context]
# Secret file patterns that should be protected
SECRET_FILE_NAMES = [".env", "credentials.pem", "private.key", "api-token.txt", "secret-config.yaml"]
# --- Strategies ---
@st.composite
def cross_context_pairs(draw: st.DrawFn) -> tuple[str, str]:
"""Generates pairs (requesting_context, target_context) where:
- A B
- B shared (target is NOT the shared context)
"""
context_a = draw(st.sampled_from(ALL_CONTEXTS))
context_b = draw(st.sampled_from(NON_SHARED_CONTEXTS))
assume(context_a != context_b)
return context_a, context_b
@st.composite
def secret_file_paths(draw: st.DrawFn, target_context: str) -> Path:
"""Generates secret file paths within a given target context.
Produces paths like:
- {context}/.env
- {context}/some-project/{secret_file}
"""
secret_name = draw(st.sampled_from(SECRET_FILE_NAMES))
# Either at context root or in a subdirectory
use_subdir = draw(st.booleans())
if use_subdir:
subdir = draw(st.sampled_from(["project-a", "module-x", "config", "keys"]))
return Path(target_context) / subdir / secret_name
return Path(target_context) / secret_name
# --- Helpers ---
def _create_test_environment() -> tuple[Path, ContextGuard, AuditLogger]:
"""Creates a temporary monorepo structure with ContextGuard and AuditLogger.
Returns a tuple of (tmp_dir_path, guard, audit_logger).
The caller does NOT need to clean up - Python's tempfile handles that.
"""
tmp_dir = Path(tempfile.mkdtemp(prefix="monorepo_test_"))
# Create the monorepo directory structure
for ctx in ALL_CONTEXTS:
(tmp_dir / ctx).mkdir(parents=True, exist_ok=True)
# Create access-config.yaml
config_dir = tmp_dir / "shared" / "config"
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / "access-config.yaml"
config_file.write_text(
"contexts:\n"
" privat:\n"
" env_file: privat/.env\n"
" allowed_shared:\n"
" - shared/tools/\n"
" - shared/powers/\n"
" - shared/config/\n"
" dhive:\n"
" env_file: dhive/.env\n"
" allowed_shared:\n"
" - shared/tools/\n"
" - shared/powers/\n"
" - shared/config/\n"
" bahn:\n"
" env_file: bahn/.env\n"
" allowed_shared:\n"
" - shared/tools/\n"
" - shared/powers/\n"
" - shared/config/\n"
" - shared/knowledge-store/\n"
" shared:\n"
" env_file: shared/.env\n"
' allowed_shared: ["*"]\n',
encoding="utf-8",
)
guard = ContextGuard(root_path=tmp_dir, access_config_path=config_file)
log_path = tmp_dir / ".audit" / "access.log"
audit_logger = AuditLogger(log_path=log_path)
return tmp_dir, guard, audit_logger
# --- Tests ---
class TestProperty3CrossContextSecretAccessDenied:
"""Property 3: Kontextübergreifender Secret-Zugriff wird verweigert.
**Validates: Requirements 2.1, 2.3, 2.5, 2.7**
For any combination of requesting context A and target context B
(where A B and B shared), access to .env files and secret files
of B must be denied, and an audit log entry must be generated with
timestamp, requesting context, target context, and resource.
"""
@given(data=st.data())
@settings(max_examples=200)
def test_cross_context_env_access_denied(self, data: st.DataObject) -> None:
"""Access to .env files of another non-shared context is always denied."""
context_a, context_b = data.draw(cross_context_pairs())
_, guard, _ = _create_test_environment()
# Target: the .env file of context B
target_path = Path(context_b) / ".env"
result = guard.check_access(context_a, target_path)
assert result is False, (
f"ContextGuard allowed cross-context .env access: "
f"'{context_a}' accessing '{target_path}' should be denied"
)
@given(data=st.data())
@settings(max_examples=200)
def test_cross_context_secret_file_access_denied(self, data: st.DataObject) -> None:
"""Access to secret files of another non-shared context is always denied."""
context_a, context_b = data.draw(cross_context_pairs())
_, guard, _ = _create_test_environment()
# Generate a secret file path in context B
target_path = data.draw(secret_file_paths(target_context=context_b))
result = guard.check_access(context_a, target_path)
assert result is False, (
f"ContextGuard allowed cross-context secret access: "
f"'{context_a}' accessing '{target_path}' should be denied"
)
@given(data=st.data())
@settings(max_examples=200)
def test_cross_context_access_generates_audit_log(self, data: st.DataObject) -> None:
"""When cross-context secret access is denied, an audit log entry must be
generated with timestamp, requesting_context, target_context, and resource."""
context_a, context_b = data.draw(cross_context_pairs())
_, guard, audit_logger = _create_test_environment()
# Target: .env file of context B
target_path = Path(context_b) / ".env"
# Verify access is denied
access_allowed = guard.check_access(context_a, target_path)
assert access_allowed is False
# Log the violation (as the integration layer would do on denied access)
now = datetime.now()
event = SecurityEvent(
timestamp=now,
requesting_context=context_a,
target_context=context_b,
resource=str(target_path),
action="read",
outcome="denied",
)
audit_logger.log_violation(event)
# Verify audit log entry was created with required fields
log_content = audit_logger.log_path.read_text(encoding="utf-8")
assert log_content.strip() != "", "Audit log should not be empty after violation"
# Parse the last log entry and verify required fields
last_line = log_content.strip().split("\n")[-1]
# Verify timestamp is present (ISO format in brackets)
assert "[" in last_line and "]" in last_line, (
f"Audit log entry missing timestamp brackets: {last_line}"
)
# Verify requesting context is present
assert context_a in last_line, (
f"Audit log entry missing requesting context '{context_a}': {last_line}"
)
# Verify target context is present
assert context_b in last_line, (
f"Audit log entry missing target context '{context_b}': {last_line}"
)
# Verify resource path is present
assert str(target_path) in last_line, (
f"Audit log entry missing resource '{target_path}': {last_line}"
)
@given(data=st.data())
@settings(max_examples=100)
def test_audit_log_entry_has_valid_timestamp(self, data: st.DataObject) -> None:
"""The audit log entry must contain a parseable ISO timestamp."""
context_a, context_b = data.draw(cross_context_pairs())
_, _, audit_logger = _create_test_environment()
now = datetime.now()
event = SecurityEvent(
timestamp=now,
requesting_context=context_a,
target_context=context_b,
resource=f"{context_b}/.env",
action="read",
outcome="denied",
)
audit_logger.log_violation(event)
log_content = audit_logger.log_path.read_text(encoding="utf-8")
last_line = log_content.strip().split("\n")[-1]
# Extract timestamp from brackets [timestamp]
start = last_line.index("[") + 1
end = last_line.index("]")
timestamp_str = last_line[start:end]
# Verify it's a valid ISO timestamp
parsed_ts = datetime.fromisoformat(timestamp_str)
assert parsed_ts is not None, f"Could not parse timestamp: {timestamp_str}"
# Timestamp should be very close to 'now' (within seconds)
delta = abs((parsed_ts - now).total_seconds())
assert delta < 2.0, f"Timestamp drift too large: {delta}s"
@@ -0,0 +1,322 @@
"""Property-basierte Tests für Shared-Tool-Isolation (Property 4).
**Validates: Requirements 2.7, 8.5**
Property 4: Shared-Tool-Isolation
- For any Tool aus dem shared-Bereich, das in einem bestimmten Arbeitskontext
ausgeführt wird, darf es ausschließlich auf die Secrets des aktiven Kontexts
und auf explizit freigegebene shared-Ressourcen zugreifen. Jeder andere
Zugriff muss blockiert werden.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.models import Context
from monorepo.security import ContextGuard
# --- Constants ---
# Non-shared contexts (the real work contexts)
WORK_CONTEXTS = [Context.PRIVAT.value, Context.DHIVE.value, Context.BAHN.value]
# All contexts including shared
ALL_CONTEXTS = [ctx.value for ctx in Context]
# Shared sub-paths that are allowed by default config
DEFAULT_ALLOWED_SHARED = {
"privat": ["shared/tools/", "shared/powers/", "shared/config/"],
"dhive": ["shared/tools/", "shared/powers/", "shared/config/"],
"bahn": ["shared/tools/", "shared/powers/", "shared/config/", "shared/knowledge-store/"],
}
# Shared sub-paths that are NOT allowed by any work context except bahn->knowledge-store
RESTRICTED_SHARED_PATHS = ["shared/mcp-servers/"]
# --- Helpers ---
def create_test_monorepo(tmp_dir: Path) -> Path:
"""Creates a minimal monorepo structure with access-config.yaml."""
root = tmp_dir / "monorepo-root"
root.mkdir(parents=True, exist_ok=True)
# Create context directories
for ctx in ALL_CONTEXTS:
(root / ctx).mkdir(exist_ok=True)
# Create shared sub-directories
for subdir in ["tools", "powers", "config", "knowledge-store", "mcp-servers"]:
(root / "shared" / subdir).mkdir(parents=True, exist_ok=True)
# Create .env files per context
for ctx in WORK_CONTEXTS:
env_file = root / ctx / ".env"
env_file.write_text(f"SECRET_{ctx.upper()}=value-{ctx}\nAPI_KEY_{ctx.upper()}=key-{ctx}\n")
shared_env = root / "shared" / ".env"
shared_env.write_text("SHARED_SECRET=shared-value\n")
# Create access-config.yaml
config_dir = root / "shared" / "config"
config_dir.mkdir(parents=True, exist_ok=True)
access_config = config_dir / "access-config.yaml"
access_config.write_text(
"contexts:\n"
" privat:\n"
" env_file: privat/.env\n"
" allowed_shared:\n"
" - shared/tools/\n"
" - shared/powers/\n"
" - shared/config/\n"
" dhive:\n"
" env_file: dhive/.env\n"
" allowed_shared:\n"
" - shared/tools/\n"
" - shared/powers/\n"
" - shared/config/\n"
" bahn:\n"
" env_file: bahn/.env\n"
" allowed_shared:\n"
" - shared/tools/\n"
" - shared/powers/\n"
" - shared/config/\n"
" - shared/knowledge-store/\n"
" shared:\n"
" env_file: shared/.env\n"
' allowed_shared: ["*"]\n'
)
return root
# --- Strategies ---
@st.composite
def shared_tool_path(draw: st.DrawFn) -> str:
"""Generates a path to a tool in the shared/tools/ directory."""
tool_name = draw(st.from_regex(r"[a-z][a-z0-9\-]{1,20}", fullmatch=True))
has_subpath = draw(st.booleans())
if has_subpath:
filename = draw(st.from_regex(r"[a-z][a-z0-9_]{0,10}\.(py|sh|yaml)", fullmatch=True))
return f"shared/tools/{tool_name}/{filename}"
return f"shared/tools/{tool_name}"
@st.composite
def active_context(draw: st.DrawFn) -> str:
"""Generates an active work context (not shared, since tools execute IN a work context)."""
return draw(st.sampled_from(WORK_CONTEXTS))
@st.composite
def other_context(draw: st.DrawFn, exclude: str) -> str:
"""Generates a work context different from the excluded one."""
others = [ctx for ctx in WORK_CONTEXTS if ctx != exclude]
return draw(st.sampled_from(others))
@st.composite
def secret_path_for_context(draw: st.DrawFn, context: str) -> str:
"""Generates a secret file path within a given context."""
filename = draw(st.sampled_from([".env", "secrets.key", "token.pem", "api-secret.yaml"]))
has_subdir = draw(st.booleans())
if has_subdir:
subdir = draw(st.from_regex(r"[a-z][a-z0-9\-]{1,10}", fullmatch=True))
return f"{context}/{subdir}/{filename}"
return f"{context}/{filename}"
@st.composite
def allowed_shared_resource_for_context(draw: st.DrawFn, context: str) -> str:
"""Generates a shared resource path that IS allowed for the given context."""
allowed_paths = DEFAULT_ALLOWED_SHARED[context]
base = draw(st.sampled_from(allowed_paths))
filename = draw(st.from_regex(r"[a-z][a-z0-9_]{0,10}\.(py|yaml|md|sh)", fullmatch=True))
subdir = draw(st.from_regex(r"[a-z][a-z0-9\-]{1,10}", fullmatch=True))
use_subdir = draw(st.booleans())
if use_subdir:
return f"{base}{subdir}/{filename}"
return f"{base}{filename}"
@st.composite
def non_allowed_shared_resource(draw: st.DrawFn, context: str) -> str:
"""Generates a shared resource path that is NOT allowed for the given context."""
# mcp-servers is not in the allowed_shared list for any work context
filename = draw(st.from_regex(r"[a-z][a-z0-9_]{0,10}\.(py|yaml|json)", fullmatch=True))
subdir = draw(st.from_regex(r"[a-z][a-z0-9\-]{1,10}", fullmatch=True))
# For privat and dhive, shared/knowledge-store/ is also not allowed
if context in ("privat", "dhive"):
restricted = draw(st.sampled_from(["shared/mcp-servers/", "shared/knowledge-store/"]))
else:
# For bahn, only mcp-servers is restricted
restricted = "shared/mcp-servers/"
return f"{restricted}{subdir}/{filename}"
# --- Property Tests ---
class TestProperty4SharedToolIsolation:
"""Property 4: Shared-Tool-Isolation.
**Validates: Requirements 2.7, 8.5**
For any tool from the shared area that is executed in a specific work context,
it may only access the secrets of the active context and explicitly released
shared resources. Any other access must be blocked.
"""
@given(ctx=st.sampled_from(WORK_CONTEXTS), tool=shared_tool_path())
@settings(max_examples=200)
def test_shared_tool_can_access_own_context_secrets(self, ctx: str, tool: str) -> None:
"""A shared tool executing in context X can access X's secrets (.env)."""
with tempfile.TemporaryDirectory() as tmp:
root = create_test_monorepo(Path(tmp))
guard = ContextGuard(root_path=root)
# The tool is accessed from the active context - checking access to own .env
own_env_path = Path(f"{ctx}/.env")
assert guard.check_access(ctx, own_env_path) is True, (
f"Shared tool in context '{ctx}' was denied access to own secrets: {own_env_path}"
)
@given(
data=st.data(),
ctx=st.sampled_from(WORK_CONTEXTS),
)
@settings(max_examples=200)
def test_shared_tool_can_access_allowed_shared_resources(
self, data: st.DataObject, ctx: str
) -> None:
"""A shared tool executing in context X can access explicitly allowed shared resources."""
with tempfile.TemporaryDirectory() as tmp:
root = create_test_monorepo(Path(tmp))
guard = ContextGuard(root_path=root)
# Generate an allowed shared resource for this context
allowed_path_str = data.draw(allowed_shared_resource_for_context(ctx))
allowed_path = Path(allowed_path_str)
assert guard.check_access(ctx, allowed_path) is True, (
f"Shared tool in context '{ctx}' was denied access to allowed shared "
f"resource: {allowed_path_str}"
)
@given(
data=st.data(),
ctx=st.sampled_from(WORK_CONTEXTS),
)
@settings(max_examples=200)
def test_shared_tool_cannot_access_other_context_secrets(
self, data: st.DataObject, ctx: str
) -> None:
"""A shared tool executing in context X CANNOT access secrets of other contexts (Y, Z)."""
with tempfile.TemporaryDirectory() as tmp:
root = create_test_monorepo(Path(tmp))
guard = ContextGuard(root_path=root)
# Pick a different context
other_ctx = data.draw(other_context(exclude=ctx))
# Generate a secret path in that other context
other_secret = data.draw(secret_path_for_context(other_ctx))
other_secret_path = Path(other_secret)
assert guard.check_access(ctx, other_secret_path) is False, (
f"Shared tool in context '{ctx}' was ALLOWED access to other context "
f"'{other_ctx}' secrets: {other_secret}"
)
@given(
data=st.data(),
ctx=st.sampled_from(WORK_CONTEXTS),
)
@settings(max_examples=200)
def test_shared_tool_cannot_access_non_allowed_shared_resources(
self, data: st.DataObject, ctx: str
) -> None:
"""A shared tool executing in context X CANNOT access non-explicitly-shared resources."""
with tempfile.TemporaryDirectory() as tmp:
root = create_test_monorepo(Path(tmp))
guard = ContextGuard(root_path=root)
# Generate a non-allowed shared resource for this context
restricted_path_str = data.draw(non_allowed_shared_resource(ctx))
restricted_path = Path(restricted_path_str)
assert guard.check_access(ctx, restricted_path) is False, (
f"Shared tool in context '{ctx}' was ALLOWED access to non-explicitly-shared "
f"resource: {restricted_path_str}"
)
@given(
data=st.data(),
ctx=st.sampled_from(WORK_CONTEXTS),
)
@settings(max_examples=300)
def test_shared_tool_isolation_comprehensive(
self, data: st.DataObject, ctx: str
) -> None:
"""Comprehensive property: a shared tool in context X can ONLY access:
1. Secrets/files of context X itself
2. Explicitly allowed shared resources
Any path not matching these two categories must be blocked.
"""
with tempfile.TemporaryDirectory() as tmp:
root = create_test_monorepo(Path(tmp))
guard = ContextGuard(root_path=root)
# Choose a random target from all possible paths
target_type = data.draw(st.sampled_from([
"own_context",
"other_context",
"allowed_shared",
"non_allowed_shared",
]))
if target_type == "own_context":
# Accessing own context -> allowed
filename = data.draw(
st.from_regex(r"[a-z][a-z0-9_]{0,10}\.(env|py|yaml)", fullmatch=True)
)
target = Path(f"{ctx}/{filename}")
expected = True
elif target_type == "other_context":
# Accessing other context -> blocked
other_ctx = data.draw(other_context(exclude=ctx))
filename = data.draw(
st.from_regex(r"[a-z][a-z0-9_]{0,10}\.(env|py|key)", fullmatch=True)
)
target = Path(f"{other_ctx}/{filename}")
expected = False
elif target_type == "allowed_shared":
# Accessing allowed shared resource -> allowed
target_str = data.draw(allowed_shared_resource_for_context(ctx))
target = Path(target_str)
expected = True
else: # non_allowed_shared
# Accessing non-allowed shared resource -> blocked
target_str = data.draw(non_allowed_shared_resource(ctx))
target = Path(target_str)
expected = False
result = guard.check_access(ctx, target)
assert result == expected, (
f"Shared tool isolation violated! Context '{ctx}' accessing "
f"'{target}' (type={target_type}): got {result}, expected {expected}"
)
@@ -0,0 +1,574 @@
"""Unit-Tests für ConfigMerger (Shared Tooling und MCP-Konfiguration).
Testet:
- MCP-Konfiguration-Merge mit Kontext-Vorrang (Req 8.4, 8.6)
- Konflikt-Protokollierung bei Überschreibungen
- Shared-Tool-Versionierung (Req 8.2)
- Agent-Erweiterungen: Listing und harness-agnostisches Format (Req 8.3, 8.8)
- Adapter-Mechanismus für verschiedene Harnesses (Req 8.9)
- Provisionierung eines Kontexts
Requirements: 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8, 8.9
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
import yaml
from monorepo.shared_config import (
AgentExtension,
ConfigMerger,
HarnessAdapter,
MCPServerConfig,
MergeConflict,
MergedMCPConfig,
SharedToolStatus,
ToolReference,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def monorepo_root(tmp_path: Path) -> Path:
"""Erstellt eine Monorepo-Basisstruktur für Tests."""
# Shared-Bereich
(tmp_path / "shared" / "mcp-servers").mkdir(parents=True)
(tmp_path / "shared" / "tools").mkdir(parents=True)
(tmp_path / "shared" / "powers").mkdir(parents=True)
(tmp_path / "shared" / "config").mkdir(parents=True)
# Kontexte
for ctx in ("privat", "dhive", "bahn"):
(tmp_path / ctx).mkdir()
return tmp_path
@pytest.fixture
def merger(monorepo_root: Path) -> ConfigMerger:
"""Erstellt einen ConfigMerger mit der Test-Monorepo-Struktur."""
return ConfigMerger(monorepo_root)
# ---------------------------------------------------------------------------
# MCP-Konfiguration-Merge Tests
# ---------------------------------------------------------------------------
class TestMCPConfigMerge:
"""Tests für MCP-Server-Konfiguration-Merge (Req 8.4, 8.6)."""
def test_merge_with_only_shared_config(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Shared-Konfiguration wird vollständig übernommen wenn kein Override."""
shared_config = {
"servers": {
"db-context": {
"command": "npx",
"args": ["-y", "@db/mcp-server"],
"env": {"DB_TOKEN": "shared-token"},
}
}
}
config_file = monorepo_root / "shared" / "mcp-servers" / "mcp-servers.yaml"
config_file.write_text(yaml.dump(shared_config), encoding="utf-8")
result = merger.merge_mcp_config("dhive")
assert "db-context" in result.servers
assert result.servers["db-context"].command == "npx"
assert result.servers["db-context"].args == ["-y", "@db/mcp-server"]
assert result.conflicts == []
def test_context_override_takes_precedence(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Kontextspezifische Konfiguration überschreibt shared (Req 8.4)."""
# Shared config
shared_config = {
"servers": {
"db-context": {
"command": "npx",
"args": ["-y", "@db/mcp-server"],
"env": {"DB_TOKEN": "shared-token"},
}
}
}
config_file = monorepo_root / "shared" / "mcp-servers" / "mcp-servers.yaml"
config_file.write_text(yaml.dump(shared_config), encoding="utf-8")
# Context override
ctx_dir = monorepo_root / "dhive" / ".mcp-servers"
ctx_dir.mkdir()
ctx_config = {
"servers": {
"db-context": {
"command": "node",
"args": ["./custom-server.js"],
"env": {"DB_TOKEN": "dhive-token"},
}
}
}
(ctx_dir / "mcp-servers.yaml").write_text(
yaml.dump(ctx_config), encoding="utf-8"
)
result = merger.merge_mcp_config("dhive")
# Context values must win
assert result.servers["db-context"].command == "node"
assert result.servers["db-context"].args == ["./custom-server.js"]
assert result.servers["db-context"].env["DB_TOKEN"] == "dhive-token"
def test_conflicts_are_logged(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Konflikte werden protokolliert (Req 8.6)."""
shared_config = {
"servers": {
"my-server": {
"command": "npx",
"env": {"API_KEY": "shared-key"},
}
}
}
(monorepo_root / "shared" / "mcp-servers" / "mcp-servers.yaml").write_text(
yaml.dump(shared_config), encoding="utf-8"
)
ctx_dir = monorepo_root / "bahn" / ".mcp-servers"
ctx_dir.mkdir()
ctx_config = {
"servers": {
"my-server": {
"command": "python",
"env": {"API_KEY": "bahn-key"},
}
}
}
(ctx_dir / "mcp-servers.yaml").write_text(
yaml.dump(ctx_config), encoding="utf-8"
)
result = merger.merge_mcp_config("bahn")
assert len(result.conflicts) >= 2 # command + env.API_KEY
conflict_keys = [c.key for c in result.conflicts]
assert "command" in conflict_keys
assert "env.API_KEY" in conflict_keys
# Check conflict details
cmd_conflict = next(c for c in result.conflicts if c.key == "command")
assert cmd_conflict.shared_value == "npx"
assert cmd_conflict.context_value == "python"
assert cmd_conflict.resolution == "context-wins"
assert cmd_conflict.context == "bahn"
def test_context_only_servers_are_added(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Server die nur im Kontext existieren werden hinzugefügt."""
shared_config = {"servers": {"shared-only": {"command": "npx"}}}
(monorepo_root / "shared" / "mcp-servers" / "mcp-servers.yaml").write_text(
yaml.dump(shared_config), encoding="utf-8"
)
ctx_dir = monorepo_root / "privat" / ".mcp-servers"
ctx_dir.mkdir()
ctx_config = {"servers": {"ctx-only": {"command": "python"}}}
(ctx_dir / "mcp-servers.yaml").write_text(
yaml.dump(ctx_config), encoding="utf-8"
)
result = merger.merge_mcp_config("privat")
assert "shared-only" in result.servers
assert "ctx-only" in result.servers
assert result.servers["ctx-only"].command == "python"
def test_no_config_returns_empty(self, merger: ConfigMerger) -> None:
"""Keine Konfigurationsdateien → leeres Ergebnis."""
result = merger.merge_mcp_config("dhive")
assert result.servers == {}
assert result.conflicts == []
def test_env_merge_combines_keys(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Env-Variablen aus shared und Kontext werden kombiniert."""
shared_config = {
"servers": {
"s1": {"command": "x", "env": {"A": "1", "B": "2"}}
}
}
(monorepo_root / "shared" / "mcp-servers" / "mcp-servers.yaml").write_text(
yaml.dump(shared_config), encoding="utf-8"
)
ctx_dir = monorepo_root / "dhive" / ".mcp-servers"
ctx_dir.mkdir()
ctx_config = {
"servers": {
"s1": {"command": "x", "env": {"B": "override", "C": "3"}}
}
}
(ctx_dir / "mcp-servers.yaml").write_text(
yaml.dump(ctx_config), encoding="utf-8"
)
result = merger.merge_mcp_config("dhive")
env = result.servers["s1"].env
assert env["A"] == "1" # from shared
assert env["B"] == "override" # overridden by context
assert env["C"] == "3" # only in context
# ---------------------------------------------------------------------------
# Shared-Tool-Versionierung Tests
# ---------------------------------------------------------------------------
class TestSharedToolVersioning:
"""Tests für Shared-Tool-Versionierung (Req 8.2)."""
def test_ensure_tool_links_creates_symlinks(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Tool-Symlinks werden korrekt erstellt."""
# Create a fake shared tool
tool_dir = monorepo_root / "shared" / "tools" / "my-tool"
tool_dir.mkdir()
(tool_dir / "VERSION").write_text("1.2.3", encoding="utf-8")
refs = merger.ensure_tool_links("dhive")
assert len(refs) == 1
assert refs[0].name == "my-tool"
assert refs[0].version == "1.2.3"
# Verify the link/reference exists
link_path = monorepo_root / "dhive" / ".tools" / "my-tool"
assert link_path.exists() or (
monorepo_root / "dhive" / ".tools" / "my-tool.ref.yaml"
).exists()
def test_tool_version_from_pyproject(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Version wird aus pyproject.toml gelesen."""
tool_dir = monorepo_root / "shared" / "tools" / "cli-tool"
tool_dir.mkdir()
pyproject_content = '[project]\nname = "cli-tool"\nversion = "0.5.0"\n'
(tool_dir / "pyproject.toml").write_text(pyproject_content, encoding="utf-8")
refs = merger.ensure_tool_links("bahn")
assert refs[0].version == "0.5.0"
def test_check_tool_status_reports_missing(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Status-Check erkennt fehlende Tool-Links."""
tool_dir = monorepo_root / "shared" / "tools" / "some-tool"
tool_dir.mkdir()
(tool_dir / "VERSION").write_text("2.0.0", encoding="utf-8")
statuses = merger.check_tool_status("privat")
assert len(statuses) == 1
assert statuses[0].tool_name == "some-tool"
assert statuses[0].is_current is False
assert statuses[0].local_version == "missing"
def test_ensure_tool_links_is_idempotent(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Wiederholter Aufruf verändert nichts (Links schon aktuell)."""
tool_dir = monorepo_root / "shared" / "tools" / "my-tool"
tool_dir.mkdir()
(tool_dir / "VERSION").write_text("1.0.0", encoding="utf-8")
refs1 = merger.ensure_tool_links("dhive")
refs2 = merger.ensure_tool_links("dhive")
assert len(refs1) == len(refs2)
assert refs1[0].name == refs2[0].name
def test_no_tools_returns_empty(self, merger: ConfigMerger) -> None:
"""Keine Shared-Tools → leere Liste."""
refs = merger.ensure_tool_links("privat")
assert refs == []
def test_hidden_dirs_are_skipped(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Versteckte Verzeichnisse (mit Punkt) werden übersprungen."""
(monorepo_root / "shared" / "tools" / ".hidden").mkdir()
(monorepo_root / "shared" / "tools" / "visible").mkdir()
refs = merger.ensure_tool_links("bahn")
names = [r.name for r in refs]
assert ".hidden" not in names
assert "visible" in names
# ---------------------------------------------------------------------------
# Agent-Erweiterungen Tests
# ---------------------------------------------------------------------------
class TestAgentExtensions:
"""Tests für Agent-Erweiterungen (Req 8.3, 8.8, 8.9)."""
def test_list_powers_from_shared(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Powers aus shared/powers/ werden korrekt gelistet."""
power_dir = monorepo_root / "shared" / "powers" / "db-dxp-platform"
power_dir.mkdir()
(power_dir / "POWER.md").write_text(
"# DB DXP Platform\nHelps with platform stuff.", encoding="utf-8"
)
extensions = merger.list_extensions()
assert len(extensions) == 1
assert extensions[0].name == "db-dxp-platform"
assert extensions[0].type == "power"
assert extensions[0].description == "DB DXP Platform"
def test_list_extensions_with_metadata(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Extension mit extension.yaml Metadaten werden korrekt geladen."""
power_dir = monorepo_root / "shared" / "powers" / "my-power"
power_dir.mkdir()
meta = {
"name": "My Custom Power",
"type": "power",
"description": "A custom power for testing",
"tags": ["testing", "custom"],
}
(power_dir / "extension.yaml").write_text(
yaml.dump(meta), encoding="utf-8"
)
extensions = merger.list_extensions()
assert len(extensions) == 1
assert extensions[0].name == "My Custom Power"
assert extensions[0].description == "A custom power for testing"
assert extensions[0].tags == ["testing", "custom"]
def test_filter_by_type(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Filter nach Extension-Typ funktioniert."""
(monorepo_root / "shared" / "powers" / "power-1").mkdir()
ext_dir = monorepo_root / "shared" / "config" / "extensions"
ext_dir.mkdir()
(ext_dir / "steering.md").write_text("# Steering", encoding="utf-8")
powers = merger.list_extensions(ext_type="power")
steering = merger.list_extensions(ext_type="steering")
assert all(e.type == "power" for e in powers)
assert all(e.type == "steering" for e in steering)
def test_get_extensions_for_context_includes_shared(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Kontext erhält shared-Extensions als Read-Only-Referenz (Req 8.3)."""
power_dir = monorepo_root / "shared" / "powers" / "shared-power"
power_dir.mkdir()
(power_dir / "POWER.md").write_text("# Shared Power", encoding="utf-8")
extensions = merger.get_extensions_for_context("dhive")
assert any(e.name == "shared-power" for e in extensions)
def test_get_extensions_includes_context_specific(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Kontextspezifische Erweiterungen werden zusätzlich geladen."""
# Shared
(monorepo_root / "shared" / "powers" / "shared-power").mkdir()
# Context-specific
ctx_ext = monorepo_root / "dhive" / ".extensions"
ctx_ext.mkdir()
(ctx_ext / "local-skill").mkdir()
(ctx_ext / "local-skill" / "README.md").write_text(
"# Local Skill", encoding="utf-8"
)
extensions = merger.get_extensions_for_context("dhive")
names = [e.name for e in extensions]
assert "shared-power" in names
assert "local-skill" in names
# ---------------------------------------------------------------------------
# Harness-Adapter Tests
# ---------------------------------------------------------------------------
class TestHarnessAdapter:
"""Tests für den Adapter-Mechanismus (Req 8.7, 8.9)."""
def test_load_adapters_from_config(self, monorepo_root: Path) -> None:
"""Adapter werden aus harness-adapters.yaml geladen."""
adapters_config = {
"adapters": {
"kiro": {
"config_format": "kiro-powers",
"output_path": ".kiro/powers",
},
"codex": {
"config_format": "codex-agent",
"output_path": ".codex/agents",
},
}
}
(monorepo_root / "shared" / "config" / "harness-adapters.yaml").write_text(
yaml.dump(adapters_config), encoding="utf-8"
)
merger = ConfigMerger(monorepo_root)
adapters = merger.list_adapters()
assert len(adapters) == 2
names = [a.harness_name for a in adapters]
assert "kiro" in names
assert "codex" in names
def test_get_adapter_returns_none_for_unknown(
self, merger: ConfigMerger
) -> None:
"""Unbekannter Harness → None."""
assert merger.get_adapter("unknown-harness") is None
def test_transform_extension_for_codex(
self, monorepo_root: Path
) -> None:
"""Transformation für Codex erzeugt AGENTS.md Referenz."""
adapters_config = {
"adapters": {
"codex": {"config_format": "codex-agent"},
}
}
(monorepo_root / "shared" / "config" / "harness-adapters.yaml").write_text(
yaml.dump(adapters_config), encoding="utf-8"
)
# Create a power to transform
power_dir = monorepo_root / "shared" / "powers" / "test-power"
power_dir.mkdir()
(power_dir / "POWER.md").write_text("# Test Power\nDoes stuff.", encoding="utf-8")
merger = ConfigMerger(monorepo_root)
ext = AgentExtension(
name="test-power",
type="power",
path=power_dir,
description="Test power for testing",
)
result = merger.transform_extension_for_harness(ext, "codex")
assert result is not None
assert result.exists()
content = result.read_text(encoding="utf-8")
assert "test-power" in content
def test_transform_returns_none_without_adapter(
self, merger: ConfigMerger, monorepo_root: Path
) -> None:
"""Transformation ohne konfigurierten Adapter → None."""
ext = AgentExtension(
name="test",
type="power",
path=monorepo_root / "shared" / "powers",
)
result = merger.transform_extension_for_harness(ext, "nonexistent")
assert result is None
# ---------------------------------------------------------------------------
# Harness-Konfiguration pro Kontext Tests
# ---------------------------------------------------------------------------
class TestHarnessConfig:
"""Tests für Harness-Konfiguration pro Kontext (Req 8.7)."""
def test_get_harness_config_returns_context_config(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""Harness-Konfiguration pro Kontext wird korrekt zurückgegeben."""
config = {
"contexts": {
"dhive": {
"harness": "codex",
"config": {"model": "o3-pro"},
},
"bahn": {
"harness": "kiro",
"config": {"powers_path": "shared/powers"},
},
}
}
(monorepo_root / "shared" / "config" / "harness-config.yaml").write_text(
yaml.dump(config), encoding="utf-8"
)
dhive_config = merger.get_harness_config("dhive")
assert dhive_config["harness"] == "codex"
bahn_config = merger.get_harness_config("bahn")
assert bahn_config["harness"] == "kiro"
def test_get_harness_config_returns_empty_for_unknown(
self, merger: ConfigMerger
) -> None:
"""Unbekannter Kontext → leeres Dict."""
assert merger.get_harness_config("unknown") == {}
# ---------------------------------------------------------------------------
# Provisionierung Tests
# ---------------------------------------------------------------------------
class TestProvisionContext:
"""Tests für die Kontext-Provisionierung."""
def test_provision_context_integrates_all(
self, monorepo_root: Path, merger: ConfigMerger
) -> None:
"""provision_context liefert Tools, MCP-Config und Extensions."""
# Setup shared tool
tool_dir = monorepo_root / "shared" / "tools" / "test-tool"
tool_dir.mkdir()
(tool_dir / "VERSION").write_text("1.0.0", encoding="utf-8")
# Setup shared MCP config
mcp_config = {"servers": {"s1": {"command": "npx"}}}
(monorepo_root / "shared" / "mcp-servers" / "mcp-servers.yaml").write_text(
yaml.dump(mcp_config), encoding="utf-8"
)
# Setup shared power
(monorepo_root / "shared" / "powers" / "test-power").mkdir()
result = merger.provision_context("dhive")
assert result["context"] == "dhive"
assert len(result["tools"]) == 1
assert result["tools"][0]["name"] == "test-tool"
assert "s1" in result["mcp_config"]["servers"]
assert any(e["name"] == "test-power" for e in result["extensions"])
@@ -0,0 +1,267 @@
"""Property-basierte Tests für Shared-Tool-Versionierung (Property 22).
**Validates: Requirements 8.2**
Property 22: Shared-Tool-Versionierung ohne manuelle Synchronisation
- For any Aktualisierung eines Tools im shared-Bereich müssen alle Kontexte
bei ihrer nächsten Ausführung die aktualisierte Version verwenden, ohne
manuelle Schritte in einzelnen Kontexten.
Getestete Eigenschaften:
1. Für ein beliebiges Tool in shared/tools/ muss ensure_tool_links eine Referenz
erstellen, die auf die aktuelle shared-Version zeigt.
2. Bei einer Versionsänderung muss re-calling ensure_tool_links den Link
auf die neue Version aktualisieren.
3. Die Tool-Referenz muss immer zum tatsächlichen shared-Tool-Pfad auflösen.
4. Hidden directories (mit .) dürfen nie in Tool-Links erscheinen.
5. ensure_tool_links muss idempotent sein: zweimal aufrufen ergibt das gleiche Ergebnis.
"""
from __future__ import annotations
from pathlib import Path
import yaml
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from monorepo.shared_config import ConfigMerger, ToolReference
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
# Tool names: valid kebab-case-style identifiers (no dots at start)
_TOOL_NAME_CHARS = st.sampled_from(
"abcdefghijklmnopqrstuvwxyz0123456789-"
)
tool_names = st.text(
alphabet="abcdefghijklmnopqrstuvwxyz0123456789-",
min_size=2,
max_size=30,
).filter(
lambda s: not s.startswith("-")
and not s.endswith("-")
and not s.startswith(".")
and "--" not in s
)
# Version strings: semver-like or arbitrary version identifiers
version_strings = st.from_regex(r"[0-9]+\.[0-9]+\.[0-9]+", fullmatch=True)
# Contexts
contexts = st.sampled_from(["privat", "dhive", "bahn"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_monorepo_with_tool(
tmp_path: Path, tool_name: str, version: str
) -> tuple[Path, ConfigMerger]:
"""Creates a monorepo structure with a single shared tool."""
root = tmp_path
(root / "shared" / "tools").mkdir(parents=True, exist_ok=True)
(root / "shared" / "mcp-servers").mkdir(parents=True, exist_ok=True)
(root / "shared" / "powers").mkdir(parents=True, exist_ok=True)
(root / "shared" / "config").mkdir(parents=True, exist_ok=True)
for ctx in ("privat", "dhive", "bahn"):
(root / ctx).mkdir(exist_ok=True)
tool_dir = root / "shared" / "tools" / tool_name
tool_dir.mkdir(parents=True, exist_ok=True)
(tool_dir / "VERSION").write_text(version, encoding="utf-8")
merger = ConfigMerger(root)
return root, merger
def _tool_ref_resolves_to_shared(root: Path, context: str, tool_name: str) -> bool:
"""Checks whether the tool reference in the context resolves to the shared tool."""
context_tools_path = root / context / ".tools"
link_path = context_tools_path / tool_name
shared_tool_path = root / "shared" / "tools" / tool_name
# Case 1: symlink
if link_path.is_symlink():
try:
return link_path.resolve() == shared_tool_path.resolve()
except OSError:
return False
# Case 2: import reference YAML
ref_file = context_tools_path / f"{tool_name}.ref.yaml"
if ref_file.exists():
try:
with open(ref_file, encoding="utf-8") as f:
data = yaml.safe_load(f)
return data.get("shared_path") == str(shared_tool_path)
except (yaml.YAMLError, OSError):
return False
return False
# ---------------------------------------------------------------------------
# Property Tests
# ---------------------------------------------------------------------------
class TestSharedToolVersioningProperty:
"""Property 22: Shared-Tool-Versionierung ohne manuelle Synchronisation.
**Validates: Requirements 8.2**
"""
@settings(max_examples=50)
@given(
tool_name=tool_names,
version=version_strings,
context=contexts,
)
def test_ensure_tool_links_creates_reference_to_current_version(
self, tool_name: str, version: str, context: str, tmp_path_factory
) -> None:
"""For any tool in shared/tools/, ensure_tool_links creates a reference
pointing to the current shared version.
**Validates: Requirements 8.2**
"""
tmp_path = tmp_path_factory.mktemp("monorepo")
root, merger = _create_monorepo_with_tool(tmp_path, tool_name, version)
refs = merger.ensure_tool_links(context)
# Must produce exactly one reference for our tool
assert len(refs) >= 1
tool_ref = next((r for r in refs if r.name == tool_name), None)
assert tool_ref is not None, f"Expected tool reference for '{tool_name}'"
assert tool_ref.version == version
assert tool_ref.shared_path == root / "shared" / "tools" / tool_name
@settings(max_examples=50)
@given(
tool_name=tool_names,
version_old=version_strings,
version_new=version_strings,
context=contexts,
)
def test_version_update_propagates_without_manual_sync(
self, tool_name: str, version_old: str, version_new: str, context: str, tmp_path_factory
) -> None:
"""For any version update in the shared tool, re-calling ensure_tool_links
must update the link to the new version.
**Validates: Requirements 8.2**
"""
assume(version_old != version_new)
tmp_path = tmp_path_factory.mktemp("monorepo")
root, merger = _create_monorepo_with_tool(tmp_path, tool_name, version_old)
# Initial link creation
merger.ensure_tool_links(context)
# Simulate version update in shared tool
version_file = root / "shared" / "tools" / tool_name / "VERSION"
version_file.write_text(version_new, encoding="utf-8")
# Re-create merger to pick up new version (simulates next execution)
merger2 = ConfigMerger(root)
refs = merger2.ensure_tool_links(context)
# The reference must now point to the new version
tool_ref = next((r for r in refs if r.name == tool_name), None)
assert tool_ref is not None
assert tool_ref.version == version_new
@settings(max_examples=50)
@given(
tool_name=tool_names,
version=version_strings,
context=contexts,
)
def test_tool_reference_resolves_to_shared_path(
self, tool_name: str, version: str, context: str, tmp_path_factory
) -> None:
"""The tool reference (symlink or import ref) must always resolve to the
actual shared tool path.
**Validates: Requirements 8.2**
"""
tmp_path = tmp_path_factory.mktemp("monorepo")
root, merger = _create_monorepo_with_tool(tmp_path, tool_name, version)
merger.ensure_tool_links(context)
assert _tool_ref_resolves_to_shared(root, context, tool_name), (
f"Tool reference for '{tool_name}' in context '{context}' "
f"does not resolve to shared/tools/{tool_name}"
)
@settings(max_examples=50)
@given(
tool_name=tool_names,
version=version_strings,
context=contexts,
)
def test_hidden_directories_never_in_tool_links(
self, tool_name: str, version: str, context: str, tmp_path_factory
) -> None:
"""Hidden directories (starting with .) must never appear in tool links.
**Validates: Requirements 8.2**
"""
tmp_path = tmp_path_factory.mktemp("monorepo")
root, merger = _create_monorepo_with_tool(tmp_path, tool_name, version)
# Also create a hidden directory in shared/tools/
hidden_dir = root / "shared" / "tools" / ".hidden-cache"
hidden_dir.mkdir(parents=True, exist_ok=True)
(hidden_dir / "VERSION").write_text("0.0.0", encoding="utf-8")
refs = merger.ensure_tool_links(context)
# No reference must have a name starting with "."
for ref in refs:
assert not ref.name.startswith("."), (
f"Hidden directory '{ref.name}' appeared in tool links"
)
@settings(max_examples=50)
@given(
tool_name=tool_names,
version=version_strings,
context=contexts,
)
def test_ensure_tool_links_is_idempotent(
self, tool_name: str, version: str, context: str, tmp_path_factory
) -> None:
"""ensure_tool_links must be idempotent: calling it twice produces the
same result.
**Validates: Requirements 8.2**
"""
tmp_path = tmp_path_factory.mktemp("monorepo")
root, merger = _create_monorepo_with_tool(tmp_path, tool_name, version)
refs1 = merger.ensure_tool_links(context)
refs2 = merger.ensure_tool_links(context)
# Same number of references
assert len(refs1) == len(refs2)
# Same names, versions, and link types
refs1_sorted = sorted(refs1, key=lambda r: r.name)
refs2_sorted = sorted(refs2, key=lambda r: r.name)
for r1, r2 in zip(refs1_sorted, refs2_sorted):
assert r1.name == r2.name
assert r1.version == r2.version
assert r1.link_type == r2.link_type
assert r1.shared_path == r2.shared_path
@@ -0,0 +1,373 @@
"""Unit-Tests für den StructureManager.
Testet die Verwaltung der 3-Ebenen-Ordnerhierarchie:
- Namensvalidierung (kebab-case, 2-50 Zeichen)
- Projekterstellung in allen vier Kontexten
- Fehlermeldung bei existierendem Projektnamen
- Edge Cases: Minimum-/Maximum-Länge, Sonderzeichen, Unicode
Validates: Requirements 1.1, 1.2, 1.3, 1.6
"""
from __future__ import annotations
import pytest
from pathlib import Path
from monorepo.structure import StructureManager
from monorepo.models import ProjectInfo
@pytest.fixture
def manager(tmp_path: Path) -> StructureManager:
"""Erstellt einen StructureManager mit einem temporären Root-Verzeichnis."""
return StructureManager(root_path=tmp_path)
# ---------------------------------------------------------------------------
# 1. validate_name: gültige kebab-case Namen
# ---------------------------------------------------------------------------
class TestValidateNameAccepts:
"""Testet, dass validate_name gültige Namen akzeptiert."""
def test_simple_name(self, manager: StructureManager) -> None:
assert manager.validate_name("my-project") is True
def test_minimum_length_two_chars(self, manager: StructureManager) -> None:
"""Minimum-Länge: 2 Zeichen (z.B. 'ab')."""
assert manager.validate_name("ab") is True
def test_maximum_length_fifty_chars(self, manager: StructureManager) -> None:
"""Maximum-Länge: 50 Zeichen."""
name = "a" * 50
assert manager.validate_name(name) is True
def test_starts_with_digit(self, manager: StructureManager) -> None:
assert manager.validate_name("1project") is True
def test_ends_with_digit(self, manager: StructureManager) -> None:
assert manager.validate_name("project1") is True
def test_only_digits(self, manager: StructureManager) -> None:
assert manager.validate_name("12") is True
def test_with_hyphens(self, manager: StructureManager) -> None:
assert manager.validate_name("my-cool-project") is True
def test_mixed_letters_digits_hyphens(self, manager: StructureManager) -> None:
assert manager.validate_name("a1-b2-c3") is True
# ---------------------------------------------------------------------------
# 2. validate_name: Ablehnungen (ungültige Namen)
# ---------------------------------------------------------------------------
class TestValidateNameRejects:
"""Testet, dass validate_name ungültige Namen ablehnt."""
def test_single_char(self, manager: StructureManager) -> None:
"""Einzelnes Zeichen ist zu kurz (< 2)."""
assert manager.validate_name("a") is False
def test_fifty_one_chars(self, manager: StructureManager) -> None:
"""51 Zeichen überschreitet Maximum."""
name = "a" * 51
assert manager.validate_name(name) is False
def test_starts_with_hyphen(self, manager: StructureManager) -> None:
assert manager.validate_name("-project") is False
def test_ends_with_hyphen(self, manager: StructureManager) -> None:
assert manager.validate_name("project-") is False
def test_uppercase_letters(self, manager: StructureManager) -> None:
assert manager.validate_name("MyProject") is False
def test_spaces(self, manager: StructureManager) -> None:
assert manager.validate_name("my project") is False
def test_underscores(self, manager: StructureManager) -> None:
assert manager.validate_name("my_project") is False
def test_unicode_characters(self, manager: StructureManager) -> None:
assert manager.validate_name("mein-prüfprojekt") is False
def test_empty_string(self, manager: StructureManager) -> None:
assert manager.validate_name("") is False
def test_only_hyphens(self, manager: StructureManager) -> None:
assert manager.validate_name("---") is False
def test_special_characters(self, manager: StructureManager) -> None:
assert manager.validate_name("my@project") is False
def test_dots(self, manager: StructureManager) -> None:
assert manager.validate_name("my.project") is False
# ---------------------------------------------------------------------------
# 3. create_project: Erstellung in jedem der 4 Kontexte
# ---------------------------------------------------------------------------
class TestCreateProjectContexts:
"""Testet Projekterstellung in allen vier Kontexten."""
@pytest.mark.parametrize("context", ["privat", "dhive", "bahn", "shared"])
def test_create_project_in_each_context(
self, manager: StructureManager, context: str
) -> None:
"""Projekt kann in jedem gültigen Kontext angelegt werden."""
result = manager.create_project(context, "test-project")
assert result.exists()
assert result.is_dir()
assert result == manager.root_path / context / "test-project"
@pytest.mark.parametrize("context", ["privat", "dhive", "bahn", "shared"])
def test_create_project_returns_correct_path(
self, manager: StructureManager, context: str
) -> None:
"""Der zurückgegebene Pfad zeigt auf den erstellten Ordner."""
result = manager.create_project(context, "my-app")
expected = manager.root_path / context / "my-app"
assert result == expected
# ---------------------------------------------------------------------------
# 4. create_project: ValueError bei ungültigem Kontext
# ---------------------------------------------------------------------------
class TestCreateProjectInvalidContext:
"""Testet Fehlermeldung bei ungültigem Kontext."""
def test_invalid_context_raises_valueerror(self, manager: StructureManager) -> None:
with pytest.raises(ValueError, match="Ungültiger Kontext"):
manager.create_project("invalid", "my-project")
def test_empty_context_raises_valueerror(self, manager: StructureManager) -> None:
with pytest.raises(ValueError, match="Ungültiger Kontext"):
manager.create_project("", "my-project")
def test_capitalized_context_raises_valueerror(self, manager: StructureManager) -> None:
with pytest.raises(ValueError, match="Ungültiger Kontext"):
manager.create_project("Privat", "my-project")
# ---------------------------------------------------------------------------
# 5. create_project: ValueError bei ungültigem Projektnamen
# ---------------------------------------------------------------------------
class TestCreateProjectInvalidName:
"""Testet Fehlermeldung bei ungültigem Projektnamen."""
def test_invalid_name_raises_valueerror(self, manager: StructureManager) -> None:
with pytest.raises(ValueError, match="Ungültiger Projektname"):
manager.create_project("privat", "My_Project")
def test_single_char_name_raises_valueerror(self, manager: StructureManager) -> None:
with pytest.raises(ValueError, match="Ungültiger Projektname"):
manager.create_project("privat", "a")
def test_empty_name_raises_valueerror(self, manager: StructureManager) -> None:
with pytest.raises(ValueError, match="Ungültiger Projektname"):
manager.create_project("privat", "")
# ---------------------------------------------------------------------------
# 6. create_project: ValueError bei doppeltem Projektnamen (Namenskonflikt)
# ---------------------------------------------------------------------------
class TestCreateProjectDuplicate:
"""Testet die Fehlermeldung bei existierendem Projektnamen."""
def test_duplicate_raises_valueerror(self, manager: StructureManager) -> None:
"""Zweites Anlegen des gleichen Projekts schlägt fehl."""
manager.create_project("privat", "my-project")
with pytest.raises(ValueError, match="Namenskonflikt"):
manager.create_project("privat", "my-project")
def test_duplicate_error_mentions_name(self, manager: StructureManager) -> None:
"""Die Fehlermeldung nennt den kollidierenden Namen."""
manager.create_project("dhive", "existing-app")
with pytest.raises(ValueError, match="existing-app"):
manager.create_project("dhive", "existing-app")
def test_same_name_different_context_works(self, manager: StructureManager) -> None:
"""Gleicher Name in verschiedenen Kontexten ist erlaubt."""
path1 = manager.create_project("privat", "my-tool")
path2 = manager.create_project("dhive", "my-tool")
assert path1.exists()
assert path2.exists()
assert path1 != path2
def test_duplicate_does_not_modify_existing(self, manager: StructureManager) -> None:
"""Ein fehlgeschlagener Duplikat-Versuch verändert nichts."""
path = manager.create_project("bahn", "my-service")
# Datei im bestehenden Projekt erstellen
marker = path / "marker.txt"
marker.write_text("original")
with pytest.raises(ValueError):
manager.create_project("bahn", "my-service")
# Bestehender Inhalt bleibt unverändert
assert marker.read_text() == "original"
# ---------------------------------------------------------------------------
# 7. list_projects: Auflistung und Filterung
# ---------------------------------------------------------------------------
class TestListProjects:
"""Testet die Projektauflistung und Kontextfilterung."""
def test_list_all_projects(self, manager: StructureManager) -> None:
"""Listet alle Projekte aus allen Kontexten."""
manager.create_project("privat", "app-a")
manager.create_project("dhive", "app-b")
manager.create_project("bahn", "app-c")
projects = manager.list_projects()
names = [p.name for p in projects]
assert "app-a" in names
assert "app-b" in names
assert "app-c" in names
def test_list_filtered_by_context(self, manager: StructureManager) -> None:
"""Filterung nach Kontext liefert nur Projekte des Kontexts."""
manager.create_project("privat", "priv-app")
manager.create_project("dhive", "dhive-app")
projects = manager.list_projects(context="privat")
names = [p.name for p in projects]
assert "priv-app" in names
assert "dhive-app" not in names
def test_list_returns_project_info(self, manager: StructureManager) -> None:
"""Jedes Element enthält name, context und path."""
manager.create_project("shared", "common-lib")
projects = manager.list_projects(context="shared")
assert len(projects) == 1
proj = projects[0]
assert proj.name == "common-lib"
assert proj.context == "shared"
assert proj.path == manager.root_path / "shared" / "common-lib"
def test_list_empty_when_no_projects(self, manager: StructureManager) -> None:
"""Leere Liste wenn keine Projekte existieren."""
projects = manager.list_projects()
assert projects == []
def test_list_invalid_context_raises(self, manager: StructureManager) -> None:
"""Ungültiger Kontextfilter löst ValueError aus."""
with pytest.raises(ValueError, match="Ungültiger Kontext"):
manager.list_projects(context="invalid")
# ---------------------------------------------------------------------------
# 8. list_projects: Überspringt versteckte Verzeichnisse
# ---------------------------------------------------------------------------
class TestListProjectsSkipsHidden:
"""Testet, dass versteckte Verzeichnisse (. prefix) übersprungen werden."""
def test_skips_dotfiles(self, manager: StructureManager) -> None:
"""Verzeichnisse mit . Prefix werden nicht gelistet."""
manager.create_project("privat", "visible-app")
# Verstecktes Verzeichnis manuell erstellen
hidden_dir = manager.root_path / "privat" / ".hidden-dir"
hidden_dir.mkdir(parents=True)
projects = manager.list_projects(context="privat")
names = [p.name for p in projects]
assert "visible-app" in names
assert ".hidden-dir" not in names
def test_skips_git_directory(self, manager: StructureManager) -> None:
"""Das .git Verzeichnis wird übersprungen."""
manager.create_project("dhive", "real-project")
git_dir = manager.root_path / "dhive" / ".git"
git_dir.mkdir(parents=True)
projects = manager.list_projects(context="dhive")
names = [p.name for p in projects]
assert "real-project" in names
assert ".git" not in names
# ---------------------------------------------------------------------------
# 9. resolve_context: Korrekte Kontexterkennung
# ---------------------------------------------------------------------------
class TestResolveContext:
"""Testet die Kontextauflösung anhand eines Pfads."""
@pytest.mark.parametrize("context", ["privat", "dhive", "bahn", "shared"])
def test_resolve_each_context(
self, manager: StructureManager, context: str
) -> None:
"""Erkennt den Kontext für Pfade in jedem Kontextordner."""
project_path = manager.create_project(context, "test-proj")
resolved = manager.resolve_context(project_path)
assert resolved == context
def test_resolve_nested_path(self, manager: StructureManager) -> None:
"""Erkennt den Kontext auch für tiefere Pfade."""
project_path = manager.create_project("bahn", "deep-proj")
module_path = project_path / "module-a" / "subdir"
module_path.mkdir(parents=True)
resolved = manager.resolve_context(module_path)
assert resolved == "bahn"
# ---------------------------------------------------------------------------
# 10. resolve_context: ValueError bei Pfaden außerhalb des Monorepos
# ---------------------------------------------------------------------------
class TestResolveContextOutside:
"""Testet Fehlermeldung bei Pfaden außerhalb des Monorepos."""
def test_outside_monorepo_raises(self, manager: StructureManager, tmp_path: Path) -> None:
"""Pfad außerhalb des Monorepo-Root löst ValueError aus."""
outside_path = tmp_path.parent / "some-other-dir"
with pytest.raises(ValueError):
manager.resolve_context(outside_path)
def test_root_path_itself_raises(self, manager: StructureManager) -> None:
"""Das Monorepo-Root selbst ist kein Projekt."""
with pytest.raises(ValueError):
manager.resolve_context(manager.root_path)
def test_unknown_context_folder_raises(self, manager: StructureManager) -> None:
"""Unbekannter Ordnername auf Ebene 1 löst ValueError aus."""
unknown = manager.root_path / "unknown-context" / "some-project"
unknown.mkdir(parents=True)
with pytest.raises(ValueError, match="nicht in einem bekannten"):
manager.resolve_context(unknown)
@@ -0,0 +1,187 @@
"""Property-basierte Tests für den StructureManager.
**Validates: Requirements 1.3**
Property 1: Namensvalidierung akzeptiert nur gültiges kebab-case
- For any String, die validate_name-Funktion soll genau dann true zurückgeben,
wenn der String ausschließlich aus Kleinbuchstaben, Ziffern und Bindestrichen besteht,
zwischen 2 und 50 Zeichen lang ist und nicht mit einem Bindestrich beginnt oder endet.
"""
from __future__ import annotations
import string
from pathlib import Path
from hypothesis import given, assume, settings
from hypothesis import strategies as st
from monorepo.structure import StructureManager
# --- Strategies ---
# Valid kebab-case characters
VALID_CHARS = string.ascii_lowercase + string.digits + "-"
VALID_START_END_CHARS = string.ascii_lowercase + string.digits
@st.composite
def valid_kebab_names(draw: st.DrawFn) -> str:
"""Generates valid kebab-case names: 2-50 chars, [a-z0-9-], no leading/trailing dash."""
length = draw(st.integers(min_value=2, max_value=50))
# First and last char: no dash allowed
first = draw(st.sampled_from(VALID_START_END_CHARS))
if length == 2:
last = draw(st.sampled_from(VALID_START_END_CHARS))
return first + last
last = draw(st.sampled_from(VALID_START_END_CHARS))
# Middle chars can include dashes
middle = draw(st.text(alphabet=VALID_CHARS, min_size=length - 2, max_size=length - 2))
return first + middle + last
@st.composite
def invalid_names_wrong_chars(draw: st.DrawFn) -> str:
"""Generates names containing at least one invalid character (not [a-z0-9-])."""
# Generate a base valid-ish name, then inject an invalid char
invalid_char = draw(
st.sampled_from(
list(set(string.printable) - set(VALID_CHARS) - set(string.whitespace))
+ ["Ä", "ö", "ü", "", " ", "_", ".", "A", "Z"]
)
)
# Place the invalid char in a string of valid length
length = draw(st.integers(min_value=2, max_value=50))
position = draw(st.integers(min_value=0, max_value=length - 1))
chars = []
for i in range(length):
if i == position:
chars.append(invalid_char)
else:
chars.append(draw(st.sampled_from(VALID_START_END_CHARS)))
return "".join(chars)
@st.composite
def invalid_names_too_short(draw: st.DrawFn) -> str:
"""Generates names that are too short (0 or 1 character)."""
length = draw(st.integers(min_value=0, max_value=1))
return draw(st.text(alphabet=VALID_START_END_CHARS, min_size=length, max_size=length))
@st.composite
def invalid_names_too_long(draw: st.DrawFn) -> str:
"""Generates names that are too long (>50 characters)."""
length = draw(st.integers(min_value=51, max_value=100))
first = draw(st.sampled_from(VALID_START_END_CHARS))
last = draw(st.sampled_from(VALID_START_END_CHARS))
middle = draw(st.text(alphabet=VALID_CHARS, min_size=length - 2, max_size=length - 2))
return first + middle + last
@st.composite
def invalid_names_leading_dash(draw: st.DrawFn) -> str:
"""Generates names that start with a dash."""
length = draw(st.integers(min_value=2, max_value=50))
rest = draw(st.text(alphabet=VALID_START_END_CHARS, min_size=length - 1, max_size=length - 1))
return "-" + rest
@st.composite
def invalid_names_trailing_dash(draw: st.DrawFn) -> str:
"""Generates names that end with a dash."""
length = draw(st.integers(min_value=2, max_value=50))
rest = draw(st.text(alphabet=VALID_START_END_CHARS, min_size=length - 1, max_size=length - 1))
return rest + "-"
# --- Tests ---
manager = StructureManager(root_path=Path("/tmp/test-monorepo"))
class TestProperty1NameValidation:
"""Property 1: Namensvalidierung akzeptiert nur gültiges kebab-case.
**Validates: Requirements 1.3**
"""
@given(name=valid_kebab_names())
@settings(max_examples=200)
def test_valid_names_are_accepted(self, name: str) -> None:
"""Valid kebab-case names (2-50 chars, [a-z0-9-], no leading/trailing dash)
must be accepted by validate_name."""
assert manager.validate_name(name) is True, (
f"validate_name rejected valid name: '{name}'"
)
@given(name=invalid_names_wrong_chars())
@settings(max_examples=200)
def test_names_with_invalid_chars_are_rejected(self, name: str) -> None:
"""Names containing characters outside [a-z0-9-] must be rejected."""
assert manager.validate_name(name) is False, (
f"validate_name accepted name with invalid chars: '{name}'"
)
@given(name=invalid_names_too_short())
@settings(max_examples=50)
def test_names_too_short_are_rejected(self, name: str) -> None:
"""Names shorter than 2 characters must be rejected."""
assert manager.validate_name(name) is False, (
f"validate_name accepted too-short name: '{name}'"
)
@given(name=invalid_names_too_long())
@settings(max_examples=200)
def test_names_too_long_are_rejected(self, name: str) -> None:
"""Names longer than 50 characters must be rejected."""
assert manager.validate_name(name) is False, (
f"validate_name accepted too-long name: '{name}'"
)
@given(name=invalid_names_leading_dash())
@settings(max_examples=100)
def test_names_starting_with_dash_are_rejected(self, name: str) -> None:
"""Names starting with a dash must be rejected."""
assert manager.validate_name(name) is False, (
f"validate_name accepted name starting with dash: '{name}'"
)
@given(name=invalid_names_trailing_dash())
@settings(max_examples=100)
def test_names_ending_with_dash_are_rejected(self, name: str) -> None:
"""Names ending with a dash must be rejected."""
assert manager.validate_name(name) is False, (
f"validate_name accepted name ending with dash: '{name}'"
)
@given(name=st.text(min_size=0, max_size=100))
@settings(max_examples=500)
def test_validate_name_biconditional(self, name: str) -> None:
"""For any string, validate_name returns True if and only if:
- The string consists exclusively of [a-z0-9-]
- Has length between 2 and 50 (inclusive)
- Does not start or end with a dash
This is the full biconditional property check.
"""
# Compute expected result from first principles
is_valid_chars = all(c in VALID_CHARS for c in name)
is_valid_length = 2 <= len(name) <= 50
no_leading_dash = len(name) > 0 and name[0] != "-"
no_trailing_dash = len(name) > 0 and name[-1] != "-"
expected = (
is_valid_chars
and is_valid_length
and no_leading_dash
and no_trailing_dash
)
result = manager.validate_name(name)
assert result == expected, (
f"validate_name('{name}') returned {result}, expected {expected}. "
f"chars_valid={is_valid_chars}, length_valid={is_valid_length}, "
f"no_lead_dash={no_leading_dash}, no_trail_dash={no_trailing_dash}"
)