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