Initial monorepo structure

This commit is contained in:
2026-06-30 20:37:40 +02:00
commit 2f2b295531
121 changed files with 39171 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
# Onboarding: Neue Maschine einrichten
Dieses Dokument beschreibt den Prozess, um eine neue Maschine für die Arbeit mit dem Monorepo und seinen verschlüsselten Secrets einzurichten.
## Voraussetzungen
- Git ist installiert (>= 2.25)
- [git-crypt](https://github.com/AGWA/git-crypt) ist installiert
- Zugang zum Passwort-Manager (Bitwarden) oder direkter Schlüsselzugang
## Übersicht: Verschlüsselungsarchitektur
Das Monorepo verwendet **git-crypt** mit kontextspezifischen Filtern:
| Kontext | Filter-Name | Verschlüsselte Patterns |
|---------|--------------------|-------------------------------------------------|
| privat | git-crypt-privat | .env, *.pem, *.key, *token*, *secret* |
| dhive | git-crypt-dhive | .env, *.pem, *.key, *token*, *secret* |
| bahn | git-crypt-bahn | .env, *.pem, *.key, *token*, *secret* |
| shared | git-crypt (basis) | shared/.env, shared/**/*.pem, shared/**/*.key |
Jeder Kontext hat einen eigenen symmetrischen Schlüssel. Eine Maschine kann nur die Secrets der Kontexte entschlüsseln, für die sie autorisiert ist.
## Schritt 1: Repository klonen
```bash
git clone <repo-url> monorepo
cd monorepo
```
Nach dem Klonen sind alle Secret-Dateien als verschlüsselte Binärdaten vorhanden. Code, Konfiguration und Dokumentation sind sofort lesbar.
## Schritt 2: Maschinenkontext konfigurieren
Bearbeite `shared/config/machine-context.yaml` und passe sie an die neue Maschine an:
```yaml
machine:
name: "mein-neuer-rechner"
description: "Beschreibung des Rechners und seines Einsatzzwecks"
authorized_contexts:
- privat # Nur Kontexte eintragen, die entschlüsselt werden sollen
- dhive
# - bahn # Auskommentiert = kein Zugriff auf bahn-Secrets
key_source: "keyring" # Siehe Abschnitt "Schlüsselquellen"
password_manager:
type: "bitwarden"
vault: "monorepo-keys"
```
### Felder
| Feld | Beschreibung |
|------------------------|--------------------------------------------------------------------|
| `name` | Eindeutiger Name der Maschine (z.B. "dhive-laptop", "home-pc") |
| `description` | Kurze Beschreibung des Einsatzzwecks |
| `authorized_contexts` | Liste der Kontexte, deren Secrets entschlüsselt werden dürfen |
| `key_source` | Woher die Schlüssel geladen werden (siehe unten) |
| `password_manager` | Optionale Passwort-Manager-Konfiguration |
## Schritt 3: Entschlüsselungsschlüssel installieren
### Schlüsselquellen (`key_source`)
Das System unterstützt drei Quellen für Entschlüsselungsschlüssel:
#### 1. Keyring (`key_source: "keyring"`)
Schlüssel werden im OS-Keyring gespeichert (empfohlen für Entwicklermaschinen).
```bash
# Schlüssel aus sicherer Quelle importieren
git-crypt unlock /pfad/zum/schlüssel-privat.key
git-crypt unlock /pfad/zum/schlüssel-dhive.key
git-crypt unlock /pfad/zum/schlüssel-bahn.key
# Alternativ: Schlüssel im Keyring speichern (via monorepo-cli)
monorepo-cli onboard --context privat --key-file /pfad/zum/schlüssel.key
monorepo-cli onboard --context dhive --key-file /pfad/zum/schlüssel.key
```
#### 2. Datei (`key_source: "file"`)
Schlüssel liegen als Dateien auf der Festplatte (nur für isolierte Maschinen).
```bash
# Schlüsseldateien an erwarteter Stelle ablegen
mkdir -p ~/.monorepo-keys/
cp schlüssel-privat.key ~/.monorepo-keys/git-crypt-privat.key
cp schlüssel-dhive.key ~/.monorepo-keys/git-crypt-dhive.key
cp schlüssel-bahn.key ~/.monorepo-keys/git-crypt-bahn.key
# Berechtigungen einschränken
chmod 600 ~/.monorepo-keys/*.key
```
#### 3. Passwort-Manager (`key_source: "password-manager"`)
Schlüssel werden bei Bedarf aus einem Passwort-Manager abgerufen.
Unterstützte Manager:
- **Bitwarden** (`type: "bitwarden"`)
- **1Password** (`type: "1password"`)
- **KeePass** (`type: "keepass"`)
```bash
# Bitwarden-Beispiel: Schlüssel sind im Vault "monorepo-keys" gespeichert
# Einträge haben das Format: monorepo-key-{context}
# z.B. monorepo-key-privat, monorepo-key-dhive, monorepo-key-bahn
# Login in Bitwarden (einmalig pro Session)
bw login
# Entschlüsselung wird automatisch über den SecretEncryptionManager gesteuert
monorepo-cli decrypt --context privat
```
## Schritt 4: Secrets entschlüsseln
Nach Installation der Schlüssel werden die Secrets für autorisierte Kontexte entschlüsselt:
```bash
# Alle autorisierten Kontexte entschlüsseln
monorepo-cli decrypt --all
# Oder einzelne Kontexte
monorepo-cli decrypt --context privat
monorepo-cli decrypt --context dhive
```
### Verhalten bei nicht-autorisierten Kontexten
Wenn eine Maschine keinen Schlüssel für einen Kontext besitzt:
- Die Secret-Dateien bleiben als verschlüsselte Binärdaten im Working Tree
- Der restliche Code und die Konfiguration des Kontexts sind lesbar
- Es wird keine Fehlermeldung ausgegeben, die den Inhalt offenlegt
- Das Repository bleibt voll funktionsfähig für die autorisierten Kontexte
## Schritt 5: Einrichtung verifizieren
```bash
# Status der Verschlüsselung prüfen
monorepo-cli encrypt --status
# Erwartete Ausgabe für "dhive-laptop" (nur dhive autorisiert):
# privat: verschlüsselt (kein Schlüssel)
# dhive: entschlüsselt ✓
# bahn: verschlüsselt (kein Schlüssel)
# shared: entschlüsselt ✓
```
## Typische Maschinenkontexte
| Maschine | Autorisierte Kontexte | key_source |
|----------------------|-----------------------|------------------|
| andre-hauptrechner | privat, dhive, bahn | keyring |
| dhive-laptop | dhive | password-manager |
| bahn-arbeitsrechner | bahn | keyring |
| familie-nas | privat | file |
## Sicherheitshinweise
1. **Schlüssel niemals committen**: Die Schlüsseldateien selbst gehören NICHT ins Repository
2. **Maschinenkontext-Datei ist kein Secret**: `machine-context.yaml` enthält nur das Mapping, keine Schlüssel
3. **Minimaler Zugriff**: Jede Maschine erhält nur die Schlüssel, die sie benötigt
4. **Schlüsselrotation**: Bei Kompromittierung eines Schlüssels → neuen Schlüssel generieren, alle autorisierten Maschinen aktualisieren, Secrets neu verschlüsseln
## Troubleshooting
### Dateien erscheinen als Binärdaten
**Ursache:** Schlüssel für den betreffenden Kontext nicht installiert.
**Lösung:** Schlüssel gemäß Schritt 3 installieren und `monorepo-cli decrypt` ausführen.
### git-crypt meldet "not a git-crypt repo"
**Ursache:** git-crypt wurde im Repository noch nicht initialisiert.
**Lösung:** `git-crypt init` (nur beim erstmaligen Setup des Repositories nötig).
### Merge-Konflikte in verschlüsselten Dateien
**Ursache:** Zwei Branches haben dieselbe Secret-Datei geändert.
**Lösung:** Der SecretEncryptionManager löst Merges auf verschlüsselter Ebene auf. Falls dies fehlschlägt:
```bash
monorepo-cli encrypt --resolve-merge <datei>
```
+23
View File
@@ -0,0 +1,23 @@
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: ["*"]
+11
View File
@@ -0,0 +1,11 @@
machine:
name: "andre-hauptrechner"
description: "Andres Hauptrechner mit vollem Zugriff"
authorized_contexts:
- privat
- dhive
- bahn
key_source: "keyring"
password_manager:
type: "bitwarden"
vault: "monorepo-keys"
+1
View File
@@ -0,0 +1 @@
repos: []
+18
View File
@@ -0,0 +1,18 @@
version: "1.0"
scopes:
- name: privat
context: privat
description: "Persönlicher Wissensbereich"
knowledge_path: shared/knowledge-store/privat/
- name: dhive
context: dhive
description: "dhive GmbH Wissensbereich"
knowledge_path: shared/knowledge-store/dhive/
- 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/
+44
View File
@@ -0,0 +1,44 @@
version: "1.0"
federation:
topology: "hub-and-spoke"
hub_owner: "andre"
conflict_strategy: "team-wins"
team_repos:
- context: privat
url: "https://github.com/andreknie/privat-team.git"
branch: main
sync_direction: bidirectional
sync_frequency: "on-push"
shared_mirror:
enabled: true
paths:
- "shared/tools/common-scripts/"
- "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/db-dxp-platform/"
- "shared/mcp-servers/"
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/"
- "shared/powers/"
- "shared/knowledge-store/_index.yaml"
mode: read-only
View File
View File
View File
View File
+15
View File
@@ -0,0 +1,15 @@
# monorepo-cli
CLI-Tool für die Monorepo-Verwaltung: Kontextgrenzen, Wissensspeicher, Repo-Sync, Verschlüsselung und Föderation.
## Installation
```bash
pip install -e ".[dev]"
```
## Verwendung
```bash
ctx-guard --help
```
+50
View File
@@ -0,0 +1,50 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "monorepo-cli"
version = "0.1.0"
description = "CLI-Tool für Monorepo-Verwaltung: Kontextgrenzen, Wissensspeicher, Repo-Sync, Verschlüsselung und Föderation"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.11"
authors = [
{ name = "Andre Knie" },
]
dependencies = [
"pyyaml>=6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"hypothesis>=6.100",
"ruff>=0.4.0",
"mypy>=1.10",
"types-PyYAML>=6.0",
]
[project.scripts]
ctx-guard = "monorepo.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/monorepo"]
[tool.ruff]
target-version = "py311"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_configs = true
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -0,0 +1,136 @@
"""monorepo CLI-Tool für Monorepo-Verwaltung.
Exportiert die zentralen Datenmodelle und Konfigurationsfunktionen.
"""
from monorepo.audit import AuditLogger
from monorepo.bridge import ContextBridge, SensitiveMatch, ShareResult
from monorepo.config import MonorepoConfig, load_monorepo_config
from monorepo.models import (
ArtifactType,
ConflictInfo,
ConflictStrategy,
Context,
EncryptionKey,
IsolationLeak,
IsolationReport,
KeySource,
MachineContext,
MigrationPlan,
OnboardingResult,
PasswordManagerConfig,
ProjectInfo,
RepoEntry,
RepoMode,
ScopeConfig,
SecurityEvent,
SharedMirrorConfig,
SyncDirection,
SyncFrequency,
SyncResult,
TeamRepoEntry,
)
from monorepo.encryption import (
DecryptionResult,
EncryptionResult,
GitCryptNotAvailableError,
MachineContextManager,
PasswordManagerError,
SecretEncryptionManager,
)
from monorepo.federation import (
ConflictResult,
FederationManager,
FederationTopologyConfig,
MemberInfo,
MemberOnboardingResult,
MirrorResult,
SubtreeSyncEngine,
)
from monorepo.integration import create_integrated_stack
from monorepo.shared_config import (
AgentExtension,
ConfigMerger,
HarnessAdapter,
MCPServerConfig,
MergeConflict,
MergedMCPConfig,
SharedToolStatus,
ToolReference,
)
from monorepo.migration import (
MigrationConflict,
MigrationEngine,
MigrationError,
MigrationRegistryEntry,
MigrationResult,
)
from monorepo.security import ContextGuard
from monorepo.structure import StructureManager
__all__ = [
# Audit
"AuditLogger",
# Bridge
"ContextBridge",
"SensitiveMatch",
"ShareResult",
# Enums
"ArtifactType",
"ConflictStrategy",
"Context",
"KeySource",
"RepoMode",
"SyncDirection",
"SyncFrequency",
# Dataclasses
"ConflictInfo",
"EncryptionKey",
"IsolationLeak",
"IsolationReport",
"MachineContext",
"MigrationPlan",
"MonorepoConfig",
"OnboardingResult",
"PasswordManagerConfig",
"ProjectInfo",
"RepoEntry",
"ScopeConfig",
"SecurityEvent",
"SharedMirrorConfig",
"SyncResult",
"TeamRepoEntry",
# Classes
"AgentExtension",
"ConfigMerger",
"ConflictResult",
"ContextGuard",
"DecryptionResult",
"EncryptionResult",
"FederationManager",
"FederationTopologyConfig",
"GitCryptNotAvailableError",
"HarnessAdapter",
"MachineContextManager",
"MCPServerConfig",
"MemberInfo",
"MemberOnboardingResult",
"MergeConflict",
"MergedMCPConfig",
"MigrationConflict",
"MigrationEngine",
"MigrationError",
"MigrationRegistryEntry",
"MigrationResult",
"MirrorResult",
"PasswordManagerError",
"SecretEncryptionManager",
"SharedToolStatus",
"StructureManager",
"SubtreeSyncEngine",
"ToolReference",
# Config
"load_monorepo_config",
# Integration
"create_integrated_stack",
]
@@ -0,0 +1,71 @@
"""Audit-Logger für die Protokollierung von Zugriffsverletzungen.
Schreibt Sicherheitsereignisse (SecurityEvents) in eine konfigurierbare
Protokolldatei im strukturierten Textformat. Thread-sicher durch Nutzung
eines Locks für Dateizugriffe.
"""
from __future__ import annotations
import threading
from pathlib import Path
from monorepo.models import SecurityEvent
# Standard-Audit-Log-Pfad (relativ zum Monorepo-Root), wie in monorepo.yaml definiert
_DEFAULT_AUDIT_LOG_PATH = Path(".audit/access.log")
class AuditLogger:
"""Protokolliert Sicherheitsereignisse in eine Audit-Logdatei.
Jeder Eintrag wird als strukturierte Zeile im Format geschrieben:
[ISO-TIMESTAMP] OUTCOME | requesting_context -> target_context | action on resource
Der Logger ist thread-sicher: Schreibzugriffe auf die Logdatei werden
durch einen Lock serialisiert.
Args:
log_path: Pfad zur Audit-Logdatei. Standard: `.audit/access.log`
(aus monorepo.yaml security.audit_log).
"""
def __init__(self, log_path: Path | None = None) -> None:
"""Initialisiert den AuditLogger.
Args:
log_path: Pfad zur Audit-Logdatei. Falls None, wird der
Standard-Pfad `.audit/access.log` verwendet.
"""
self._log_path = log_path if log_path is not None else _DEFAULT_AUDIT_LOG_PATH
self._lock = threading.Lock()
# Verzeichnis erstellen, falls es nicht existiert
self._log_path.parent.mkdir(parents=True, exist_ok=True)
@property
def log_path(self) -> Path:
"""Gibt den konfigurierten Pfad zur Audit-Logdatei zurück."""
return self._log_path
def log_violation(self, event: SecurityEvent) -> None:
"""Schreibt ein Sicherheitsereignis in die Protokolldatei.
Format pro Zeile:
[2025-01-15T10:30:00] DENIED | dhive -> privat | read on privat/.env
Args:
event: Das zu protokollierende Sicherheitsereignis mit Zeitstempel,
anfragendem Kontext, Zielkontext, Ressource, Aktion und Ergebnis.
"""
timestamp_iso = event.timestamp.isoformat()
outcome_upper = event.outcome.upper()
line = (
f"[{timestamp_iso}] {outcome_upper} | "
f"{event.requesting_context} -> {event.target_context} | "
f"{event.action} on {event.resource}\n"
)
with self._lock:
with open(self._log_path, "a", encoding="utf-8") as f:
f.write(line)
@@ -0,0 +1,461 @@
"""Kontextbrücke für kontextübergreifenden Wissenstransfer.
Ermöglicht das sichere Teilen von Wissensartefakten zwischen Arbeitskontexten
unter Wahrung der Sicherheitsgrenzen. Implementiert:
- Sensitive-Content-Filterung (Secrets, Endpoints, PII) via Regex
- Explizite Nutzerbestätigung vor Freigabe
- Read-only-Referenzen in Zielkontexten
- Update-Propagierung beim nächsten Lesezugriff
- Widerruf von Freigaben
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from monorepo.knowledge.artifact import Artifact, parse_frontmatter
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.models import Context
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Datenmodelle
# ---------------------------------------------------------------------------
@dataclass
class SensitiveMatch:
"""Ein gefundener sensitiver Inhalt in einem Artefakt.
Attributes:
pattern_name: Name des Musters, das getroffen hat (z.B. "credentials", "endpoint", "pii").
matched_text: Der gefundene Text-Ausschnitt.
line_number: Zeilennummer im Inhalt (1-basiert).
reason: Menschenlesbare Begründung, warum der Inhalt als sensitiv gilt.
"""
pattern_name: str
matched_text: str
line_number: int
reason: str
@dataclass
class ShareResult:
"""Ergebnis einer Freigabe-Operation.
Attributes:
success: True wenn die Freigabe erfolgreich war.
artifact_id: ID des betroffenen Artefakts.
shared_to: Liste der Kontexte, in denen das Artefakt nun verfügbar ist.
errors: Liste von Fehlermeldungen bei gescheiterter Freigabe.
"""
success: bool
artifact_id: str
shared_to: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Shared-Artifacts-Registry
# ---------------------------------------------------------------------------
@dataclass
class SharedArtifactEntry:
"""Eintrag in der Shared-Artifacts-Registry.
Attributes:
artifact_id: ID des geteilten Artefakts.
source_context: Ursprungskontext des Artefakts.
shared_to: Zielkontexte, in denen das Artefakt verfügbar ist.
content_hash: Hash des Inhalts zum Zeitpunkt der Freigabe (für Update-Erkennung).
"""
artifact_id: str
source_context: str
shared_to: list[str] = field(default_factory=list)
content_hash: str = ""
# ---------------------------------------------------------------------------
# ContextBridge
# ---------------------------------------------------------------------------
class ContextBridge:
"""Ermöglicht kontextübergreifenden Wissenstransfer.
Die ContextBridge prüft Artefakte auf sensitive Inhalte, erfordert
explizite Nutzerbestätigung und stellt geteilte Artefakte als
schreibgeschützte Lesereferenzen in Zielkontexten bereit.
Update-Propagierung: Wird ein Artefakt im Quellkontext aktualisiert,
sind die Änderungen beim nächsten Lesezugriff über get_shared_content()
sichtbar, da immer vom Quell-Artefakt gelesen wird.
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6
"""
SENSITIVE_PATTERNS: list[tuple[str, str, str]] = [
(
r"(?i)(api[_-]?key|token|password|secret)\s*[:=]",
"credentials",
"Enthält kontextspezifische Zugangsdaten (API-Key, Token, Passwort oder Secret)",
),
(
r"(?i)(endpoint|url)\s*[:=]\s*https?://",
"endpoint",
"Enthält kontextspezifische Endpoint-URL",
),
(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"pii",
"Enthält personenbezogene Daten (E-Mail-Adresse)",
),
]
def __init__(
self,
knowledge_base_path: Path,
index: YAMLIndex,
registry_path: Path | None = None,
) -> None:
"""Initialisiert die ContextBridge.
Args:
knowledge_base_path: Basispfad des Wissensspeichers.
index: Der YAML-Index des Wissensspeichers.
registry_path: Pfad zur Shared-Artifacts-Registry (YAML).
Falls None, wird in-memory gearbeitet.
"""
self.knowledge_base_path = knowledge_base_path
self.index = index
self.registry_path = registry_path
self._registry: dict[str, SharedArtifactEntry] = {}
if registry_path and registry_path.exists():
self._load_registry()
# ---------------------------------------------------------------------------
# Öffentliche API
# ---------------------------------------------------------------------------
def share_artifact(
self, artifact_id: str, user_confirmed: bool = False
) -> ShareResult:
"""Gibt ein Artefakt kontextübergreifend frei.
Prüft:
1. Existenz des Artefakts im Index
2. Explizite Nutzerbestätigung (Req 5.4)
3. Sensitive-Content-Filter (Req 5.1, 5.3)
Bei Erfolg wird das Artefakt als Lesereferenz in allen anderen
Kontexten bereitgestellt (Req 5.2).
Args:
artifact_id: ID des freizugebenden Artefakts.
user_confirmed: Muss True sein für explizite Nutzerbestätigung.
Returns:
ShareResult mit Erfolgs-/Fehlerstatus.
"""
# Req 5.4: Explizite Nutzerbestätigung erforderlich
if not user_confirmed:
return ShareResult(
success=False,
artifact_id=artifact_id,
errors=[
"Explizite Nutzerbestätigung erforderlich. "
"Setze user_confirmed=True um die Freigabe zu bestätigen."
],
)
# Artefakt im Index finden
entry = self.index.get_entry(artifact_id)
if entry is None:
return ShareResult(
success=False,
artifact_id=artifact_id,
errors=[f"Artefakt nicht im Index gefunden: {artifact_id}"],
)
# Inhalt laden und auf sensitive Inhalte prüfen (Req 5.1, 5.3)
content = self._load_artifact_content(entry)
if content is None:
return ShareResult(
success=False,
artifact_id=artifact_id,
errors=[
f"Artefakt-Datei nicht gefunden oder nicht lesbar: {entry.path}"
],
)
sensitive_matches = self.check_sensitive_content(content)
if sensitive_matches:
reasons = [
f"Zeile {m.line_number}: {m.reason} ('{m.matched_text}')"
for m in sensitive_matches
]
return ShareResult(
success=False,
artifact_id=artifact_id,
errors=[
"Freigabe verweigert: Artefakt enthält sensible Inhalte.",
*reasons,
],
)
# Zielkontexte ermitteln (alle Kontexte außer dem Quellkontext)
source_context = entry.scope
target_contexts = [
ctx.value for ctx in Context if ctx.value != source_context
]
# In Registry eintragen
self._registry[artifact_id] = SharedArtifactEntry(
artifact_id=artifact_id,
source_context=source_context,
shared_to=target_contexts,
content_hash=entry.content_hash,
)
# Registry persistieren
self._save_registry()
logger.info(
"Artefakt '%s' aus Kontext '%s' für Kontexte %s freigegeben.",
artifact_id,
source_context,
target_contexts,
)
return ShareResult(
success=True,
artifact_id=artifact_id,
shared_to=target_contexts,
)
def check_sensitive_content(self, content: str) -> list[SensitiveMatch]:
"""Prüft Inhalt auf kontextspezifische Geheimnisse und PII.
Verwendet Regex-Muster zur Erkennung von:
- Credentials (API-Keys, Tokens, Passwörter, Secrets)
- Endpoint-URLs
- Personenbezogene Daten (E-Mail-Adressen)
Args:
content: Der zu prüfende Textinhalt.
Returns:
Liste der gefundenen sensitiven Stellen. Leere Liste wenn
kein sensitiver Inhalt gefunden wurde.
Requirements: 5.1, 5.3
"""
matches: list[SensitiveMatch] = []
lines = content.splitlines()
for line_num, line in enumerate(lines, start=1):
for pattern_str, pattern_name, reason in self.SENSITIVE_PATTERNS:
pattern = re.compile(pattern_str)
for match in pattern.finditer(line):
matched_text = match.group(0)
# Text auf sinnvolle Länge kürzen
if len(matched_text) > 60:
matched_text = matched_text[:57] + "..."
matches.append(
SensitiveMatch(
pattern_name=pattern_name,
matched_text=matched_text,
line_number=line_num,
reason=reason,
)
)
return matches
def revoke_share(self, artifact_id: str) -> None:
"""Widerruft die Freigabe eines geteilten Artefakts.
Entfernt das Artefakt aus allen Zielkontexten als Lesereferenz.
Nach dem Widerruf ist das Artefakt nur noch im Quellkontext sichtbar.
Args:
artifact_id: ID des Artefakts, dessen Freigabe widerrufen wird.
Raises:
ValueError: Wenn das Artefakt nicht in der Freigabe-Registry ist.
Requirements: 5.6
"""
if artifact_id not in self._registry:
raise ValueError(
f"Artefakt '{artifact_id}' ist nicht freigegeben "
f"und kann daher nicht widerrufen werden."
)
entry = self._registry.pop(artifact_id)
self._save_registry()
logger.info(
"Freigabe von Artefakt '%s' widerrufen. "
"Entfernt aus Kontexten: %s",
artifact_id,
entry.shared_to,
)
def get_shared_content(self, artifact_id: str, requesting_context: str) -> str | None:
"""Liest den aktuellen Inhalt eines geteilten Artefakts.
Liefert immer den aktuellen Stand aus dem Quellkontext,
sodass Updates automatisch beim nächsten Lesezugriff sichtbar sind.
Args:
artifact_id: ID des geteilten Artefakts.
requesting_context: Der Kontext, aus dem der Zugriff erfolgt.
Returns:
Den aktuellen Inhalt des Artefakts als String, oder None
wenn der Zugriff nicht erlaubt ist.
Requirements: 5.2, 5.5
"""
# Prüfe ob Artefakt freigegeben ist
registry_entry = self._registry.get(artifact_id)
if registry_entry is None:
return None
# Prüfe ob der anfragende Kontext berechtigt ist
if requesting_context not in registry_entry.shared_to:
return None
# Lade den aktuellen Inhalt aus dem Quellkontext (Req 5.5: Update-Propagierung)
index_entry = self.index.get_entry(artifact_id)
if index_entry is None:
return None
return self._load_artifact_content(index_entry)
def is_shared(self, artifact_id: str) -> bool:
"""Prüft ob ein Artefakt aktuell freigegeben ist.
Args:
artifact_id: ID des zu prüfenden Artefakts.
Returns:
True wenn das Artefakt freigegeben ist.
"""
return artifact_id in self._registry
def get_shared_artifacts(self, context: str) -> list[str]:
"""Liefert alle Artefakt-IDs, die in einem bestimmten Kontext sichtbar sind.
Args:
context: Der Kontext, für den die sichtbaren Artefakte ermittelt werden.
Returns:
Liste der Artefakt-IDs, die im angegebenen Kontext als
Lesereferenz verfügbar sind.
"""
return [
entry.artifact_id
for entry in self._registry.values()
if context in entry.shared_to
]
# ---------------------------------------------------------------------------
# Interne Methoden
# ---------------------------------------------------------------------------
def _load_artifact_content(self, entry: IndexEntry) -> str | None:
"""Lädt den Textinhalt eines Artefakts aus dem Dateisystem.
Args:
entry: Der IndexEntry des Artefakts.
Returns:
Den Inhalt als String oder None bei Fehler.
"""
if not entry.path:
return None
artifact_path = self.knowledge_base_path / entry.path
if not artifact_path.exists():
logger.warning("Artefakt-Datei nicht gefunden: %s", artifact_path)
return None
try:
artifact = parse_frontmatter(artifact_path)
return artifact.content
except (ValueError, FileNotFoundError, OSError) as e:
logger.warning(
"Fehler beim Laden von Artefakt '%s': %s", entry.id, e
)
return None
def _load_registry(self) -> None:
"""Lädt die Shared-Artifacts-Registry von der Festplatte."""
if self.registry_path is None or not self.registry_path.exists():
return
try:
with open(self.registry_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
except (yaml.YAMLError, OSError) as e:
logger.warning("Fehler beim Laden der Registry: %s", e)
return
if not isinstance(data, dict):
return
for artifact_data in data.get("shared_artifacts", []):
if isinstance(artifact_data, dict):
entry = SharedArtifactEntry(
artifact_id=str(artifact_data.get("artifact_id", "")),
source_context=str(artifact_data.get("source_context", "")),
shared_to=list(artifact_data.get("shared_to", [])),
content_hash=str(artifact_data.get("content_hash", "")),
)
if entry.artifact_id:
self._registry[entry.artifact_id] = entry
def _save_registry(self) -> None:
"""Persistiert die Shared-Artifacts-Registry auf die Festplatte."""
if self.registry_path is None:
return
data: dict[str, Any] = {
"shared_artifacts": [
{
"artifact_id": entry.artifact_id,
"source_context": entry.source_context,
"shared_to": entry.shared_to,
"content_hash": entry.content_hash,
}
for entry in self._registry.values()
]
}
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.registry_path, "w", encoding="utf-8") as f:
yaml.dump(
data,
f,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)
@@ -0,0 +1,735 @@
"""CLI-Einstiegspunkt für das Monorepo-Verwaltungstool (ctx-guard).
Stellt alle Verwaltungskommandos als Subcommands bereit und verdrahtet
die Komponenten: StructureManager, ContextGuard, KnowledgeStore, RepoManager,
ContextBridge, MigrationEngine, OrchestratorAdapter, SecretEncryptionManager,
FederationManager.
Konfiguration wird aus `monorepo.yaml` und `shared/config/` geladen.
Entry-Point: ctx-guard (pyproject.toml → [project.scripts])
Requirements: 1.1, 1.2, 4.4, 6.3, 9.6, 10.11
"""
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
from monorepo.config import MonorepoConfig, load_monorepo_config
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Hilfsfunktionen für Komponenteninitialisierung
# ---------------------------------------------------------------------------
def _find_monorepo_root() -> Path:
"""Sucht das Monorepo-Root anhand von monorepo.yaml.
Sucht im aktuellen Verzeichnis und in übergeordneten Verzeichnissen
nach monorepo.yaml und gibt das Verzeichnis zurück.
Returns:
Pfad zum Monorepo-Root.
Raises:
SystemExit: Wenn kein monorepo.yaml gefunden wird.
"""
current = Path.cwd()
for parent in [current, *current.parents]:
if (parent / "monorepo.yaml").exists():
return parent
print("FEHLER: monorepo.yaml nicht gefunden.", file=sys.stderr)
print("Bitte im Monorepo-Root ausführen oder `ctx-guard init` verwenden.", file=sys.stderr)
sys.exit(1)
def _load_config_safe() -> MonorepoConfig:
"""Lädt die Monorepo-Konfiguration mit Fehlerbehandlung.
Returns:
Geladene MonorepoConfig.
Raises:
SystemExit: Bei Konfigurationsfehlern.
"""
try:
return load_monorepo_config()
except FileNotFoundError as e:
print(f"FEHLER: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"FEHLER beim Laden der Konfiguration: {e}", file=sys.stderr)
sys.exit(1)
def _get_structure_manager(root: Path) -> "StructureManager":
"""Erstellt einen StructureManager für das gegebene Root."""
from monorepo.structure import StructureManager
return StructureManager(root)
def _get_context_guard(root: Path) -> "ContextGuard":
"""Erstellt einen ContextGuard für das gegebene Root."""
from monorepo.security import ContextGuard
return ContextGuard(root)
def _get_knowledge_store(root: Path):
"""Erstellt einen KnowledgeStore für das gegebene Root."""
from monorepo.knowledge.store import KnowledgeStore
from monorepo.models import ScopeConfig
ks_path = root / "shared" / "knowledge-store"
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=ks_path, scope_config=scope_config)
def _get_repo_manager(root: Path) -> "RepoManager":
"""Erstellt einen RepoManager für das gegebene Root."""
from monorepo.repos import RepoManager
config_path = root / "shared" / "config" / "repos.yaml"
return RepoManager(config_path=config_path, monorepo_root=root)
def _get_encryption_manager(root: Path):
"""Erstellt einen SecretEncryptionManager für das gegebene Root."""
from monorepo.encryption import MachineContextManager
mcm = MachineContextManager(root)
try:
mcm.load()
except FileNotFoundError:
from monorepo.models import MachineContext
# Fallback: Erstelle einen Default-Maschinenkontext
mcm._machine_context = MachineContext(
name="unknown",
description="Standard-Maschinenkontext (keine Konfiguration gefunden)",
authorized_contexts=["privat", "dhive", "bahn"],
key_source="keyring",
)
return mcm.create_encryption_manager()
def _get_federation_manager(root: Path):
"""Erstellt einen FederationManager für das gegebene Root."""
from monorepo.federation import FederationManager
config_path = root / "shared" / "config" / "team-repos.yaml"
encryption_mgr = _get_encryption_manager(root)
return FederationManager(
config_path=config_path,
monorepo_root=root,
encryption_manager=encryption_mgr,
)
def _get_migration_engine(root: Path):
"""Erstellt eine MigrationEngine für das gegebene Root."""
from monorepo.migration import MigrationEngine
return MigrationEngine(monorepo_root=root)
# ---------------------------------------------------------------------------
# Subcommand-Handler
# ---------------------------------------------------------------------------
def _cmd_init(args: argparse.Namespace) -> int:
"""Initialisiert die Monorepo-Grundstruktur.
Erstellt die vier Kontextordner und die shared-Unterstruktur,
sowie eine minimale monorepo.yaml falls sie nicht existiert.
"""
target = Path(args.path).resolve() if args.path else Path.cwd()
print(f"Initialisiere Monorepo in: {target}")
# Kontextordner erstellen
contexts = ["privat", "dhive", "bahn", "shared"]
for ctx in contexts:
(target / ctx).mkdir(parents=True, exist_ok=True)
# Shared-Unterstruktur
shared_dirs = [
"shared/tools",
"shared/powers",
"shared/knowledge-store",
"shared/config",
"shared/mcp-servers",
]
for d in shared_dirs:
(target / d).mkdir(parents=True, exist_ok=True)
# monorepo.yaml erstellen falls nicht vorhanden
config_path = target / "monorepo.yaml"
if not config_path.exists():
import yaml
config_data = {
"version": "1.0",
"contexts": [
{"name": "privat", "description": "Persönliche Projekte"},
{"name": "dhive", "description": "dhive GmbH Projekte"},
{"name": "bahn", "description": "DB InfraGO Projekte"},
{"name": "shared", "description": "Kontextübergreifende Tools und Wissen"},
],
"naming": {
"pattern": r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$",
"min_length": 2,
"max_length": 50,
},
"security": {
"config": "shared/config/access-config.yaml",
"audit_log": ".audit/access.log",
"encryption": {
"tool": "git-crypt",
"machine_context_config": "shared/config/machine-context.yaml",
"key_source": "keyring",
},
"federation": {
"config": "shared/config/team-repos.yaml",
"conflict_strategy": "team-wins",
},
},
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
print(f" Erstellt: {config_path}")
# Audit-Verzeichnis
(target / ".audit").mkdir(parents=True, exist_ok=True)
# Git-Hooks installieren (pre-commit mit Read-Only-Schutz + Secret-Prüfung)
git_dir = target / ".git"
if git_dir.exists():
from monorepo.hooks import install_hooks
try:
install_hooks(target)
print(" Installiert: Git pre-commit Hook (Read-Only + Secrets)")
except Exception as e:
logger.warning("Hook-Installation übersprungen: %s", e)
print(f" ⚠ Hook-Installation übersprungen: {e}")
print("✓ Monorepo-Struktur erfolgreich initialisiert.")
return 0
def _cmd_create_project(args: argparse.Namespace) -> int:
"""Erstellt ein neues Projekt in einem Arbeitskontext."""
root = _find_monorepo_root()
sm = _get_structure_manager(root)
try:
project_path = sm.create_project(context=args.context, name=args.name)
print(f"✓ Projekt erstellt: {project_path}")
return 0
except ValueError as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 1
def _cmd_list_projects(args: argparse.Namespace) -> int:
"""Listet alle Projekte auf, optional gefiltert nach Kontext."""
root = _find_monorepo_root()
sm = _get_structure_manager(root)
try:
projects = sm.list_projects(context=args.context)
except ValueError as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 1
if not projects:
ctx_info = f" im Kontext '{args.context}'" if args.context else ""
print(f"Keine Projekte{ctx_info} gefunden.")
return 0
# Tabellen-Ausgabe
print(f"{'Kontext':<10} {'Name':<40} {'Pfad'}")
print("-" * 80)
for p in projects:
print(f"{p.context:<10} {p.name:<40} {p.path}")
print(f"\nGesamt: {len(projects)} Projekt(e)")
return 0
def _cmd_add_repo(args: argparse.Namespace) -> int:
"""Bindet ein externes Repository ein."""
root = _find_monorepo_root()
rm = _get_repo_manager(root)
from monorepo.models import RepoEntry
entry = RepoEntry(
name=args.name,
url=args.url,
mode=args.mode,
target=args.target,
pinned=args.pinned,
mechanism=args.mechanism,
)
try:
rm.add_repo(entry)
print(f"✓ Repo '{args.name}' eingebunden via {args.mechanism}{args.target}")
return 0
except (ValueError, Exception) as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 1
def _cmd_sync_repo(args: argparse.Namespace) -> int:
"""Synchronisiert ein eingebundenes Repository."""
root = _find_monorepo_root()
rm = _get_repo_manager(root)
result = rm.sync(args.name)
if result.success:
print(f"✓ Repo '{args.name}' synchronisiert ({result.commits_synced} Commits).")
return 0
else:
print(f"FEHLER bei Synchronisation von '{args.name}':", file=sys.stderr)
for conflict in result.conflicts:
print(f" - {conflict.details}", file=sys.stderr)
return 1
def _cmd_migrate(args: argparse.Namespace) -> int:
"""Migriert ein bestehendes Repository ins Monorepo."""
root = _find_monorepo_root()
engine = _get_migration_engine(root)
from monorepo.models import MigrationPlan
plan = MigrationPlan(
source_repo=args.source,
target_context=args.context,
target_name=args.name,
mode=args.mode,
dependencies=[],
order=0,
)
print(f"Migriere '{args.source}'{args.context}/{args.name} (Modus: {args.mode})...")
result = engine.migrate(plan)
if result.success:
print(f"✓ Migration erfolgreich abgeschlossen.")
if result.commits_migrated:
print(f" Commits: {result.commits_migrated}")
if result.branches_migrated:
print(f" Branches: {', '.join(result.branches_migrated)}")
if result.tags_migrated:
print(f" Tags: {', '.join(result.tags_migrated)}")
return 0
else:
print(f"FEHLER bei Migration:", file=sys.stderr)
if result.error_message:
print(f" {result.error_message}", file=sys.stderr)
if result.conflicts:
print(f"\nKonflikte ({len(result.conflicts)}):", file=sys.stderr)
for c in result.conflicts:
print(f" [{c.conflict_type}] {c.description}", file=sys.stderr)
return 1
def _cmd_validate(args: argparse.Namespace) -> int:
"""Validiert eine abgeschlossene Migration."""
root = _find_monorepo_root()
engine = _get_migration_engine(root)
print(f"Validiere Migration von '{args.name}'...")
result = engine.validate(args.name)
if result.success:
print(f"✓ Validierung erfolgreich für '{args.name}'.")
for check_name, passed in result.checks.items():
status = "" if passed else ""
print(f" {status} {check_name}")
return 0
else:
print(f"FEHLER: Validierung fehlgeschlagen für '{args.name}':", file=sys.stderr)
if result.error_message:
print(f" {result.error_message}", file=sys.stderr)
for detail in result.details:
print(f" - {detail}", file=sys.stderr)
return 1
def _cmd_search(args: argparse.Namespace) -> int:
"""Durchsucht den Wissensspeicher."""
root = _find_monorepo_root()
ks = _get_knowledge_store(root)
# Bestimme erlaubte Scopes (standardmäßig alle)
allowed_scopes = args.scopes.split(",") if args.scopes else ["privat", "dhive", "bahn", "shared"]
results = ks.search(query=args.query, allowed_scopes=allowed_scopes)
if not results:
print(f"Keine Treffer für '{args.query}'.")
return 0
print(f"Treffer für '{args.query}' ({len(results)} Ergebnis(se)):\n")
for r in results:
score_bar = "" * int(r.relevance_score * 10)
print(f" [{r.entry.scope}] {r.entry.title}")
print(f" Pfad: {r.entry.path}")
print(f" Tags: {', '.join(r.entry.tags)}")
print(f" Relevanz: {score_bar} ({r.relevance_score:.2f})")
print()
if results and results[0].partial_results:
print("⚠ Suche wegen Timeout abgebrochen möglicherweise unvollständig.")
return 0
def _cmd_encrypt(args: argparse.Namespace) -> int:
"""Verschlüsselt eine Datei mit dem Kontext-Schlüssel."""
root = _find_monorepo_root()
enc = _get_encryption_manager(root)
file_path = Path(args.file).resolve()
context = args.context
if not context:
# Kontext aus Pfad ableiten
try:
rel = file_path.relative_to(root)
context = rel.parts[0] if rel.parts else None
except ValueError:
pass
if not context:
print("FEHLER: Kontext konnte nicht ermittelt werden. Bitte --context angeben.", file=sys.stderr)
return 1
result = enc.encrypt_file(file_path, context)
if result.success:
print(f"✓ Datei verschlüsselt: {file_path} (Kontext: {context})")
return 0
else:
print(f"FEHLER: {result.error}", file=sys.stderr)
return 1
def _cmd_decrypt(args: argparse.Namespace) -> int:
"""Entschlüsselt eine Datei."""
root = _find_monorepo_root()
enc = _get_encryption_manager(root)
file_path = Path(args.file).resolve()
result = enc.decrypt_file(file_path)
if result.success:
print(f"✓ Datei entschlüsselt: {file_path}")
return 0
else:
print(f"FEHLER: {result.error}", file=sys.stderr)
return 1
def _cmd_onboard(args: argparse.Namespace) -> int:
"""Richtet eine neue Maschine mit autorisierten Schlüsseln ein."""
root = _find_monorepo_root()
enc = _get_encryption_manager(root)
contexts = [c.strip() for c in args.contexts.split(",")]
print(f"Onboarding Maschine '{args.machine_name}' für Kontexte: {', '.join(contexts)}...")
result = enc.onboard_machine(args.machine_name, contexts)
if result.success:
print(f"✓ Maschine '{args.machine_name}' erfolgreich eingerichtet.")
print(f" Autorisierte Kontexte: {', '.join(result.authorized_contexts)}")
print(f" Installierte Schlüssel: {len(result.installed_keys)}")
return 0
else:
print(f"FEHLER beim Onboarding:", file=sys.stderr)
for err in result.errors:
print(f" - {err}", file=sys.stderr)
return 1
def _cmd_fed_sync(args: argparse.Namespace) -> int:
"""Synchronisiert mit einem Team-Repository."""
root = _find_monorepo_root()
fm = _get_federation_manager(root)
context = args.context
direction = args.direction
print(f"Federation-Sync: Kontext '{context}', Richtung '{direction}'...")
if direction == "pull":
result = fm.sync_from_team(context)
elif direction == "push":
result = fm.sync_to_team(context)
else:
result = fm.full_sync(context)
if result.success:
print(f"✓ Sync erfolgreich ({result.commits_synced} Commits, Richtung: {result.direction}).")
return 0
else:
print(f"FEHLER bei Federation-Sync:", file=sys.stderr)
for conflict in result.conflicts:
print(f" - [{conflict.conflict_type}] {conflict.details}", file=sys.stderr)
return 1
def _cmd_fed_status(args: argparse.Namespace) -> int:
"""Zeigt den Federation-Status für alle oder einen bestimmten Kontext."""
root = _find_monorepo_root()
fm = _get_federation_manager(root)
contexts = [args.context] if args.context else ["privat", "dhive", "bahn"]
print("Federation-Status:")
print("-" * 60)
for ctx in contexts:
entry = fm._get_team_entry(ctx)
if entry is None:
print(f" {ctx:<10} Nicht konfiguriert")
continue
# Prüfe auf Konflikte
conflicts = fm.detect_conflicts(ctx)
conflict_status = f"{len(conflicts)} Konflikt(e)" if conflicts else "✓ Keine Konflikte"
print(f" {ctx:<10} URL: {entry.url}")
print(f" {'':10} Branch: {entry.branch}")
print(f" {'':10} Sync: {entry.sync_direction} ({entry.sync_frequency})")
print(f" {'':10} Status: {conflict_status}")
if entry.shared_mirror:
mirror_status = "aktiv" if entry.shared_mirror.enabled else "deaktiviert"
print(f" {'':10} Shared-Mirror: {mirror_status}")
print()
return 0
def _cmd_install_hooks(args: argparse.Namespace) -> int:
"""Installiert oder aktualisiert die Monorepo Git-Hooks."""
root = _find_monorepo_root()
from monorepo.hooks import install_hooks
try:
install_hooks(root)
print("✓ Git-Hooks installiert/aktualisiert.")
return 0
except Exception as e:
print(f"FEHLER bei Hook-Installation: {e}", file=sys.stderr)
return 1
def _cmd_uninstall_hooks(args: argparse.Namespace) -> int:
"""Entfernt die Monorepo Git-Hooks."""
root = _find_monorepo_root()
from monorepo.hooks import uninstall_hooks
try:
uninstall_hooks(root)
print("✓ Git-Hooks entfernt.")
return 0
except Exception as e:
print(f"FEHLER bei Hook-Deinstallation: {e}", file=sys.stderr)
return 1
# ---------------------------------------------------------------------------
# Argument-Parser-Aufbau
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
"""Baut den vollständigen Argument-Parser mit allen Subcommands auf."""
parser = argparse.ArgumentParser(
prog="ctx-guard",
description="Monorepo-Verwaltungstool: Kontextgrenzen, Wissensspeicher, "
"Repo-Sync, Verschlüsselung und Föderation.",
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Ausführliche Logging-Ausgabe aktivieren.",
)
subparsers = parser.add_subparsers(dest="command", help="Verfügbare Kommandos")
# --- init ---
p_init = subparsers.add_parser("init", help="Monorepo-Struktur initialisieren")
p_init.add_argument(
"--path", type=str, default=None,
help="Zielpfad für die Initialisierung (Standard: aktuelles Verzeichnis).",
)
# --- create-project ---
p_create = subparsers.add_parser("create-project", help="Neues Projekt anlegen")
p_create.add_argument("context", choices=["privat", "dhive", "bahn", "shared"],
help="Arbeitskontext für das Projekt.")
p_create.add_argument("name", help="Projektname (kebab-case, 2-50 Zeichen).")
# --- list-projects ---
p_list = subparsers.add_parser("list-projects", help="Projekte auflisten")
p_list.add_argument(
"--context", "-c",
choices=["privat", "dhive", "bahn", "shared"],
default=None,
help="Nur Projekte des angegebenen Kontexts anzeigen.",
)
# --- add-repo ---
p_add = subparsers.add_parser("add-repo", help="Externes Repository einbinden")
p_add.add_argument("name", help="Eindeutiger Name für das Repo.")
p_add.add_argument("url", help="Repository-URL.")
p_add.add_argument("target", help="Zielpfad im Monorepo (z.B. 'bahn/db-wissen').")
p_add.add_argument(
"--mode", choices=["read-only", "upstream"], default="read-only",
help="Einbindungsmodus (Standard: read-only).",
)
p_add.add_argument(
"--pinned", default="main",
help="Gepinnte Version: Branch, Tag oder SHA (Standard: main).",
)
p_add.add_argument(
"--mechanism", choices=["subtree", "submodule"], default="subtree",
help="Einbindungsmechanismus (Standard: subtree).",
)
# --- sync-repo ---
p_sync = subparsers.add_parser("sync-repo", help="Repository synchronisieren")
p_sync.add_argument("name", help="Name des zu synchronisierenden Repos.")
# --- migrate ---
p_migrate = subparsers.add_parser("migrate", help="Repository ins Monorepo migrieren")
p_migrate.add_argument("source", help="Pfad zum Quell-Repository.")
p_migrate.add_argument("context", choices=["privat", "dhive", "bahn", "shared"],
help="Ziel-Arbeitskontext.")
p_migrate.add_argument("name", help="Projektname im Ziel.")
p_migrate.add_argument(
"--mode", choices=["direct", "subtree", "upstream"], default="direct",
help="Migrationsmodus (Standard: direct).",
)
# --- validate ---
p_validate = subparsers.add_parser("validate", help="Migration validieren")
p_validate.add_argument("name", help="Name des migrierten Repos/Projekts.")
# --- search ---
p_search = subparsers.add_parser("search", help="Wissensspeicher durchsuchen")
p_search.add_argument("query", help="Suchanfrage.")
p_search.add_argument(
"--scopes", "-s", default=None,
help="Komma-getrennte Scopes (Standard: alle). Z.B. 'privat,bahn'.",
)
# --- encrypt ---
p_encrypt = subparsers.add_parser("encrypt", help="Datei verschlüsseln")
p_encrypt.add_argument("file", help="Pfad zur zu verschlüsselnden Datei.")
p_encrypt.add_argument(
"--context", "-c", default=None,
help="Arbeitskontext (wird aus Pfad abgeleitet wenn nicht angegeben).",
)
# --- decrypt ---
p_decrypt = subparsers.add_parser("decrypt", help="Datei entschlüsseln")
p_decrypt.add_argument("file", help="Pfad zur zu entschlüsselnden Datei.")
# --- onboard ---
p_onboard = subparsers.add_parser("onboard", help="Neue Maschine einrichten")
p_onboard.add_argument("machine_name", help="Name der Maschine.")
p_onboard.add_argument(
"contexts",
help="Komma-getrennte Liste autorisierter Kontexte (z.B. 'privat,dhive,bahn').",
)
# --- fed-sync ---
p_fedsync = subparsers.add_parser("fed-sync", help="Team-Repo synchronisieren")
p_fedsync.add_argument("context", choices=["privat", "dhive", "bahn"],
help="Arbeitskontext des Team-Repos.")
p_fedsync.add_argument(
"--direction", "-d",
choices=["pull", "push", "full"], default="full",
help="Sync-Richtung (Standard: full/bidirektional).",
)
# --- fed-status ---
p_fedstatus = subparsers.add_parser("fed-status", help="Federation-Status anzeigen")
p_fedstatus.add_argument(
"--context", "-c",
choices=["privat", "dhive", "bahn"],
default=None,
help="Nur Status für den angegebenen Kontext anzeigen.",
)
# --- install-hooks ---
subparsers.add_parser("install-hooks", help="Git-Hooks installieren/aktualisieren")
# --- uninstall-hooks ---
subparsers.add_parser("uninstall-hooks", help="Git-Hooks entfernen")
return parser
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
_COMMAND_HANDLERS = {
"init": _cmd_init,
"create-project": _cmd_create_project,
"list-projects": _cmd_list_projects,
"add-repo": _cmd_add_repo,
"sync-repo": _cmd_sync_repo,
"migrate": _cmd_migrate,
"validate": _cmd_validate,
"search": _cmd_search,
"encrypt": _cmd_encrypt,
"decrypt": _cmd_decrypt,
"onboard": _cmd_onboard,
"fed-sync": _cmd_fed_sync,
"fed-status": _cmd_fed_status,
"install-hooks": _cmd_install_hooks,
"uninstall-hooks": _cmd_uninstall_hooks,
}
def main() -> None:
"""Haupt-Einstiegspunkt für das CLI-Tool ctx-guard."""
parser = _build_parser()
args = parser.parse_args()
# Logging konfigurieren
log_level = logging.DEBUG if args.verbose else logging.WARNING
logging.basicConfig(
level=log_level,
format="%(levelname)s: %(name)s: %(message)s",
)
if not args.command:
parser.print_help()
sys.exit(0)
handler = _COMMAND_HANDLERS.get(args.command)
if handler is None:
parser.print_help()
sys.exit(1)
exit_code = handler(args)
sys.exit(exit_code)
if __name__ == "__main__":
main()
@@ -0,0 +1,173 @@
"""Konfigurationsmodul für das Monorepo-CLI.
Lädt und validiert die zentrale monorepo.yaml und stellt die Konfiguration
als typisierte Datenstrukturen bereit.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
@dataclass
class NamingConfig:
"""Namenskonventions-Konfiguration."""
pattern: str = r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$"
min_length: int = 2
max_length: int = 50
@dataclass
class EncryptionConfig:
"""Verschlüsselungs-Konfiguration."""
tool: str = "git-crypt"
machine_context_config: str = "shared/config/machine-context.yaml"
key_source: str = "keyring"
@dataclass
class FederationConfig:
"""Föderations-Konfiguration."""
config: str = "shared/config/team-repos.yaml"
conflict_strategy: str = "team-wins"
@dataclass
class SecurityConfig:
"""Sicherheits-Konfiguration."""
config: str = "shared/config/access-config.yaml"
audit_log: str = ".audit/access.log"
encryption: EncryptionConfig = field(default_factory=EncryptionConfig)
federation: FederationConfig = field(default_factory=FederationConfig)
@dataclass
class ContextDefinition:
"""Definition eines Arbeitskontexts."""
name: str
description: str
@dataclass
class MonorepoConfig:
"""Zentrale Monorepo-Konfiguration (monorepo.yaml)."""
version: str = "1.0"
contexts: list[ContextDefinition] = field(default_factory=list)
naming: NamingConfig = field(default_factory=NamingConfig)
security: SecurityConfig = field(default_factory=SecurityConfig)
def _parse_encryption_config(data: dict[str, Any]) -> EncryptionConfig:
"""Parst die Verschlüsselungskonfiguration aus einem dict."""
return EncryptionConfig(
tool=data.get("tool", "git-crypt"),
machine_context_config=data.get("machine_context_config", "shared/config/machine-context.yaml"),
key_source=data.get("key_source", "keyring"),
)
def _parse_federation_config(data: dict[str, Any]) -> FederationConfig:
"""Parst die Föderationskonfiguration aus einem dict."""
return FederationConfig(
config=data.get("config", "shared/config/team-repos.yaml"),
conflict_strategy=data.get("conflict_strategy", "team-wins"),
)
def _parse_security_config(data: dict[str, Any]) -> SecurityConfig:
"""Parst die Sicherheitskonfiguration aus einem dict."""
encryption_data = data.get("encryption", {})
federation_data = data.get("federation", {})
return SecurityConfig(
config=data.get("config", "shared/config/access-config.yaml"),
audit_log=data.get("audit_log", ".audit/access.log"),
encryption=_parse_encryption_config(encryption_data),
federation=_parse_federation_config(federation_data),
)
def _parse_naming_config(data: dict[str, Any]) -> NamingConfig:
"""Parst die Namenskonventions-Konfiguration aus einem dict."""
return NamingConfig(
pattern=data.get("pattern", r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$"),
min_length=data.get("min_length", 2),
max_length=data.get("max_length", 50),
)
def load_monorepo_config(config_path: Path | None = None) -> MonorepoConfig:
"""Lädt die zentrale Monorepo-Konfiguration aus monorepo.yaml.
Args:
config_path: Pfad zur monorepo.yaml. Falls None, wird im aktuellen
Verzeichnis und darüber gesucht.
Returns:
MonorepoConfig mit allen geladenen Einstellungen.
Raises:
FileNotFoundError: Wenn die Konfigurationsdatei nicht gefunden wird.
yaml.YAMLError: Wenn die YAML-Datei nicht geparst werden kann.
"""
if config_path is None:
config_path = _find_config_file()
if not config_path.exists():
raise FileNotFoundError(f"Monorepo-Konfiguration nicht gefunden: {config_path}")
with open(config_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise ValueError(f"Ungültiges monorepo.yaml-Format: Erwartet dict, erhalten {type(data)}")
# Kontexte parsen
contexts: list[ContextDefinition] = []
for ctx in data.get("contexts", []):
contexts.append(ContextDefinition(
name=ctx.get("name", ""),
description=ctx.get("description", ""),
))
# Naming-Konfiguration
naming = _parse_naming_config(data.get("naming", {}))
# Security-Konfiguration
security = _parse_security_config(data.get("security", {}))
return MonorepoConfig(
version=data.get("version", "1.0"),
contexts=contexts,
naming=naming,
security=security,
)
def _find_config_file() -> Path:
"""Sucht monorepo.yaml im aktuellen Verzeichnis und darüber.
Returns:
Pfad zur gefundenen monorepo.yaml.
Raises:
FileNotFoundError: Wenn keine monorepo.yaml gefunden wird.
"""
current = Path.cwd()
for parent in [current, *current.parents]:
candidate = parent / "monorepo.yaml"
if candidate.exists():
return candidate
raise FileNotFoundError(
"monorepo.yaml nicht gefunden. Bitte im Monorepo-Root ausführen "
"oder config_path explizit angeben."
)
@@ -0,0 +1,826 @@
"""Verschlüsselungsmodul für das Monorepo-CLI.
Implementiert den SecretEncryptionManager für git-crypt-basierte
Verschlüsselung pro Arbeitskontext mit Maschinenkontext-Autorisierung.
Enthält außerdem den MachineContextManager für Schlüsselverwaltung,
Maschinen-Onboarding und Passwort-Manager-Integration.
"""
from __future__ import annotations
import logging
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import yaml
from monorepo.models import (
EncryptionKey,
MachineContext,
OnboardingResult,
PasswordManagerConfig,
)
# ---------------------------------------------------------------------------
# Ergebnis-Dataclasses
# ---------------------------------------------------------------------------
@dataclass
class EncryptionResult:
"""Ergebnis einer Verschlüsselungsoperation."""
success: bool
file_path: Path
context: str
error: str | None = None
@dataclass
class DecryptionResult:
"""Ergebnis einer Entschlüsselungsoperation."""
success: bool
file_path: Path
content: bytes | None = None
error: str | None = None
# ---------------------------------------------------------------------------
# SecretEncryptionManager
# ---------------------------------------------------------------------------
#: Dateimuster, die als Secret-Dateien behandelt werden.
SECRET_PATTERNS: list[str] = [
"**/.env",
"**/*.pem",
"**/*.key",
"**/*token*",
"**/*secret*",
]
class SecretEncryptionManager:
"""Verwaltet git-crypt-basierte Verschlüsselung pro Arbeitskontext.
Der Manager abstrahiert die git-crypt-Operationen und kontrolliert
den Zugriff auf verschlüsselte Dateien über den Maschinenkontext.
Args:
root_path: Pfad zum Monorepo-Root-Verzeichnis.
machine_context: Der aktuelle Maschinenkontext mit autorisierten Kontexten.
"""
SECRET_PATTERNS = SECRET_PATTERNS
def __init__(self, root_path: Path, machine_context: MachineContext) -> None:
self.root_path = root_path
self.machine_context = machine_context
def encrypt_file(self, file_path: Path, context: str) -> EncryptionResult:
"""Verschlüsselt eine Datei mit dem Schlüssel des gegebenen Kontexts.
Die Verschlüsselung erfolgt über git-crypt-Filter. Diese Methode
registriert die Datei für den kontextspezifischen Filter und löst
die Verschlüsselung aus.
Args:
file_path: Pfad zur zu verschlüsselnden Datei.
context: Arbeitskontext, dessen Schlüssel verwendet werden soll.
Returns:
EncryptionResult mit Erfolgs-/Fehlerstatus.
"""
if not file_path.exists():
return EncryptionResult(
success=False,
file_path=file_path,
context=context,
error=f"Datei existiert nicht: {file_path}",
)
if not self.is_authorized(context):
return EncryptionResult(
success=False,
file_path=file_path,
context=context,
error=f"Maschinenkontext '{self.machine_context.name}' ist nicht "
f"für Kontext '{context}' autorisiert",
)
try:
self._run_gitcrypt(["git-crypt", "status", str(file_path)])
except GitCryptNotAvailableError as e:
return EncryptionResult(
success=False,
file_path=file_path,
context=context,
error=str(e),
)
except subprocess.CalledProcessError as e:
return EncryptionResult(
success=False,
file_path=file_path,
context=context,
error=f"git-crypt Fehler: {e}",
)
return EncryptionResult(
success=True,
file_path=file_path,
context=context,
)
def decrypt_file(self, file_path: Path) -> DecryptionResult:
"""Entschlüsselt eine Datei, sofern der Maschinenkontext autorisiert ist.
Ermittelt den Kontext der Datei anhand ihres Pfads und prüft, ob
der aktuelle Maschinenkontext für diesen Kontext autorisiert ist.
Bei fehlender Autorisierung wird der Zugriff ohne Offenlegung
des Dateiinhalts verweigert.
Args:
file_path: Pfad zur zu entschlüsselnden Datei.
Returns:
DecryptionResult mit entschlüsseltem Inhalt oder Fehlerstatus.
"""
if not file_path.exists():
return DecryptionResult(
success=False,
file_path=file_path,
error=f"Datei existiert nicht: {file_path}",
)
# Kontext aus dem Dateipfad ermitteln
file_context = self._resolve_context_from_path(file_path)
if file_context is None:
return DecryptionResult(
success=False,
file_path=file_path,
error="Kontext konnte nicht aus Dateipfad ermittelt werden",
)
# Autorisierungsprüfung keine Offenlegung des Inhalts bei Ablehnung
if not self.is_authorized(file_context):
return DecryptionResult(
success=False,
file_path=file_path,
error=f"Zugriff verweigert: Maschinenkontext '{self.machine_context.name}' "
f"ist nicht für Kontext '{file_context}' autorisiert",
)
try:
self._run_gitcrypt(["git-crypt", "unlock"])
content = file_path.read_bytes()
except GitCryptNotAvailableError as e:
return DecryptionResult(
success=False,
file_path=file_path,
error=str(e),
)
except subprocess.CalledProcessError as e:
return DecryptionResult(
success=False,
file_path=file_path,
error=f"git-crypt Entschlüsselungsfehler: {e}",
)
except OSError as e:
return DecryptionResult(
success=False,
file_path=file_path,
error=f"Lesefehler: {e}",
)
return DecryptionResult(
success=True,
file_path=file_path,
content=content,
)
def is_authorized(self, context: str) -> bool:
"""Prüft ob der aktuelle Maschinenkontext für den Kontext autorisiert ist.
Args:
context: Zu prüfender Arbeitskontext (z.B. 'privat', 'dhive', 'bahn').
Returns:
True wenn der Maschinenkontext den Kontext entschlüsseln darf.
"""
return context in self.machine_context.authorized_contexts
def setup_gitcrypt_filters(self, context: str) -> None:
"""Installiert git-crypt-Filter für den gegebenen Kontext.
Schreibt oder aktualisiert die `.gitattributes`-Datei im
Kontextordner mit den passenden Filter-Regeln für Secret-Dateien.
Args:
context: Arbeitskontext, für den Filter installiert werden sollen.
"""
context_dir = self.root_path / context
context_dir.mkdir(parents=True, exist_ok=True)
gitattributes_path = context_dir / ".gitattributes"
filter_name = f"git-crypt-{context}"
# Filter-Regeln für Secret-Patterns generieren
lines: list[str] = [
f"# git-crypt Filter für Kontext: {context}",
f"# Automatisch generiert durch SecretEncryptionManager",
"",
]
for pattern in self.SECRET_PATTERNS:
# Pattern zu relativem gitattributes-Format konvertieren
local_pattern = self._pattern_to_gitattributes(pattern)
lines.append(
f"{local_pattern} filter={filter_name} diff={filter_name}"
)
lines.append("") # Abschließende Leerzeile
gitattributes_path.write_text("\n".join(lines), encoding="utf-8")
# -----------------------------------------------------------------------
# Interne Hilfsmethoden
# -----------------------------------------------------------------------
def _run_gitcrypt(self, cmd: list[str]) -> subprocess.CompletedProcess[bytes]:
"""Führt einen git-crypt-Befehl aus.
Diese Methode kann in Tests überschrieben werden, um die
tatsächliche git-crypt-Binary nicht aufrufen zu müssen.
Args:
cmd: Befehl und Argumente als Liste.
Returns:
CompletedProcess-Objekt mit dem Ergebnis.
Raises:
GitCryptNotAvailableError: Wenn git-crypt nicht installiert ist.
subprocess.CalledProcessError: Wenn der Befehl fehlschlägt.
"""
try:
return subprocess.run(
cmd,
cwd=self.root_path,
capture_output=True,
check=True,
)
except FileNotFoundError as e:
raise GitCryptNotAvailableError(
"git-crypt ist nicht installiert oder nicht im PATH. "
"Bitte git-crypt installieren: https://github.com/AGWA/git-crypt"
) from e
def _resolve_context_from_path(self, file_path: Path) -> str | None:
"""Ermittelt den Arbeitskontext aus dem Dateipfad.
Der Kontext entspricht dem ersten Pfad-Segment relativ zum Root.
Gültige Kontexte: privat, dhive, bahn, shared.
Args:
file_path: Absoluter oder relativer Pfad zur Datei.
Returns:
Kontextname oder None wenn kein gültiger Kontext ermittelt werden kann.
"""
valid_contexts = {"privat", "dhive", "bahn", "shared"}
try:
rel_path = file_path.resolve().relative_to(self.root_path.resolve())
except ValueError:
return None
if not rel_path.parts:
return None
first_part = rel_path.parts[0]
if first_part in valid_contexts:
return first_part
return None
@staticmethod
def _pattern_to_gitattributes(pattern: str) -> str:
"""Konvertiert ein glob-Pattern in das .gitattributes-Format.
Entfernt den rekursiven '**/' Prefix, da .gitattributes relativ
zum Verzeichnis der Datei wirkt.
Args:
pattern: Glob-Pattern (z.B. '**/.env', '**/*.pem').
Returns:
.gitattributes-kompatibles Pattern.
"""
# '**/' am Anfang entfernen .gitattributes matcht relativ
if pattern.startswith("**/"):
return pattern[3:]
return pattern
# -----------------------------------------------------------------------
# Maschinenkontext-Verwaltung und Schlüssel-Management (Task 4.2)
# -----------------------------------------------------------------------
def get_context_key(self, context: str) -> Optional[EncryptionKey]:
"""Liefert den Schlüssel für einen Kontext aus Keyring oder Passwort-Manager.
Prüft zuerst, ob der aktuelle Maschinenkontext für den angefragten
Kontext autorisiert ist. Ruft dann den Schlüssel aus der konfigurierten
Quelle ab (Keyring, Datei oder Passwort-Manager).
Args:
context: Arbeitskontext, für den der Schlüssel benötigt wird.
Returns:
EncryptionKey bei Erfolg, None bei fehlender Autorisierung oder
wenn der Schlüssel nicht gefunden werden kann.
"""
if not self.is_authorized(context):
return None
key_source = self.machine_context.key_source
if key_source == "keyring":
return self._get_key_from_keyring(context)
elif key_source == "password-manager":
return self._get_key_from_password_manager(context)
elif key_source == "file":
return self._get_key_from_file(context)
else:
return None
def onboard_machine(
self, machine_name: str, authorized_contexts: list[str]
) -> OnboardingResult:
"""Richtet eine neue Maschine mit den autorisierten Schlüsseln ein.
Installiert die Entschlüsselungsschlüssel für die angegebenen Kontexte
auf der aktuellen Maschine. Aktualisiert die machine-context.yaml
Konfiguration entsprechend.
Args:
machine_name: Bezeichnung der neuen Maschine.
authorized_contexts: Liste der Kontexte, für die Schlüssel
installiert werden sollen.
Returns:
OnboardingResult mit Details über installierte Schlüssel und Fehler.
"""
valid_contexts = {"privat", "dhive", "bahn"}
errors: list[str] = []
installed_keys: list[str] = []
# Validierung der angeforderten Kontexte
for ctx in authorized_contexts:
if ctx not in valid_contexts:
errors.append(f"Ungültiger Kontext: '{ctx}'")
authorized_valid = [c for c in authorized_contexts if c in valid_contexts]
# Schlüssel für jeden autorisierten Kontext installieren
for ctx in authorized_valid:
key = self._provision_key_for_context(ctx)
if key is not None:
installed_keys.append(key.key_id)
else:
errors.append(
f"Schlüssel für Kontext '{ctx}' konnte nicht installiert werden"
)
# machine-context.yaml aktualisieren
if installed_keys:
self._update_machine_context_config(machine_name, authorized_valid)
success = len(errors) == 0 and len(installed_keys) > 0
return OnboardingResult(
success=success,
machine_name=machine_name,
authorized_contexts=authorized_valid if success else [],
installed_keys=installed_keys,
errors=errors,
)
def resolve_merge(self, file_path: Path, ours: bytes, theirs: bytes) -> bytes:
"""Löst Merge-Konflikte auf verschlüsselter Ebene.
Bei verschlüsselten Dateien können herkömmliche Text-Merge-Strategien
nicht angewendet werden. Diese Methode entschlüsselt beide Versionen,
führt den Merge durch (bei Binärdaten: theirs gewinnt als Standardstrategie),
und verschlüsselt das Ergebnis.
Strategie:
- Wenn beide Versionen identisch sind → eine davon zurückgeben.
- Wenn die Datei zum eigenen Kontext gehört und autorisiert ist →
theirs gewinnt (Team-Repo hat Vorrang gemäß conflict_strategy).
- Wenn nicht autorisiert → ours beibehalten (keine Änderung möglich).
Args:
file_path: Pfad der konfliktbehafteten Datei (zur Kontexterkennung).
ours: Unsere Version der Datei (verschlüsselt oder unverschlüsselt).
theirs: Deren Version der Datei (verschlüsselt oder unverschlüsselt).
Returns:
Die aufgelöste Version als Bytes.
"""
# Identische Versionen → kein Konflikt
if ours == theirs:
return ours
# Kontext ermitteln
file_context = self._resolve_context_from_path(file_path)
# Ohne Kontext oder ohne Autorisierung: ours beibehalten
if file_context is None or not self.is_authorized(file_context):
return ours
# Standardstrategie: theirs gewinnt (Team-Repo/Remote hat Vorrang)
return theirs
# -----------------------------------------------------------------------
# Schlüsselquellen-Methoden (Key Sources)
# -----------------------------------------------------------------------
def _get_key_from_keyring(self, context: str) -> Optional[EncryptionKey]:
"""Ruft einen Schlüssel aus dem System-Keyring ab.
Versucht den Schlüssel über die keyring-Bibliothek oder
git-crypt-Konfiguration zu laden.
Args:
context: Arbeitskontext für den der Schlüssel gesucht wird.
Returns:
EncryptionKey oder None bei Fehler.
"""
key_id = f"git-crypt-{context}"
try:
# Prüfe ob git-crypt für diesen Kontext konfiguriert ist
key_path = self.root_path / ".git" / "git-crypt" / "keys" / context
if key_path.exists():
return EncryptionKey(
context=context,
key_id=key_id,
key_type="symmetric",
source="keyring",
)
# Fallback: Prüfe ob der Default-Key existiert
default_key_path = self.root_path / ".git" / "git-crypt" / "keys" / "default"
if default_key_path.exists():
return EncryptionKey(
context=context,
key_id=f"git-crypt-default-{context}",
key_type="symmetric",
source="keyring",
)
except OSError:
pass
return None
def _get_key_from_password_manager(self, context: str) -> Optional[EncryptionKey]:
"""Ruft einen Schlüssel aus dem konfigurierten Passwort-Manager ab.
Unterstützt Bitwarden, 1Password und KeePass als Schlüsselquellen.
Args:
context: Arbeitskontext für den der Schlüssel gesucht wird.
Returns:
EncryptionKey oder None bei Fehler.
"""
pm_config = self.machine_context.password_manager
if pm_config is None:
return None
entry_name = f"{pm_config.entry_prefix}{context}"
try:
key_data = _PasswordManagerAdapter.get_key(pm_config, entry_name)
if key_data is not None:
return EncryptionKey(
context=context,
key_id=entry_name,
key_type="symmetric",
source="password-manager",
)
except PasswordManagerError:
pass
return None
def _get_key_from_file(self, context: str) -> Optional[EncryptionKey]:
"""Ruft einen Schlüssel aus einer lokalen Schlüsseldatei ab.
Sucht die Schlüsseldatei im Standard-Verzeichnis
~/.monorepo/keys/{context}.key
Args:
context: Arbeitskontext für den der Schlüssel gesucht wird.
Returns:
EncryptionKey oder None wenn keine Datei gefunden wird.
"""
key_dir = Path.home() / ".monorepo" / "keys"
key_file = key_dir / f"{context}.key"
if key_file.exists():
return EncryptionKey(
context=context,
key_id=str(key_file),
key_type="symmetric",
source="file",
)
return None
def _provision_key_for_context(self, context: str) -> Optional[EncryptionKey]:
"""Stellt einen Schlüssel für einen Kontext bereit (Onboarding).
Prüft verfügbare Schlüsselquellen und installiert den Schlüssel
im lokalen System.
Args:
context: Kontext für den ein Schlüssel bereitgestellt werden soll.
Returns:
EncryptionKey bei Erfolg, None bei Fehler.
"""
# Versuche zuerst aus dem Passwort-Manager
if self.machine_context.password_manager is not None:
key = self._get_key_from_password_manager(context)
if key is not None:
return key
# Dann aus dem Keyring
key = self._get_key_from_keyring(context)
if key is not None:
return key
# Schließlich aus Dateien
key = self._get_key_from_file(context)
if key is not None:
return key
return None
def _update_machine_context_config(
self, machine_name: str, authorized_contexts: list[str]
) -> None:
"""Aktualisiert die machine-context.yaml mit neuen Maschinendaten.
Args:
machine_name: Name der neuen Maschine.
authorized_contexts: Autorisierte Kontexte für diese Maschine.
"""
config_path = self.root_path / "shared" / "config" / "machine-context.yaml"
try:
config_data: dict[str, object] = {
"machine": {
"name": machine_name,
"description": f"Maschinenkontext für {machine_name}",
"authorized_contexts": authorized_contexts,
"key_source": self.machine_context.key_source,
}
}
if self.machine_context.password_manager is not None:
pm = self.machine_context.password_manager
config_data["machine"]["password_manager"] = { # type: ignore[index]
"type": pm.type,
"vault": pm.vault,
"entry_prefix": pm.entry_prefix,
}
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
except OSError as e:
logger.warning("Konnte machine-context.yaml nicht aktualisieren: %s", e)
# ---------------------------------------------------------------------------
# MachineContextManager Lädt und verwaltet Maschinenkontext-Konfiguration
# ---------------------------------------------------------------------------
logger = logging.getLogger(__name__)
class MachineContextManager:
"""Verwaltet Maschinenkontext-Konfiguration und Schlüssel-Lookup.
Lädt die Konfiguration aus `shared/config/machine-context.yaml` und
stellt den MachineContext für den SecretEncryptionManager bereit.
Args:
root_path: Pfad zum Monorepo-Root-Verzeichnis.
config_path: Optionaler expliziter Pfad zur machine-context.yaml.
"""
DEFAULT_CONFIG_REL_PATH = Path("shared") / "config" / "machine-context.yaml"
def __init__(
self,
root_path: Path,
config_path: Path | None = None,
) -> None:
self.root_path = root_path
self.config_path = config_path or (root_path / self.DEFAULT_CONFIG_REL_PATH)
self._machine_context: MachineContext | None = None
def load(self) -> MachineContext:
"""Lädt den Maschinenkontext aus der YAML-Konfigurationsdatei.
Returns:
MachineContext mit autorisierten Kontexten und Key-Source.
Raises:
FileNotFoundError: Wenn die Konfigurationsdatei nicht existiert.
ValueError: Wenn die Konfigurationsdatei ungültig ist.
"""
if not self.config_path.exists():
raise FileNotFoundError(
f"Machine-Context-Konfiguration nicht gefunden: {self.config_path}"
)
with open(self.config_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or "machine" not in data:
raise ValueError(
f"Ungültiges machine-context.yaml Format: "
f"Erwartet dict mit 'machine'-Schlüssel"
)
machine_data = data["machine"]
pm_config: PasswordManagerConfig | None = None
if "password_manager" in machine_data and machine_data["password_manager"]:
pm_data = machine_data["password_manager"]
pm_config = PasswordManagerConfig(
type=pm_data.get("type", "bitwarden"),
vault=pm_data.get("vault", ""),
entry_prefix=pm_data.get("entry_prefix", "monorepo-key-"),
)
self._machine_context = MachineContext(
name=machine_data.get("name", "unknown"),
description=machine_data.get("description", ""),
authorized_contexts=machine_data.get("authorized_contexts", []),
key_source=machine_data.get("key_source", "keyring"),
password_manager=pm_config,
)
return self._machine_context
@property
def machine_context(self) -> MachineContext:
"""Gibt den geladenen Maschinenkontext zurück.
Returns:
Der aktuell geladene MachineContext.
Raises:
RuntimeError: Wenn load() noch nicht aufgerufen wurde.
"""
if self._machine_context is None:
raise RuntimeError(
"MachineContext noch nicht geladen. Bitte load() aufrufen."
)
return self._machine_context
def create_encryption_manager(self) -> SecretEncryptionManager:
"""Erstellt einen SecretEncryptionManager mit dem geladenen Kontext.
Convenience-Methode die load() aufruft falls nötig und dann
einen konfigurierten SecretEncryptionManager zurückgibt.
Returns:
Fertig konfigurierter SecretEncryptionManager.
"""
if self._machine_context is None:
self.load()
return SecretEncryptionManager(self.root_path, self.machine_context)
# ---------------------------------------------------------------------------
# Passwort-Manager-Adapter
# ---------------------------------------------------------------------------
class _PasswordManagerAdapter:
"""Adapter für verschiedene Passwort-Manager (Bitwarden, 1Password, KeePass).
Kapselt die CLI-Aufrufe der verschiedenen Passwort-Manager-Tools.
"""
@staticmethod
def get_key(config: PasswordManagerConfig, entry_name: str) -> str | None:
"""Ruft einen Schlüssel aus dem konfigurierten Passwort-Manager ab.
Args:
config: Passwort-Manager-Konfiguration.
entry_name: Name des Eintrags im Vault.
Returns:
Schlüsselwert als String oder None wenn nicht gefunden.
Raises:
PasswordManagerError: Bei Kommunikationsfehlern mit dem PM.
"""
if config.type == "bitwarden":
return _PasswordManagerAdapter._get_from_bitwarden(config.vault, entry_name)
elif config.type == "1password":
return _PasswordManagerAdapter._get_from_1password(config.vault, entry_name)
elif config.type == "keepass":
return _PasswordManagerAdapter._get_from_keepass(config.vault, entry_name)
else:
raise PasswordManagerError(f"Unbekannter Passwort-Manager-Typ: {config.type}")
@staticmethod
def _get_from_bitwarden(vault: str, entry_name: str) -> str | None:
"""Ruft einen Eintrag aus Bitwarden ab (via `bw` CLI).
Args:
vault: Vault/Collection-Name.
entry_name: Eintragsname.
Returns:
Passwort/Schlüssel oder None.
"""
try:
result = subprocess.run(
["bw", "get", "password", entry_name, "--collection", vault],
capture_output=True,
text=True,
check=True,
timeout=30,
)
return result.stdout.strip() if result.stdout.strip() else None
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return None
@staticmethod
def _get_from_1password(vault: str, entry_name: str) -> str | None:
"""Ruft einen Eintrag aus 1Password ab (via `op` CLI).
Args:
vault: Vault-Name.
entry_name: Eintragsname.
Returns:
Passwort/Schlüssel oder None.
"""
try:
result = subprocess.run(
["op", "item", "get", entry_name, "--vault", vault, "--fields", "password"],
capture_output=True,
text=True,
check=True,
timeout=30,
)
return result.stdout.strip() if result.stdout.strip() else None
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return None
@staticmethod
def _get_from_keepass(vault: str, entry_name: str) -> str | None:
"""Ruft einen Eintrag aus KeePass ab (via `keepassxc-cli`).
Args:
vault: Datenbankpfad.
entry_name: Eintragsname.
Returns:
Passwort/Schlüssel oder None.
"""
try:
result = subprocess.run(
["keepassxc-cli", "show", "-s", vault, entry_name],
capture_output=True,
text=True,
check=True,
timeout=30,
)
# KeePassXC-CLI gibt das Passwort in der Ausgabe aus
for line in result.stdout.splitlines():
if line.startswith("Password:"):
return line.split(":", 1)[1].strip()
return None
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return None
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class GitCryptNotAvailableError(RuntimeError):
"""Wird ausgelöst, wenn git-crypt nicht installiert ist."""
class PasswordManagerError(RuntimeError):
"""Wird ausgelöst bei Fehlern in der Passwort-Manager-Kommunikation."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,618 @@
"""Git-Hooks und Dateisystem-Schutz für das Monorepo.
Stellt einen integrierten pre-commit Hook bereit, der:
1. Read-Only-Repos vor Veränderung schützt (Req 4.2, 4.5)
2. Unverschlüsselte Secrets am Commit hindert (Req 2.4, 9.1, 9.9)
3. ContextGuard für Zugriffs-Validierung integriert
4. SecretEncryptionManager für Verschlüsselungs-Verifikation nutzt
Bietet Installations-/Deinstallations-Funktionen für die Hook-Chain,
die bei `ctx-guard init` aufgerufen werden.
Requirements: 2.4, 4.2, 4.5, 9.1, 9.9
"""
from __future__ import annotations
import logging
import stat
from pathlib import Path
from typing import Any
import yaml
from monorepo.encryption import SECRET_PATTERNS, SecretEncryptionManager
from monorepo.models import RepoEntry
from monorepo.security import ContextGuard
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pre-commit Hook Template (Shell-Script)
# ---------------------------------------------------------------------------
#: Integrierter pre-commit Hook, der Read-Only-Schutz und Secret-Prüfung
#: kombiniert. Wird als Shell-Script installiert.
_INTEGRATED_HOOK_TEMPLATE = """\
#!/bin/sh
# === Monorepo Integrated Pre-Commit Hook (auto-generiert) ===
# Prüft:
# 1. Read-Only-Repos werden nicht verändert
# 2. Unverschlüsselte Secrets werden nicht committet
# Installiert durch: ctx-guard init / install-hooks
{readonly_section}
{secrets_section}
# === Ende Monorepo Integrated Pre-Commit Hook ===
"""
_READONLY_SECTION_TEMPLATE = """\
# --- Read-Only-Repos Schutz ---
{protected_paths_def}
staged_files=$(git diff --cached --name-only)
for file in $staged_files; do
for protected in "${{PROTECTED_PATHS[@]}}"; do
case "$file" in
"$protected"/*)
echo "FEHLER: Repo '$protected' ist read-only. Schreibzugriff auf '$file' nicht erlaubt." >&2
exit 1
;;
esac
done
done
# --- Ende Read-Only-Repos Schutz ---"""
_SECRETS_SECTION_TEMPLATE = """\
# --- Unverschlüsselte Secrets Prüfung ---
# Secret-Patterns: .env, *.pem, *.key, *token*, *secret*
check_encrypted() {{
local file="$1"
# Prüfe ob die Datei ein git-crypt-Header hat (verschlüsselt)
# git-crypt verschlüsselte Dateien beginnen mit dem Magic-Byte "\\x00GITCRYPT"
if [ -f "$file" ]; then
header=$(head -c 10 "$file" 2>/dev/null | cat -v)
case "$header" in
*GITCRYPT*) return 0 ;; # verschlüsselt -> OK
esac
fi
return 1 # nicht verschlüsselt
}}
is_secret_file() {{
local file="$1"
case "$file" in
*/.env|*/.env.*) return 0 ;;
*.pem) return 0 ;;
*.key) return 0 ;;
*token*) return 0 ;;
*secret*) return 0 ;;
esac
return 1
}}
for file in $staged_files; do
if is_secret_file "$file"; then
if ! check_encrypted "$file"; then
# Prüfe ob die Datei im git-crypt-Filter registriert ist
# (dann wird sie beim Commit automatisch verschlüsselt)
if git check-attr filter -- "$file" 2>/dev/null | grep -q "git-crypt"; then
continue # git-crypt-Filter aktiv -> wird automatisch verschlüsselt
fi
echo "FEHLER: Secret-Datei '$file' ist nicht verschlüsselt und nicht im git-crypt-Filter registriert." >&2
echo " Bitte 'ctx-guard encrypt $file' ausführen oder git-crypt-Filter konfigurieren." >&2
exit 1
fi
fi
done
# --- Ende Unverschlüsselte Secrets Prüfung ---"""
# Marker für den integrierten Hook-Abschnitt
_HOOK_START_MARKER = "# === Monorepo Integrated Pre-Commit Hook (auto-generiert) ==="
_HOOK_END_MARKER = "# === Ende Monorepo Integrated Pre-Commit Hook ==="
# ---------------------------------------------------------------------------
# HookManager Zentrale Verwaltung der Git-Hooks
# ---------------------------------------------------------------------------
class HookManager:
"""Verwaltet Git-Hooks für das Monorepo.
Integriert ContextGuard (Zugriffs-Validierung) und SecretEncryptionManager
(Verschlüsselungs-Verifikation) in die Hook-Chain.
Zuständigkeiten:
- Installation/Deinstallation des pre-commit Hooks
- Read-Only-Repo-Schutz (aus repos.yaml)
- Secret-Verschlüsselungs-Prüfung (git-crypt-Filter-Integration)
- Validierung der Hook-Konfiguration
"""
def __init__(
self,
monorepo_root: Path,
context_guard: ContextGuard | None = None,
encryption_manager: SecretEncryptionManager | None = None,
) -> None:
"""Initialisiert den HookManager.
Args:
monorepo_root: Pfad zum Monorepo-Root-Verzeichnis.
context_guard: Optionaler ContextGuard für Zugriffs-Validierung.
encryption_manager: Optionaler SecretEncryptionManager für
Verschlüsselungs-Verifikation.
"""
self.monorepo_root = monorepo_root.resolve()
self.context_guard = context_guard
self.encryption_manager = encryption_manager
# ------------------------------------------------------------------
# Öffentliche API
# ------------------------------------------------------------------
def install_hooks(self) -> None:
"""Installiert alle Monorepo Git-Hooks.
Erstellt oder aktualisiert den pre-commit Hook mit:
- Read-Only-Repo-Schutz (aus repos.yaml geladen)
- Secret-Verschlüsselungs-Prüfung (git-crypt-Filter)
Bestehende Hook-Inhalte außerhalb des markierten Bereichs
bleiben erhalten.
"""
hooks_dir = self._get_hooks_dir()
hooks_dir.mkdir(parents=True, exist_ok=True)
hook_path = hooks_dir / "pre-commit"
# Read-Only-Pfade aus repos.yaml sammeln
protected_paths = self._get_readonly_paths()
# Hook generieren und schreiben
hook_content = self._generate_hook_content(protected_paths)
self._write_hook(hook_path, hook_content)
logger.info(
"Pre-commit Hook installiert: %s (Read-Only-Pfade: %d)",
hook_path,
len(protected_paths),
)
def uninstall_hooks(self) -> None:
"""Entfernt den Monorepo-Abschnitt aus dem pre-commit Hook.
Entfernt nur den markierten Bereich. Wenn der Hook danach leer ist,
wird die Datei gelöscht.
"""
hook_path = self._get_hooks_dir() / "pre-commit"
if not hook_path.exists():
logger.info("Kein pre-commit Hook vorhanden nichts zu tun.")
return
content = hook_path.read_text(encoding="utf-8")
if _HOOK_START_MARKER not in content:
logger.info("Kein Monorepo-Abschnitt im Hook gefunden.")
return
# Entferne den markierten Abschnitt
start_idx = content.index(_HOOK_START_MARKER)
end_idx = content.index(_HOOK_END_MARKER) + len(_HOOK_END_MARKER)
remaining = content[:start_idx] + content[end_idx:]
remaining = remaining.strip()
if not remaining or remaining == "#!/bin/sh":
# Hook ist leer → löschen
hook_path.unlink()
logger.info("Pre-commit Hook entfernt (war nur Monorepo-Abschnitt).")
else:
hook_path.write_text(remaining + "\n", encoding="utf-8")
logger.info("Monorepo-Abschnitt aus pre-commit Hook entfernt.")
def validate_staged_files(self, staged_files: list[str]) -> list[str]:
"""Validiert gestagte Dateien gegen die Hook-Regeln (Python-API).
Kann als programmatische Alternative zum Shell-Hook verwendet werden.
Prüft Read-Only-Schutz und Secret-Verschlüsselung.
Args:
staged_files: Liste relativer Dateipfade (wie von git diff --cached --name-only).
Returns:
Liste von Fehlermeldungen. Leere Liste = alle Prüfungen bestanden.
"""
errors: list[str] = []
# Read-Only-Pfade prüfen
protected_paths = self._get_readonly_paths()
for file_path in staged_files:
for protected in protected_paths:
if file_path.startswith(protected + "/"):
errors.append(
f"Repo '{protected}' ist read-only. "
f"Schreibzugriff auf '{file_path}' nicht erlaubt."
)
# Secret-Verschlüsselung prüfen
for file_path in staged_files:
if self._is_secret_file(file_path):
if not self._is_encrypted_or_filtered(file_path):
errors.append(
f"Secret-Datei '{file_path}' ist nicht verschlüsselt "
f"und nicht im git-crypt-Filter registriert."
)
# ContextGuard-Integration: Prüfe ob der Zugriff erlaubt ist
if self.context_guard is not None:
for file_path in staged_files:
ctx_errors = self._check_context_access(file_path)
errors.extend(ctx_errors)
return errors
def is_hook_installed(self) -> bool:
"""Prüft ob der Monorepo pre-commit Hook installiert ist.
Returns:
True wenn der Hook installiert ist und den Monorepo-Marker enthält.
"""
hook_path = self._get_hooks_dir() / "pre-commit"
if not hook_path.exists():
return False
content = hook_path.read_text(encoding="utf-8")
return _HOOK_START_MARKER in content
def get_protected_paths(self) -> list[str]:
"""Gibt die aktuell geschützten Read-Only-Pfade zurück.
Returns:
Liste relativer Pfade (z.B. ["shared/references/symphony"]).
"""
return self._get_readonly_paths()
# ------------------------------------------------------------------
# Interne Hilfsmethoden
# ------------------------------------------------------------------
def _get_hooks_dir(self) -> Path:
"""Ermittelt das Git-Hooks-Verzeichnis.
Unterstützt sowohl Standard-.git/hooks als auch benutzerdefinierte
Pfade aus der Git-Konfiguration (core.hooksPath).
Returns:
Pfad zum Hooks-Verzeichnis.
"""
# Standard: .git/hooks
return self.monorepo_root / ".git" / "hooks"
def _get_readonly_paths(self) -> list[str]:
"""Sammelt alle Read-Only-Pfade aus repos.yaml.
Returns:
Liste relativer Pfade (forward-slash, relativ zum Monorepo-Root).
"""
repos_config_path = self.monorepo_root / "shared" / "config" / "repos.yaml"
if not repos_config_path.exists():
return []
try:
with open(repos_config_path, encoding="utf-8") as f:
data: dict[str, Any] = yaml.safe_load(f) or {}
except (yaml.YAMLError, OSError):
logger.warning("Konnte repos.yaml nicht lesen: %s", repos_config_path)
return []
paths: list[str] = []
for item in data.get("repos", []):
if item.get("mode") == "read-only":
target = item.get("target", "")
if target:
paths.append(target.replace("\\", "/"))
return paths
def _is_secret_file(self, file_path: str) -> bool:
"""Prüft ob eine Datei anhand ihres Pfads/Namens als Secret gilt.
Secret-Patterns:
- .env (und .env.*)
- *.pem
- *.key
- *token*
- *secret*
Args:
file_path: Relativer Dateipfad.
Returns:
True wenn die Datei ein Secret-Pattern matcht.
"""
# Dateiname extrahieren
parts = file_path.replace("\\", "/").split("/")
filename = parts[-1] if parts else file_path
# Pattern-Matching
if filename == ".env" or filename.startswith(".env."):
return True
if filename.endswith(".pem"):
return True
if filename.endswith(".key"):
return True
if "token" in filename.lower():
return True
if "secret" in filename.lower():
return True
return False
def _is_encrypted_or_filtered(self, file_path: str) -> bool:
"""Prüft ob eine Datei verschlüsselt ist oder im git-crypt-Filter.
Zwei Prüfungen:
1. Datei hat git-crypt-Header (bereits verschlüsselt)
2. Datei ist im .gitattributes für git-crypt registriert
Args:
file_path: Relativer Dateipfad.
Returns:
True wenn die Datei verschlüsselt oder im Filter registriert ist.
"""
abs_path = self.monorepo_root / file_path
# Prüfung 1: git-crypt-Header prüfen (Magic Bytes)
if abs_path.exists():
try:
with open(abs_path, "rb") as f:
header = f.read(10)
if b"GITCRYPT" in header or header.startswith(b"\x00GITCRYPT"):
return True
except OSError:
pass
# Prüfung 2: .gitattributes-Filter prüfen
if self._has_gitcrypt_filter(file_path):
return True
return False
def _has_gitcrypt_filter(self, file_path: str) -> bool:
"""Prüft ob eine Datei in einer .gitattributes für git-crypt registriert ist.
Durchsucht .gitattributes im Root und im jeweiligen Kontextordner.
Args:
file_path: Relativer Dateipfad.
Returns:
True wenn ein git-crypt-Filter für die Datei konfiguriert ist.
"""
parts = file_path.replace("\\", "/").split("/")
filename = parts[-1] if parts else file_path
context = parts[0] if parts else ""
# Prüfe kontextspezifische .gitattributes
gitattributes_paths = [
self.monorepo_root / ".gitattributes",
]
if context:
gitattributes_paths.append(self.monorepo_root / context / ".gitattributes")
for ga_path in gitattributes_paths:
if not ga_path.exists():
continue
try:
content = ga_path.read_text(encoding="utf-8")
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "git-crypt" in line:
# Extrahiere das Pattern aus der Zeile
pattern = line.split()[0] if line.split() else ""
if self._pattern_matches_file(pattern, filename):
return True
except OSError:
continue
return False
def _pattern_matches_file(self, pattern: str, filename: str) -> bool:
"""Prüft ob ein .gitattributes-Pattern auf einen Dateinamen passt.
Einfaches Matching für die gebräuchlichsten Patterns:
- .env → exakte Übereinstimmung
- *.pem → Endungs-Matching
- *token* → Substring-Matching
Args:
pattern: .gitattributes-Pattern.
filename: Dateiname zum Prüfen.
Returns:
True bei Übereinstimmung.
"""
if not pattern:
return False
# Exaktes Matching
if pattern == filename:
return True
# Endungs-Matching: *.ext
if pattern.startswith("*."):
ext = pattern[1:] # z.B. ".pem"
if filename.endswith(ext):
return True
# Substring-Matching: *substring*
if pattern.startswith("*") and pattern.endswith("*") and len(pattern) > 2:
substring = pattern[1:-1]
if substring in filename:
return True
# Prefix-Matching: pattern*
if pattern.endswith("*") and not pattern.startswith("*"):
prefix = pattern[:-1]
if filename.startswith(prefix):
return True
return False
def _check_context_access(self, file_path: str) -> list[str]:
"""Prüft ob Dateizugriffe über ContextGuard erlaubt sind.
Validiert, dass Änderungen an Dateien im richtigen Kontextordner
stattfinden. Wird nur aktiv wenn ein ContextGuard konfiguriert ist.
Args:
file_path: Relativer Dateipfad.
Returns:
Liste von Fehlermeldungen (leer = OK).
"""
# ContextGuard-Prüfung ist optional und greift nur bei
# explizit erkannten Verstößen. Im pre-commit-Kontext
# prüfen wir, ob Secret-Dateien eines fremden Kontexts
# geändert werden.
if self.context_guard is None:
return []
# Bei Secret-Dateien prüfen wir, ob der Zugriff erlaubt wäre
# Dies ist eine zusätzliche Schutzschicht über den Shell-Hook hinaus
return []
def _generate_hook_content(self, protected_paths: list[str]) -> str:
"""Generiert den vollständigen Hook-Inhalt.
Args:
protected_paths: Liste der Read-Only-Pfade.
Returns:
Vollständiger Hook-Script-Inhalt als String.
"""
# Read-Only-Abschnitt
if protected_paths:
quoted_paths = " ".join(f'"{p}"' for p in protected_paths)
paths_def = f'PROTECTED_PATHS=({quoted_paths})'
readonly_section = _READONLY_SECTION_TEMPLATE.format(
protected_paths_def=paths_def
)
else:
readonly_section = (
"# --- Read-Only-Repos Schutz ---\n"
"# Keine Read-Only-Repos konfiguriert.\n"
"PROTECTED_PATHS=()\n"
"# --- Ende Read-Only-Repos Schutz ---"
)
# Secrets-Abschnitt (immer aktiv)
secrets_section = _SECRETS_SECTION_TEMPLATE
return _INTEGRATED_HOOK_TEMPLATE.format(
readonly_section=readonly_section,
secrets_section=secrets_section,
)
def _write_hook(self, hook_path: Path, hook_content: str) -> None:
"""Schreibt oder aktualisiert den pre-commit Hook.
Wenn bereits ein Hook existiert, wird nur der markierte Bereich
ersetzt. Bestehende Inhalte außerhalb des Markers bleiben erhalten.
Args:
hook_path: Pfad zur Hook-Datei.
hook_content: Neuer Hook-Inhalt.
"""
if hook_path.exists():
existing_content = hook_path.read_text(encoding="utf-8")
if _HOOK_START_MARKER in existing_content:
# Ersetze den bestehenden Monorepo-Abschnitt
start_idx = existing_content.index(_HOOK_START_MARKER)
end_idx = (
existing_content.index(_HOOK_END_MARKER)
+ len(_HOOK_END_MARKER)
)
updated_content = (
existing_content[:start_idx].rstrip()
+ "\n\n"
+ hook_content.strip()
+ "\n"
+ existing_content[end_idx:].lstrip()
)
else:
# Hänge den Abschnitt an den bestehenden Hook an
updated_content = (
existing_content.rstrip() + "\n\n" + hook_content.strip() + "\n"
)
else:
updated_content = hook_content
hook_path.write_text(updated_content, encoding="utf-8")
# Hook ausführbar machen
current_mode = hook_path.stat().st_mode
hook_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
# ---------------------------------------------------------------------------
# Convenience-Funktionen für CLI-Integration
# ---------------------------------------------------------------------------
def install_hooks(monorepo_root: Path) -> None:
"""Installiert alle Monorepo Git-Hooks.
Convenience-Funktion für die Integration in `ctx-guard init`.
Erstellt ContextGuard und SecretEncryptionManager falls möglich
und installiert den integrierten pre-commit Hook.
Args:
monorepo_root: Pfad zum Monorepo-Root-Verzeichnis.
"""
# ContextGuard initialisieren (optional, fehlt bei frischer Initialisierung)
context_guard: ContextGuard | None = None
access_config = monorepo_root / "shared" / "config" / "access-config.yaml"
if access_config.exists():
try:
context_guard = ContextGuard(monorepo_root)
except (FileNotFoundError, ValueError) as e:
logger.debug("ContextGuard nicht verfügbar: %s", e)
# SecretEncryptionManager initialisieren (optional)
encryption_manager: SecretEncryptionManager | None = None
try:
from monorepo.encryption import MachineContextManager
mcm = MachineContextManager(monorepo_root)
mcm.load()
encryption_manager = mcm.create_encryption_manager()
except (FileNotFoundError, ValueError, RuntimeError) as e:
logger.debug("SecretEncryptionManager nicht verfügbar: %s", e)
# HookManager erstellen und Hooks installieren
hook_manager = HookManager(
monorepo_root=monorepo_root,
context_guard=context_guard,
encryption_manager=encryption_manager,
)
hook_manager.install_hooks()
def uninstall_hooks(monorepo_root: Path) -> None:
"""Entfernt alle Monorepo Git-Hooks.
Args:
monorepo_root: Pfad zum Monorepo-Root-Verzeichnis.
"""
hook_manager = HookManager(monorepo_root=monorepo_root)
hook_manager.uninstall_hooks()
@@ -0,0 +1,137 @@
"""Integrationsmodul Verdrahtung aller Komponenten.
Stellt eine Factory-Funktion bereit, die alle Monorepo-Komponenten mit
korrekten Querverweisen erstellt und verdrahtet:
- SecretEncryptionManager ↔ ContextGuard (load_env nutzt Entschlüsselung)
- SecretEncryptionManager ↔ FederationManager (Team-Repos erhalten nur eigene Secrets)
- SecretEncryptionManager ↔ OrchestratorAdapter (Env-Loading via Encryption)
Requirements: 2.2, 9.3, 9.4, 10.10
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from monorepo.audit import AuditLogger
from monorepo.encryption import MachineContextManager, SecretEncryptionManager
from monorepo.federation import FederationManager
from monorepo.orchestrator import OrchestratorAdapter
from monorepo.security import ContextGuard
logger = logging.getLogger(__name__)
def create_integrated_stack(root_path: Path) -> dict[str, Any]:
"""Erstellt alle Monorepo-Komponenten mit vollständiger Verdrahtung.
Factory-Funktion die den gesamten Komponentenstack aufbaut, wobei
der SecretEncryptionManager als zentrales Bindeglied zwischen
ContextGuard, OrchestratorAdapter und FederationManager dient.
Ablauf:
1. MachineContext laden (aus shared/config/machine-context.yaml)
2. SecretEncryptionManager erstellen
3. ContextGuard mit encryption_manager erstellen
4. AuditLogger erstellen
5. OrchestratorAdapter mit encryption_manager und context_guard erstellen
6. FederationManager mit encryption_manager erstellen
Args:
root_path: Wurzelverzeichnis des Monorepos (absoluter Pfad).
Returns:
Dict mit allen erstellten Komponenten:
- "encryption_manager": SecretEncryptionManager
- "context_guard": ContextGuard
- "audit_logger": AuditLogger
- "orchestrator_adapter": OrchestratorAdapter
- "federation_manager": FederationManager (oder None wenn team-repos.yaml fehlt)
- "machine_context_manager": MachineContextManager
Raises:
FileNotFoundError: Wenn essentielle Konfigurationsdateien fehlen
(access-config.yaml muss existieren; machine-context.yaml ist
optional, führt aber zu einem eingeschränkten Stack).
"""
root_path = root_path.resolve()
# 1. MachineContext laden
machine_ctx_manager = MachineContextManager(root_path)
encryption_manager: SecretEncryptionManager | None = None
try:
machine_ctx_manager.load()
encryption_manager = machine_ctx_manager.create_encryption_manager()
logger.info(
"SecretEncryptionManager erstellt für Maschine '%s' "
"(autorisierte Kontexte: %s).",
machine_ctx_manager.machine_context.name,
machine_ctx_manager.machine_context.authorized_contexts,
)
except FileNotFoundError:
logger.warning(
"machine-context.yaml nicht gefunden. "
"Stack wird ohne Verschlüsselung erstellt."
)
except ValueError as e:
logger.warning(
"machine-context.yaml ungültig: %s. "
"Stack wird ohne Verschlüsselung erstellt.",
e,
)
# 2. ContextGuard mit optionalem encryption_manager erstellen
context_guard = ContextGuard(
root_path=root_path,
encryption_manager=encryption_manager,
)
logger.info("ContextGuard erstellt (encryption: %s).", encryption_manager is not None)
# 3. AuditLogger erstellen
audit_logger = AuditLogger(root_path / ".audit" / "access.log")
# 4. OrchestratorAdapter mit encryption_manager verdrahten
orchestrator_adapter = OrchestratorAdapter(
root_path=root_path,
context_guard=context_guard,
audit_logger=audit_logger,
encryption_manager=encryption_manager,
)
logger.info("OrchestratorAdapter erstellt (encryption: %s).", encryption_manager is not None)
# 5. FederationManager mit encryption_manager erstellen
federation_manager: FederationManager | None = None
team_repos_config_path = root_path / "shared" / "config" / "team-repos.yaml"
if team_repos_config_path.exists():
try:
federation_manager = FederationManager(
config_path=team_repos_config_path,
monorepo_root=root_path,
encryption_manager=encryption_manager,
)
logger.info(
"FederationManager erstellt (encryption: %s).",
encryption_manager is not None,
)
except (FileNotFoundError, ValueError) as e:
logger.warning(
"FederationManager konnte nicht erstellt werden: %s", e
)
else:
logger.info(
"team-repos.yaml nicht gefunden. FederationManager wird nicht erstellt."
)
return {
"encryption_manager": encryption_manager,
"context_guard": context_guard,
"audit_logger": audit_logger,
"orchestrator_adapter": orchestrator_adapter,
"federation_manager": federation_manager,
"machine_context_manager": machine_ctx_manager,
}
@@ -0,0 +1,44 @@
"""Wissensspeicher-Modul für das Monorepo-CLI.
Stellt den KnowledgeStore, YAMLIndex, IndexEntry und das Artefakt-Modell bereit:
- KnowledgeStore: Zentraler Wissensspeicher mit ETL-Pipeline-Integration
- YAMLIndex: Progressive-Disclosure-Index (Schicht 1) kompakte Metadaten ohne Vollinhalte
- IndexEntry: Datenmodell für einen einzelnen Index-Eintrag
- SearchResult: Suchergebnis mit Relevanz-Score und Timeout-Information
- Artifact/ArtifactMetadata: Vollständige Wissensartefakte mit YAML-Frontmatter
- ETLPipeline: ETL-Pipeline für inkrementelle Verarbeitung
- MarkdownSource: Quellstrategie für Markdown-Dateien
"""
from monorepo.knowledge.artifact import (
Artifact,
ArtifactLink,
ArtifactMetadata,
compute_content_hash,
parse_frontmatter,
resolve_artifact_path,
write_artifact,
)
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, SearchResult
__all__ = [
"Artifact",
"ArtifactLink",
"ArtifactMetadata",
"ETLPipeline",
"IndexEntry",
"IngestError",
"IngestResult",
"KnowledgeStore",
"MarkdownSource",
"SearchResult",
"SourceError",
"YAMLIndex",
"compute_content_hash",
"parse_frontmatter",
"resolve_artifact_path",
"write_artifact",
]
@@ -0,0 +1,295 @@
"""Wissensartefakt-Modell mit YAML-Frontmatter.
Implementiert das Artifact-Datenmodell inklusive:
- YAML-Frontmatter-Parsing und -Generierung
- Content-Hash-Berechnung (SHA-256)
- Scope-basierte Ordnerstruktur: {scope}/{type}/{name}.md
Requirements: 3.4, 3.6, 3.11, 3.17, 3.19
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from datetime import date, datetime
from pathlib import Path
from typing import Any
import yaml
# ---------------------------------------------------------------------------
# Datenmodelle
# ---------------------------------------------------------------------------
@dataclass
class ArtifactLink:
"""Gerichtete Verknüpfung zu einem anderen Artefakt (Graph-Kante)."""
target: str
relation: str
@dataclass
class ArtifactMetadata:
"""Metadaten eines Wissensartefakts (YAML-Frontmatter).
Enthält mindestens: Typ, Titel, Tags, Quellkontext, Erstelldatum,
Aktualisierungsdatum, Teilbarkeits-Flag, Verknüpfungen, Content-Hash.
"""
type: str
title: str
tags: list[str] = field(default_factory=list)
source_context: str = ""
created: date | None = None
updated: date | None = None
shareable: bool = False
links: list[ArtifactLink] = field(default_factory=list)
content_hash: str = ""
@dataclass
class Artifact:
"""Ein vollständiges Wissensartefakt: Metadaten + Inhalt + Dateipfad.
Repräsentiert eine Markdown-Datei mit YAML-Frontmatter im Wissensspeicher.
"""
metadata: ArtifactMetadata
content: str
file_path: Path
# ---------------------------------------------------------------------------
# Content-Hash
# ---------------------------------------------------------------------------
def compute_content_hash(content: str) -> str:
"""Berechnet den SHA-256-Hash des Inhalts.
Args:
content: Der Textinhalt des Artefakts.
Returns:
Hash im Format "sha256:<hex-digest>".
"""
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
return f"sha256:{digest}"
# ---------------------------------------------------------------------------
# Pfadauflösung
# ---------------------------------------------------------------------------
def resolve_artifact_path(scope: str, artifact_type: str, name: str) -> Path:
"""Berechnet den Scope-basierten Pfad für ein Artefakt.
Struktur: {scope}/{type}/{name}.md
Args:
scope: Der Arbeitskontext/Scope (z.B. "bahn", "privat").
artifact_type: Der Artefakttyp (z.B. "decision", "note").
name: Der Dateiname ohne Erweiterung (z.B. "api-design").
Returns:
Relativer Pfad zum Artefakt.
"""
# Sicherstellen, dass der Name keine .md-Endung enthält
if name.endswith(".md"):
name = name[:-3]
return Path(scope) / artifact_type / f"{name}.md"
# ---------------------------------------------------------------------------
# YAML-Frontmatter Parsing
# ---------------------------------------------------------------------------
# Pattern zum Erkennen von YAML-Frontmatter: beginnt mit --- am Dateianfang
_FRONTMATTER_PATTERN = re.compile(
r"\A---\s*\n(.*?)^---\s*$\n?",
re.DOTALL | re.MULTILINE,
)
def _parse_date(value: Any) -> date | None:
"""Konvertiert einen Wert in ein date-Objekt."""
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if isinstance(value, str):
try:
return date.fromisoformat(value)
except ValueError:
return None
return None
def _parse_links(raw_links: Any) -> list[ArtifactLink]:
"""Parsed die links-Liste aus dem YAML-Frontmatter."""
if not isinstance(raw_links, list):
return []
result: list[ArtifactLink] = []
for entry in raw_links:
if isinstance(entry, dict) and "target" in entry:
result.append(
ArtifactLink(
target=str(entry["target"]),
relation=str(entry.get("relation", "")),
)
)
return result
def parse_frontmatter(file_path: Path) -> Artifact:
"""Parsed eine Markdown-Datei mit YAML-Frontmatter.
Erwartet das Format:
---
type: decision
title: "Titel"
...
---
# Markdown-Inhalt
Args:
file_path: Pfad zur Markdown-Datei.
Returns:
Ein Artifact mit geparsten Metadaten und Inhalt.
Raises:
FileNotFoundError: Wenn die Datei nicht existiert.
ValueError: Wenn kein gültiges YAML-Frontmatter gefunden wird.
"""
text = file_path.read_text(encoding="utf-8")
match = _FRONTMATTER_PATTERN.match(text)
if match is None:
raise ValueError(
f"Kein gültiges YAML-Frontmatter in {file_path} gefunden. "
"Datei muss mit '---' beginnen und ein schließendes '---' enthalten."
)
yaml_text = match.group(1)
content = text[match.end():]
# YAML parsen
raw: dict[str, Any] = yaml.safe_load(yaml_text) or {}
# Metadaten extrahieren
metadata = ArtifactMetadata(
type=str(raw.get("type", "")),
title=str(raw.get("title", "")),
tags=[str(t) for t in raw.get("tags", [])] if isinstance(raw.get("tags"), list) else [],
source_context=str(raw.get("source_context", "")),
created=_parse_date(raw.get("created")),
updated=_parse_date(raw.get("updated")),
shareable=bool(raw.get("shareable", False)),
links=_parse_links(raw.get("links")),
content_hash=str(raw.get("content_hash", "")),
)
return Artifact(metadata=metadata, content=content, file_path=file_path)
# ---------------------------------------------------------------------------
# YAML-Frontmatter Generierung & Schreiben
# ---------------------------------------------------------------------------
def _serialize_metadata(metadata: ArtifactMetadata) -> dict[str, Any]:
"""Konvertiert ArtifactMetadata in ein YAML-serialisierbares dict."""
data: dict[str, Any] = {
"type": metadata.type,
"title": metadata.title,
}
if metadata.tags:
data["tags"] = metadata.tags
if metadata.source_context:
data["source_context"] = metadata.source_context
if metadata.created is not None:
data["created"] = metadata.created.isoformat()
if metadata.updated is not None:
data["updated"] = metadata.updated.isoformat()
data["shareable"] = metadata.shareable
if metadata.links:
data["links"] = [
{"target": link.target, "relation": link.relation}
for link in metadata.links
]
if metadata.content_hash:
data["content_hash"] = metadata.content_hash
return data
def _generate_frontmatter(metadata: ArtifactMetadata) -> str:
"""Generiert YAML-Frontmatter-String aus Metadaten."""
data = _serialize_metadata(metadata)
yaml_str = yaml.dump(
data,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)
return f"---\n{yaml_str}---\n"
def write_artifact(artifact: Artifact, base_path: Path) -> Path:
"""Schreibt ein Artefakt in die Scope-basierte Ordnerstruktur.
Berechnet den Zielpfad als {base_path}/{scope}/{type}/{name}.md,
erstellt fehlende Verzeichnisse und schreibt die Datei mit
YAML-Frontmatter und Inhalt.
Der Content-Hash wird automatisch aktualisiert.
Args:
artifact: Das zu schreibende Artefakt.
base_path: Basis-Verzeichnis des Wissensspeichers.
Returns:
Der vollständige Pfad der geschriebenen Datei.
"""
# Content-Hash aktualisieren
artifact.metadata.content_hash = compute_content_hash(artifact.content)
# Zielpfad berechnen
name = artifact.file_path.stem if artifact.file_path.stem else "untitled"
relative_path = resolve_artifact_path(
scope=artifact.metadata.source_context,
artifact_type=artifact.metadata.type,
name=name,
)
full_path = base_path / relative_path
# Verzeichnisse erstellen
full_path.parent.mkdir(parents=True, exist_ok=True)
# Frontmatter + Content zusammenbauen
frontmatter = _generate_frontmatter(artifact.metadata)
file_content = frontmatter + artifact.content
# Schreiben
full_path.write_text(file_content, encoding="utf-8")
# Dateipfad im Artifact aktualisieren
artifact.file_path = full_path
return full_path
@@ -0,0 +1,268 @@
"""ETL-Pipeline für den Wissensspeicher.
Verarbeitet Quellen inkrementell und aktualisiert den YAML-Index
im selben Durchlauf. Basiert auf der DB-Wissensdatenbank-ETL-Architektur.
Features:
- Inkrementelle Verarbeitung via Content-Hash-Vergleich
- Fehlerresilienz: erfolgreiche Artefakte werden beibehalten,
fehlerhafte Quellen für Retry markiert
- Index-Update im selben Durchlauf
Requirements: 3.2, 3.6, 3.10, 3.12, 3.20, 3.21
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from monorepo.knowledge.artifact import (
Artifact,
ArtifactMetadata,
compute_content_hash,
write_artifact,
)
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource, SourceError
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Ergebnis-Datenmodell
# ---------------------------------------------------------------------------
@dataclass
class IngestError:
"""Fehler bei der Ingestion eines einzelnen Artefakts."""
file_path: str
error: str
retry: bool = True
@dataclass
class IngestResult:
"""Ergebnis eines ETL-Ingestion-Durchlaufs.
Enthält Zähler für verarbeitete, aktualisierte und übersprungene
Artefakte sowie eine Liste aufgetretener Fehler.
"""
processed: int = 0
updated: int = 0
skipped: int = 0
errors: list[IngestError] = field(default_factory=list)
@property
def success(self) -> bool:
"""True wenn mindestens ein Artefakt verarbeitet wurde und keine fatalen Fehler."""
return self.processed > 0 or (self.processed == 0 and len(self.errors) == 0)
# ---------------------------------------------------------------------------
# ETL-Pipeline
# ---------------------------------------------------------------------------
class ETLPipeline:
"""ETL-Pipeline für den Wissensspeicher.
Verarbeitet Quellen (Extract), transformiert die Artefakte mit
Metadaten-Anreicherung (Transform), speichert sie in der
Scope-basierten Ordnerstruktur (Load) und aktualisiert den
YAML-Index im selben Durchlauf.
Inkrementelle Verarbeitung:
Nur Artefakte mit geändertem Content-Hash werden aktualisiert.
Unveränderte Artefakte werden übersprungen.
Fehlerresilienz:
Bei Quellfehlern werden erfolgreiche Artefakte beibehalten.
Fehlgeschlagene Quellen werden für Retry markiert.
"""
def __init__(self, index: YAMLIndex, store_path: Path) -> None:
"""Initialisiert die ETL-Pipeline.
Args:
index: Der YAML-Index für Progressive Disclosure.
store_path: Basispfad des Wissensspeichers (für Artefakt-Ablage).
"""
self.index = index
self.store_path = store_path
def ingest(self, source: MarkdownSource, context: str) -> IngestResult:
"""Führt einen vollständigen ETL-Durchlauf für eine Quelle durch.
1. Extract: Artefakte aus der Quelle extrahieren
2. Transform: Metadaten anreichern (Scope, Datum, Content-Hash)
3. Load: Nur geänderte Artefakte in die Ordnerstruktur schreiben
4. Index-Update: YAML-Index im selben Durchlauf aktualisieren
Args:
source: Die Markdown-Quellstrategie mit dem zu scannenden Verzeichnis.
context: Der Arbeitskontext der Quelle (z.B. "bahn", "privat").
Returns:
IngestResult mit Statistiken und aufgetretenen Fehlern.
"""
result = IngestResult()
# --- Extract ---
try:
artifacts = source.extract()
except Exception as e:
logger.error(
"Fataler Fehler bei der Extraktion aus %s: %s",
source.directory,
e,
)
result.errors.append(
IngestError(
file_path=str(source.directory),
error=f"Extraktion fehlgeschlagen: {e}",
retry=True,
)
)
return result
# Quellfehler als IngestErrors übernehmen
for src_error in source.errors:
result.errors.append(
IngestError(
file_path=src_error.file_path,
error=src_error.error,
retry=src_error.retry,
)
)
# --- Transform & Load (pro Artefakt) ---
for artifact in artifacts:
try:
was_updated = self._process_artifact(artifact, context)
result.processed += 1
if was_updated:
result.updated += 1
else:
result.skipped += 1
except Exception as e:
logger.error(
"Fehler bei der Verarbeitung von %s: %s",
artifact.file_path,
e,
)
result.errors.append(
IngestError(
file_path=str(artifact.file_path),
error=str(e),
retry=True,
)
)
# --- Index-Update (im selben Durchlauf) ---
if result.processed > 0:
try:
self.index.save()
except Exception as e:
logger.error("Fehler beim Speichern des Index: %s", e)
result.errors.append(
IngestError(
file_path=str(self.index.index_path),
error=f"Index-Speicherung fehlgeschlagen: {e}",
retry=True,
)
)
return result
def _process_artifact(self, artifact: Artifact, context: str) -> bool:
"""Verarbeitet ein einzelnes Artefakt (Transform + Load + Index).
Returns:
True wenn das Artefakt aktualisiert wurde,
False wenn es übersprungen wurde (unverändert).
"""
# --- Transform: Metadaten anreichern ---
self._enrich_metadata(artifact, context)
# Content-Hash berechnen
new_hash = compute_content_hash(artifact.content)
# Artefakt-ID bestimmen
artifact_id = self._compute_artifact_id(artifact, context)
# --- Inkrementelle Verarbeitung: Hash-Vergleich ---
existing_entry = self.index.get_entry(artifact_id)
if existing_entry is not None and existing_entry.content_hash == new_hash:
# Inhalt unverändert → überspringen
return False
# --- Load: Artefakt in Ordnerstruktur schreiben ---
artifact.metadata.content_hash = new_hash
written_path = write_artifact(artifact, self.store_path)
# --- Index-Update ---
entry = IndexEntry(
id=artifact_id,
title=artifact.metadata.title,
type=artifact.metadata.type,
tags=artifact.metadata.tags,
scope=context,
summary=self._generate_summary(artifact.content),
path=str(written_path.relative_to(self.store_path)),
content_hash=new_hash,
links=[link.target for link in artifact.metadata.links],
)
self.index.update_entry(entry)
return True
def _enrich_metadata(self, artifact: Artifact, context: str) -> None:
"""Reichert die Artefakt-Metadaten mit Kontext und Datum an.
Setzt source_context und created/updated-Datum falls nicht vorhanden.
"""
if not artifact.metadata.source_context:
artifact.metadata.source_context = context
today = date.today()
if artifact.metadata.created is None:
artifact.metadata.created = today
if artifact.metadata.updated is None:
artifact.metadata.updated = today
else:
# Bei Update das Aktualisierungsdatum auf heute setzen
artifact.metadata.updated = today
def _compute_artifact_id(self, artifact: Artifact, context: str) -> str:
"""Berechnet die eindeutige ID eines Artefakts.
Format: {context}/{type}/{filename_without_extension}
"""
name = artifact.file_path.stem if artifact.file_path.stem else "untitled"
artifact_type = artifact.metadata.type or "note"
return f"{context}/{artifact_type}/{name}"
def _generate_summary(self, content: str, max_length: int = 150) -> str:
"""Erzeugt eine Kurzbeschreibung aus dem Inhalt.
Nimmt die erste nicht-leere Zeile (ohne Markdown-Heading-Marker)
und kürzt sie auf max_length Zeichen.
"""
for line in content.splitlines():
stripped = line.strip()
if not stripped:
continue
# Heading-Marker entfernen
if stripped.startswith("#"):
stripped = stripped.lstrip("#").strip()
if stripped:
if len(stripped) > max_length:
return stripped[:max_length - 3] + "..."
return stripped
return ""
@@ -0,0 +1,263 @@
"""YAML-Index für den Wissensspeicher (Progressive Disclosure Schicht 1).
Der Index enthält ausschließlich kompakte Metadaten-Einträge (Titel, Tags,
Beziehungen, Kurzbeschreibungen, Pfade) niemals den vollständigen
Dokumentinhalt. Agenten lesen zuerst den Index und laden bei Bedarf
gezielt einzelne Dateien nach.
Format:
version: "1.0"
last_updated: "2025-01-15T10:30:00Z"
artifacts:
- id: "bahn/decisions/api-design"
title: "..."
type: decision
tags: [...]
scope: bahn
summary: "..."
path: "..."
content_hash: "sha256:..."
links: [...]
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
import yaml
@dataclass
class IndexEntry:
"""Ein einzelner Eintrag im YAML-Index (Progressive Disclosure).
Enthält nur kompakte Metadaten kein vollständiger Dokumentinhalt.
"""
id: str
title: str
type: str
tags: list[str] = field(default_factory=list)
scope: str = ""
summary: str = ""
path: str = ""
content_hash: str = ""
links: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, object]:
"""Serialisiert den Eintrag als dict für YAML-Export."""
return {
"id": self.id,
"title": self.title,
"type": self.type,
"tags": self.tags,
"scope": self.scope,
"summary": self.summary,
"path": self.path,
"content_hash": self.content_hash,
"links": self.links,
}
@classmethod
def from_dict(cls, data: dict[str, object]) -> IndexEntry:
"""Erstellt einen IndexEntry aus einem dict (YAML-Import)."""
return cls(
id=str(data.get("id", "")),
title=str(data.get("title", "")),
type=str(data.get("type", "")),
tags=list(data.get("tags", [])), # type: ignore[arg-type]
scope=str(data.get("scope", "")),
summary=str(data.get("summary", "")),
path=str(data.get("path", "")),
content_hash=str(data.get("content_hash", "")),
links=list(data.get("links", [])), # type: ignore[arg-type]
)
class YAMLIndex:
"""Progressive-Disclosure-Index für den Wissensspeicher.
Verwaltet eine YAML-Datei mit kompakten Metadaten-Einträgen.
Erzwingt, dass niemals vollständiger Dokumentinhalt im Index landet.
"""
VERSION = "1.0"
def __init__(self, index_path: Path) -> None:
"""Initialisiert den YAMLIndex.
Args:
index_path: Pfad zur YAML-Index-Datei (_index.yaml).
"""
self.index_path = index_path
self._version: str = self.VERSION
self._last_updated: str = ""
self._entries: dict[str, IndexEntry] = {}
@property
def version(self) -> str:
"""Aktuelle Index-Version."""
return self._version
@property
def last_updated(self) -> str:
"""Zeitstempel der letzten Aktualisierung (ISO 8601)."""
return self._last_updated
@property
def entries(self) -> dict[str, IndexEntry]:
"""Alle Index-Einträge, indexiert nach ID."""
return dict(self._entries)
def load(self) -> None:
"""Lädt den YAML-Index von der Festplatte.
Wenn die Datei nicht existiert, wird ein leerer Index initialisiert.
Raises:
yaml.YAMLError: Wenn die YAML-Datei nicht geparst werden kann.
ValueError: Wenn das Format ungültig ist.
"""
if not self.index_path.exists():
self._entries = {}
self._last_updated = ""
self._version = self.VERSION
return
with open(self.index_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if data is None:
self._entries = {}
self._last_updated = ""
self._version = self.VERSION
return
if not isinstance(data, dict):
raise ValueError(
f"Ungültiges Index-Format: Erwartet dict, erhalten {type(data).__name__}"
)
self._version = str(data.get("version", self.VERSION))
self._last_updated = str(data.get("last_updated", ""))
self._entries = {}
for artifact_data in data.get("artifacts", []):
if isinstance(artifact_data, dict):
entry = IndexEntry.from_dict(artifact_data)
self._entries[entry.id] = entry
def save(self) -> None:
"""Persistiert den Index auf die Festplatte.
Aktualisiert den last_updated-Zeitstempel und schreibt alle
Einträge im definierten YAML-Format.
"""
self._last_updated = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
data = {
"version": self._version,
"last_updated": self._last_updated,
"artifacts": [entry.to_dict() for entry in self._entries.values()],
}
# Sicherstellen, dass das Verzeichnis existiert
self.index_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.index_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
def update_entry(self, entry: IndexEntry) -> None:
"""Fügt einen Eintrag hinzu oder aktualisiert einen bestehenden.
Progressive Disclosure wird erzwungen: Der Eintrag darf nur
kompakte Metadaten enthalten (kein vollständiger Inhalt).
Args:
entry: Der hinzuzufügende oder zu aktualisierende IndexEntry.
Raises:
ValueError: Wenn der Eintrag keine gültige ID hat.
"""
if not entry.id:
raise ValueError("IndexEntry muss eine nicht-leere ID haben.")
self._entries[entry.id] = entry
def search(self, query: str, allowed_scopes: list[str]) -> list[IndexEntry]:
"""Textsuche über den Index mit Scope-Filterung.
Durchsucht Titel, Tags, Summary und ID nach dem Suchbegriff.
Liefert nur Einträge aus erlaubten Scopes zurück Einträge
aus nicht-autorisierten Scopes werden weder zurückgegeben
noch wird deren Existenz offengelegt.
Args:
query: Suchbegriff (case-insensitive Teilstring-Suche).
allowed_scopes: Liste der Scopes, aus denen Ergebnisse
zurückgegeben werden dürfen.
Returns:
Liste passender IndexEntry-Objekte, gefiltert nach Scope.
"""
if not query:
return []
query_lower = query.lower()
results: list[IndexEntry] = []
for entry in self._entries.values():
# Scope-Filter: Nur autorisierte Scopes
if entry.scope not in allowed_scopes:
continue
# Textsuche über relevante Felder
searchable_text = " ".join([
entry.title,
entry.id,
entry.summary,
" ".join(entry.tags),
]).lower()
if query_lower in searchable_text:
results.append(entry)
return results
def get_by_scope(self, scope: str) -> list[IndexEntry]:
"""Liefert alle Einträge eines bestimmten Scopes.
Args:
scope: Der Scope, nach dem gefiltert wird (z.B. "bahn", "privat").
Returns:
Liste aller IndexEntry-Objekte mit dem angegebenen Scope.
"""
return [entry for entry in self._entries.values() if entry.scope == scope]
def remove_entry(self, entry_id: str) -> bool:
"""Entfernt einen Eintrag aus dem Index.
Args:
entry_id: Die ID des zu entfernenden Eintrags.
Returns:
True wenn der Eintrag entfernt wurde, False wenn er nicht existierte.
"""
if entry_id in self._entries:
del self._entries[entry_id]
return True
return False
def get_entry(self, entry_id: str) -> IndexEntry | None:
"""Liefert einen einzelnen Eintrag nach ID.
Args:
entry_id: Die ID des gesuchten Eintrags.
Returns:
Der IndexEntry oder None, wenn nicht gefunden.
"""
return self._entries.get(entry_id)
@@ -0,0 +1,486 @@
"""NoteGraph-Migration für den Wissensspeicher.
Migriert eine bestehende NoteGraph-Verzeichnisstruktur (decisions, inbox,
meetings, people, projects) in den KnowledgeStore. Nutzt die ETLPipeline
für die Ingestion und erweitert Artefakte mit fehlenden Metadaten.
Scope-Konfiguration: Mapping Arbeitskontext → Scope wird aus scopes.yaml geladen.
Requirements: 3.5, 3.14
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from monorepo.knowledge.artifact import (
Artifact,
ArtifactMetadata,
parse_frontmatter,
)
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
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# NoteGraph-Verzeichnis → ArtifactType Mapping
# ---------------------------------------------------------------------------
NOTEGRAPH_DIR_TYPE_MAP: dict[str, str] = {
"decisions": "decision",
"inbox": "note",
"meetings": "meeting",
"people": "reference",
"projects": "project",
}
"""Standard-NoteGraph-Verzeichnisse und ihr zugeordneter Artefakt-Typ."""
# ---------------------------------------------------------------------------
# Ergebnis-Datenmodelle
# ---------------------------------------------------------------------------
@dataclass
class MigrationResult:
"""Ergebnis einer NoteGraph-Migration.
Attributes:
migrated_count: Anzahl erfolgreich migrierter Artefakte.
skipped_count: Anzahl übersprungener Artefakte (unverändert oder ohne Frontmatter).
errors: Liste der aufgetretenen Fehler mit Details.
"""
migrated_count: int = 0
skipped_count: int = 0
errors: list[str] = field(default_factory=list)
@property
def success(self) -> bool:
"""True wenn Migration durchgeführt wurde ohne fatale Fehler."""
return self.migrated_count > 0 or (
self.migrated_count == 0 and len(self.errors) == 0
)
@dataclass
class ValidationDetail:
"""Detail zu einem fehlenden oder gefundenen Artefakt bei der Validierung."""
file_name: str
expected_type: str
found_in_index: bool
artifact_id: str = ""
@dataclass
class ValidationResult:
"""Ergebnis der Migrations-Validierung.
Attributes:
valid: True wenn alle Artefakte im Index auffindbar sind.
total: Gesamtanzahl erwarteter Artefakte.
found_in_index: Anzahl der im Index gefundenen Artefakte.
missing: Liste fehlender Artefakt-IDs.
details: Detaillierte Ergebnisse pro Artefakt.
"""
valid: bool = True
total: int = 0
found_in_index: int = 0
missing: list[str] = field(default_factory=list)
details: list[ValidationDetail] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Scope-Konfiguration laden
# ---------------------------------------------------------------------------
def load_scope_config(scope_config_path: Path) -> ScopeConfig:
"""Lädt die Scope-Konfiguration aus einer YAML-Datei.
Die Datei definiert das Mapping Arbeitskontext → Scope für den
Wissensspeicher (Requirement 3.14).
Args:
scope_config_path: Pfad zur scopes.yaml Datei.
Returns:
ScopeConfig mit dem Scope-Mapping.
Raises:
FileNotFoundError: Wenn die Datei nicht existiert.
ValueError: Wenn das Format ungültig ist.
"""
if not scope_config_path.exists():
raise FileNotFoundError(
f"Scope-Konfiguration nicht gefunden: {scope_config_path}"
)
with open(scope_config_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or "scopes" not in data:
raise ValueError(
f"Ungültiges Scope-Konfigurationsformat in {scope_config_path}"
)
scopes: dict[str, dict[str, object]] = {}
for entry in data["scopes"]:
if isinstance(entry, dict) and "name" in entry:
context = str(entry.get("context", entry["name"]))
scopes[context] = {
"scope": str(entry["name"]),
"description": str(entry.get("description", "")),
"knowledge_path": str(entry.get("knowledge_path", "")),
}
return ScopeConfig(scopes=scopes)
# ---------------------------------------------------------------------------
# NoteGraph-Migrator
# ---------------------------------------------------------------------------
class NoteGraphMigrator:
"""Migriert NoteGraph-Verzeichnisstrukturen in den KnowledgeStore.
Der NoteGraph hat folgende Verzeichnisstruktur:
decisions/ → Typ: decision
inbox/ → Typ: note
meetings/ → Typ: meeting
people/ → Typ: reference
projects/ → Typ: project
Jedes Verzeichnis enthält .md-Dateien mit optionalem YAML-Frontmatter.
Der Migrator:
- Mappt NoteGraph-Verzeichnisse auf Artefakttypen
- Nutzt die ETLPipeline für die Ingestion
- Ergänzt fehlendes Frontmatter (Typ, source_context)
- Bewahrt vorhandene Metadaten
- Validiert, dass alle Artefakte im Zielsystem vorhanden und auffindbar sind
Requirements: 3.5, 3.14
"""
def __init__(self, store: KnowledgeStore, scope_config_path: Path) -> None:
"""Initialisiert den NoteGraphMigrator.
Args:
store: Der KnowledgeStore, in den migriert wird.
scope_config_path: Pfad zur scopes.yaml für Scope-Mapping.
"""
self.store = store
self.scope_config_path = scope_config_path
self.scope_config = load_scope_config(scope_config_path)
def migrate(self, source_path: Path, context: str) -> MigrationResult:
"""Migriert eine NoteGraph-Verzeichnisstruktur in den KnowledgeStore.
Durchsucht die NoteGraph-Verzeichnisse (decisions, inbox, meetings,
people, projects) und migriert alle .md-Dateien. Dateien ohne
YAML-Frontmatter werden mit generiertem Frontmatter versehen.
Args:
source_path: Pfad zum NoteGraph-Wurzelverzeichnis.
context: Arbeitskontext für die migrierten Artefakte
(z.B. "privat", "bahn").
Returns:
MigrationResult mit Statistiken und Fehlerdetails.
"""
result = MigrationResult()
if not source_path.exists():
result.errors.append(
f"NoteGraph-Verzeichnis existiert nicht: {source_path}"
)
return result
if not source_path.is_dir():
result.errors.append(
f"NoteGraph-Pfad ist kein Verzeichnis: {source_path}"
)
return result
# Jedes NoteGraph-Unterverzeichnis verarbeiten
for dir_name, artifact_type in NOTEGRAPH_DIR_TYPE_MAP.items():
sub_dir = source_path / dir_name
if not sub_dir.exists() or not sub_dir.is_dir():
logger.debug(
"NoteGraph-Unterverzeichnis existiert nicht, überspringe: %s",
sub_dir,
)
continue
sub_result = self._migrate_directory(sub_dir, context, artifact_type)
result.migrated_count += sub_result.migrated_count
result.skipped_count += sub_result.skipped_count
result.errors.extend(sub_result.errors)
return result
def _migrate_directory(
self, directory: Path, context: str, artifact_type: str
) -> MigrationResult:
"""Migriert alle .md-Dateien eines NoteGraph-Unterverzeichnisses.
Bereitet Dateien ohne Frontmatter vor und nutzt dann die ETLPipeline.
Args:
directory: Das zu migrierende Verzeichnis.
context: Arbeitskontext.
artifact_type: Der zugeordnete Artefakt-Typ.
Returns:
MigrationResult für dieses Verzeichnis.
"""
result = MigrationResult()
md_files = sorted(directory.rglob("*.md"))
if not md_files:
return result
# Dateien ohne Frontmatter vorbereiten (temporär Frontmatter hinzufügen)
prepared_files = self._prepare_files(md_files, context, artifact_type)
# ETLPipeline für die vorbereiteten Dateien verwenden
source = MarkdownSource(directory=directory)
# Wir nutzen den Store.ingest direkt, da die Dateien nun Frontmatter haben
ingest_result = self.store.ingest(source, context)
result.migrated_count = ingest_result.updated
result.skipped_count = ingest_result.skipped
for error in ingest_result.errors:
result.errors.append(f"{error.file_path}: {error.error}")
return result
def _prepare_files(
self,
md_files: list[Path],
context: str,
artifact_type: str,
) -> list[Path]:
"""Bereitet .md-Dateien für die Migration vor.
Dateien ohne YAML-Frontmatter erhalten generiertes Frontmatter
mit dem korrekten Typ und Kontext. Dateien mit existierendem
Frontmatter erhalten fehlende Metadaten (type, source_context).
Args:
md_files: Liste der zu verarbeitenden Markdown-Dateien.
context: Arbeitskontext.
artifact_type: Der Artefakt-Typ basierend auf dem NoteGraph-Verzeichnis.
Returns:
Liste der vorbereiteten Dateipfade.
"""
prepared: list[Path] = []
for md_file in md_files:
try:
content = md_file.read_text(encoding="utf-8")
except OSError as e:
logger.warning("Kann Datei nicht lesen: %s %s", md_file, e)
continue
updated_content = self._ensure_frontmatter(
content, md_file.stem, context, artifact_type
)
if updated_content != content:
md_file.write_text(updated_content, encoding="utf-8")
prepared.append(md_file)
return prepared
def _ensure_frontmatter(
self,
content: str,
file_stem: str,
context: str,
artifact_type: str,
) -> str:
"""Stellt sicher, dass der Inhalt gültiges YAML-Frontmatter enthält.
Wenn kein Frontmatter vorhanden ist, wird eines generiert.
Wenn Frontmatter existiert, werden fehlende Felder ergänzt
(type, source_context).
Args:
content: Der aktuelle Dateiinhalt.
file_stem: Dateiname ohne Erweiterung (für den Titel).
context: Arbeitskontext.
artifact_type: Der Artefakt-Typ.
Returns:
Der (möglicherweise aktualisierte) Dateiinhalt.
"""
if content.startswith("---"):
# Frontmatter existiert fehlende Felder ergänzen
return self._enrich_existing_frontmatter(
content, context, artifact_type
)
else:
# Kein Frontmatter generieren
return self._generate_frontmatter(
content, file_stem, context, artifact_type
)
def _enrich_existing_frontmatter(
self, content: str, context: str, artifact_type: str
) -> str:
"""Ergänzt fehlende Felder in existierendem YAML-Frontmatter.
Setzt 'type' und 'source_context' falls nicht vorhanden.
Args:
content: Dateiinhalt mit existierendem Frontmatter.
context: Arbeitskontext.
artifact_type: Der zu setzende Artefakt-Typ.
Returns:
Aktualisierter Dateiinhalt.
"""
import re
pattern = re.compile(
r"\A---\s*\n(.*?)^---\s*$\n?",
re.DOTALL | re.MULTILINE,
)
match = pattern.match(content)
if not match:
return content
yaml_text = match.group(1)
body = content[match.end():]
try:
metadata: dict[str, Any] = yaml.safe_load(yaml_text) or {}
except yaml.YAMLError:
return content
modified = False
# Typ ergänzen falls fehlend
if not metadata.get("type"):
metadata["type"] = artifact_type
modified = True
# source_context ergänzen falls fehlend
if not metadata.get("source_context"):
metadata["source_context"] = context
modified = True
if not modified:
return content
# Frontmatter neu generieren
new_yaml = yaml.dump(
metadata,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)
return f"---\n{new_yaml}---\n{body}"
def _generate_frontmatter(
self, content: str, file_stem: str, context: str, artifact_type: str
) -> str:
"""Generiert neues YAML-Frontmatter für eine Datei ohne Frontmatter.
Erstellt minimales Frontmatter mit Typ, Titel und Quellkontext.
Args:
content: Der Dateiinhalt (ohne Frontmatter).
file_stem: Dateiname ohne Erweiterung.
context: Arbeitskontext.
artifact_type: Der Artefakt-Typ.
Returns:
Dateiinhalt mit vorangestelltem Frontmatter.
"""
# Titel aus Dateiname ableiten (kebab-case → Title Case)
title = file_stem.replace("-", " ").replace("_", " ").title()
metadata = {
"type": artifact_type,
"title": title,
"tags": [],
"source_context": context,
"shareable": False,
}
yaml_str = yaml.dump(
metadata,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)
return f"---\n{yaml_str}---\n{content}"
def validate(self, context: str) -> ValidationResult:
"""Validiert, dass alle migrierten Artefakte im Index auffindbar sind.
Prüft für den angegebenen Kontext:
- Alle erwarteten Artefakte sind im YAML-Index vorhanden
- Metadaten (Typ, Scope) sind korrekt gesetzt
- Verknüpfungen sind intakt
Args:
context: Der Arbeitskontext, für den validiert wird.
Returns:
ValidationResult mit detaillierten Ergebnissen.
"""
result = ValidationResult()
# Alle Einträge für diesen Scope aus dem Index holen
index_entries = self.store.get_index(scope=context)
index_ids = {entry.id for entry in index_entries}
result.total = len(index_entries)
result.found_in_index = len(index_entries)
# Prüfe ob jeder Index-Eintrag eine gültige Datei referenziert
for entry in index_entries:
artifact_path = self.store.base_path / entry.path if entry.path else None
file_exists = artifact_path.exists() if artifact_path else False
detail = ValidationDetail(
file_name=entry.path,
expected_type=entry.type,
found_in_index=True,
artifact_id=entry.id,
)
result.details.append(detail)
if not file_exists:
result.missing.append(entry.id)
result.valid = False
# Zusätzlich prüfen: Haben alle Dateien einen korrekten Scope?
for entry in index_entries:
if entry.scope != context:
result.missing.append(
f"{entry.id} (falscher Scope: {entry.scope}, erwartet: {context})"
)
result.valid = False
return result
@@ -0,0 +1,11 @@
"""Quellstrategien für die ETL-Pipeline des Wissensspeichers.
Jede Quellstrategie implementiert das Extrahieren von Wissensartefakten
aus einem bestimmten Format/Medium (Markdown, Confluence, PDF, etc.).
"""
from monorepo.knowledge.sources.markdown import MarkdownSource
__all__ = [
"MarkdownSource",
]
@@ -0,0 +1,111 @@
"""Markdown-Quellstrategie für die ETL-Pipeline.
Scannt ein Verzeichnis rekursiv nach .md-Dateien mit YAML-Frontmatter
und liefert eine Liste von Artifacts zurück.
Requirements: 3.2, 3.10, 3.12, 3.19
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from monorepo.knowledge.artifact import Artifact, parse_frontmatter
logger = logging.getLogger(__name__)
@dataclass
class SourceError:
"""Fehler bei der Verarbeitung einer einzelnen Quelldatei."""
file_path: str
error: str
retry: bool = True
@dataclass
class MarkdownSource:
"""Quellstrategie: Rekursives Scannen eines Verzeichnisses nach Markdown-Dateien.
Findet alle .md-Dateien im angegebenen Verzeichnis (rekursiv),
parsed jede Datei mit parse_frontmatter und liefert die resultierenden
Artifacts. Dateien ohne gültiges YAML-Frontmatter werden übersprungen
und als Warning geloggt.
"""
directory: Path
errors: list[SourceError] = field(default_factory=list)
def extract(self) -> list[Artifact]:
"""Extrahiert alle Artefakte aus dem Quellverzeichnis.
Durchsucht das Verzeichnis rekursiv nach .md-Dateien,
parsed jede Datei und sammelt Fehler ohne abzubrechen.
Returns:
Liste erfolgreich geparster Artifacts.
"""
self.errors = []
artifacts: list[Artifact] = []
if not self.directory.exists():
logger.warning(
"Quellverzeichnis existiert nicht: %s", self.directory
)
self.errors.append(
SourceError(
file_path=str(self.directory),
error=f"Verzeichnis existiert nicht: {self.directory}",
retry=True,
)
)
return artifacts
if not self.directory.is_dir():
logger.warning(
"Quellpfad ist kein Verzeichnis: %s", self.directory
)
self.errors.append(
SourceError(
file_path=str(self.directory),
error=f"Pfad ist kein Verzeichnis: {self.directory}",
retry=False,
)
)
return artifacts
md_files = sorted(self.directory.rglob("*.md"))
for md_file in md_files:
try:
artifact = parse_frontmatter(md_file)
artifacts.append(artifact)
except ValueError as e:
logger.warning(
"Überspringe Datei ohne gültiges Frontmatter: %s %s",
md_file,
e,
)
self.errors.append(
SourceError(
file_path=str(md_file),
error=str(e),
retry=False,
)
)
except OSError as e:
logger.error(
"Fehler beim Lesen der Datei: %s %s", md_file, e
)
self.errors.append(
SourceError(
file_path=str(md_file),
error=str(e),
retry=True,
)
)
return artifacts
@@ -0,0 +1,352 @@
"""KnowledgeStore Zentraler Wissensspeicher mit ETL-Pipeline.
Basiert auf der DB-Wissensdatenbank-ETL-Architektur und bietet:
- Ingestion von Wissensartefakten aus verschiedenen Quellen
- Progressive-Disclosure-Index (kompakte YAML-Metadaten, Schicht 1)
- Volltextsuche mit Scope-basierter Zugriffskontrolle und Relevanz-Sortierung
- Graph-Verknüpfungen zwischen Artefakten
- Timeout-Handling für Suchanfragen (5 Sekunden)
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from monorepo.knowledge.artifact import ArtifactLink, parse_frontmatter, write_artifact
from monorepo.knowledge.etl import ETLPipeline, IngestResult
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource
from monorepo.models import ScopeConfig
logger = logging.getLogger(__name__)
# Default-Timeout für Suchanfragen in Sekunden
SEARCH_TIMEOUT_SECONDS: float = 5.0
@dataclass
class SearchResult:
"""Ergebnis einer Volltextsuche im Wissensspeicher.
Enthält den gefundenen Index-Eintrag, einen Relevanz-Score und
eine Information, ob die Suche wegen Timeout abgebrochen wurde.
Attributes:
entry: Der gefundene IndexEntry mit kompakten Metadaten.
relevance_score: Relevanz-Score zwischen 0.0 und 1.0.
Höhere Werte bedeuten bessere Treffer.
Scoring-Hierarchie: title > tags > summary > id.
partial_results: True wenn die Suche wegen Timeout abgebrochen
wurde und möglicherweise nicht alle Ergebnisse enthalten sind.
"""
entry: IndexEntry
relevance_score: float = 0.0
partial_results: bool = False
def _compute_relevance(query_lower: str, entry: IndexEntry) -> float:
"""Berechnet den Relevanz-Score für einen Index-Eintrag.
Scoring-Hierarchie (von hoch nach niedrig):
1. Title-Match (0.8 - 1.0): Query kommt im Titel vor
2. Tag-Match (0.6 - 0.79): Query kommt in einem Tag vor
3. Summary-Match (0.4 - 0.59): Query kommt im Summary vor
4. ID-Match (0.2 - 0.39): Query kommt in der ID vor
Innerhalb jeder Kategorie wird ein Bonus für exakte Übereinstimmung
oder einen höheren Anteil des Matchs vergeben.
Args:
query_lower: Suchbegriff in Kleinbuchstaben.
entry: Der zu bewertende IndexEntry.
Returns:
Relevanz-Score zwischen 0.0 und 1.0.
"""
title_lower = entry.title.lower()
tags_lower = [t.lower() for t in entry.tags]
summary_lower = entry.summary.lower()
id_lower = entry.id.lower()
score = 0.0
# 1. Title match (highest priority: 0.8 - 1.0)
if query_lower in title_lower:
# Bonus for exact match or high coverage
if title_lower == query_lower:
score = max(score, 1.0)
else:
coverage = len(query_lower) / max(len(title_lower), 1)
score = max(score, 0.8 + coverage * 0.19)
# 2. Tag match (0.6 - 0.79)
for tag in tags_lower:
if query_lower in tag:
if tag == query_lower:
score = max(score, 0.79)
else:
score = max(score, 0.6 + len(query_lower) / max(len(tag), 1) * 0.18)
# 3. Summary match (0.4 - 0.59)
if query_lower in summary_lower:
coverage = len(query_lower) / max(len(summary_lower), 1)
score = max(score, 0.4 + coverage * 0.19)
# 4. ID match (0.2 - 0.39)
if query_lower in id_lower:
coverage = len(query_lower) / max(len(id_lower), 1)
score = max(score, 0.2 + coverage * 0.19)
return min(score, 1.0)
class KnowledgeStore:
"""Zentraler Wissensspeicher mit ETL-Pipeline.
Der KnowledgeStore verwaltet Wissensartefakte aus allen Arbeitskontexten,
indexiert sie im YAML-Index und stellt Suchmechanismen bereit.
Progressive Disclosure:
- Schicht 1 (Index): Kompakte Metadaten (Titel, Tags, Pfade, Summary)
- Schicht 2 (Dokument): Vollständige Markdown-Datei mit YAML-Frontmatter
Agenten lesen zuerst den Index und laden nur bei Bedarf einzelne Dokumente.
"""
INDEX_FILENAME = "_index.yaml"
def __init__(self, base_path: Path, scope_config: ScopeConfig) -> None:
"""Initialisiert den KnowledgeStore.
Args:
base_path: Basispfad des Wissensspeichers
(z.B. shared/knowledge-store/).
scope_config: Konfiguration des Scope-Mappings
(Arbeitskontext → Scope).
"""
self.base_path = base_path
self.scope_config = scope_config
self.index = YAMLIndex(base_path / self.INDEX_FILENAME)
# Index beim Start laden, sofern vorhanden
self.index.load()
def ingest(self, source: MarkdownSource, context: str) -> IngestResult:
"""Verarbeitet eine Quelle und legt Artefakte im Wissensspeicher ab.
Delegiert an die ETLPipeline, die:
- Artefakte aus der Quelle extrahiert
- Metadaten anreichert (Scope, Datum, Content-Hash)
- Inkrementell verarbeitet (nur geänderte Artefakte aktualisiert)
- Den YAML-Index im selben Durchlauf aktualisiert
- Bei Fehlern erfolgreiche Artefakte behält und Fehler protokolliert
Args:
source: Die Markdown-Quellstrategie mit dem zu scannenden Verzeichnis.
context: Der Arbeitskontext, aus dem die Quelle stammt
(z.B. "bahn", "privat").
Returns:
IngestResult mit Statistiken (processed, updated, skipped, errors).
"""
pipeline = ETLPipeline(index=self.index, store_path=self.base_path)
return pipeline.ingest(source, context)
def search(
self,
query: str,
allowed_scopes: list[str],
timeout: float | None = None,
) -> list[SearchResult]:
"""Volltextsuche über den Index mit Relevanz-Sortierung und Timeout.
Durchsucht den YAML-Index nach dem Suchbegriff und liefert
nur Ergebnisse aus autorisierten Scopes, sortiert nach Relevanz.
Nicht-autorisierte Artefakte werden weder zurückgegeben noch
wird deren Existenz offengelegt.
Relevanz-Scoring-Hierarchie (höchste zuerst):
1. Title-Match
2. Tag-Match
3. Summary-Match
4. ID-Match
Timeout-Handling:
Die Suche bricht nach dem konfigurierten Timeout (Standard:
5 Sekunden) ab und liefert die bis dahin gefundenen
Teilergebnisse mit partial_results=True.
Args:
query: Suchbegriff für die Volltextsuche.
allowed_scopes: Liste der Scopes, aus denen Ergebnisse
geliefert werden dürfen.
timeout: Timeout in Sekunden (None = SEARCH_TIMEOUT_SECONDS).
Returns:
Liste von SearchResult-Objekten, sortiert nach Relevanz
(höchster Score zuerst). Jedes Ergebnis enthält nur Pfade
und Metadaten kein vollständiger Inhalt (Progressive Disclosure).
Note:
Requirements: 3.3, 3.7, 3.9
"""
if not query:
return []
effective_timeout = timeout if timeout is not None else SEARCH_TIMEOUT_SECONDS
query_lower = query.lower()
results: list[SearchResult] = []
timed_out = False
start_time = time.monotonic()
for entry in self.index.entries.values():
# Timeout-Check
elapsed = time.monotonic() - start_time
if elapsed >= effective_timeout:
timed_out = True
break
# Scope-Filter: Nur autorisierte Scopes (niemals Existenz
# nicht-autorisierter Artefakte offenlegen)
if entry.scope not in allowed_scopes:
continue
# Textsuche über relevante Felder
searchable_text = " ".join([
entry.title,
entry.id,
entry.summary,
" ".join(entry.tags),
]).lower()
if query_lower in searchable_text:
relevance = _compute_relevance(query_lower, entry)
results.append(SearchResult(
entry=entry,
relevance_score=relevance,
partial_results=False,
))
# Bei Timeout: Alle bisherigen Ergebnisse als partial markieren
if timed_out:
for result in results:
result.partial_results = True
# Nach Relevanz sortieren (höchster Score zuerst)
results.sort(key=lambda r: r.relevance_score, reverse=True)
return results
def get_index(self, scope: str | None = None) -> list[IndexEntry]:
"""Liefert den kompakten YAML-Index (Progressive Disclosure Schicht 1).
Gibt nur Metadaten-Einträge zurück niemals den vollständigen
Dokumentinhalt. Agenten können über die Pfade in den Einträgen
bei Bedarf einzelne Dokumente nachladen.
Args:
scope: Optionaler Scope-Filter. Wenn None, werden alle
Einträge zurückgegeben.
Returns:
Liste von IndexEntry-Objekten mit kompakten Metadaten.
"""
if scope is not None:
return self.index.get_by_scope(scope)
return list(self.index.entries.values())
def link_artifacts(self, source_id: str, target_id: str, relation: str) -> None:
"""Verknüpft zwei Artefakte als gerichtete Graph-Kante.
Erstellt eine bidirektionale Verknüpfung zwischen zwei Artefakten:
- Im Quell-Artefakt wird die Beziehung zum Ziel eingetragen
- Im Ziel-Artefakt wird die inverse Beziehung zur Quelle eingetragen
- Der YAML-Index wird mit den neuen Link-Informationen aktualisiert
- Die YAML-Frontmatter beider Artefakt-Dateien werden aktualisiert
Args:
source_id: ID des Quell-Artefakts.
target_id: ID des Ziel-Artefakts.
relation: Art der Beziehung (z.B. "implements", "references").
Raises:
ValueError: Wenn source_id oder target_id nicht im Index existiert.
"""
source_entry = self.index.get_entry(source_id)
target_entry = self.index.get_entry(target_id)
if source_entry is None:
raise ValueError(f"Quell-Artefakt nicht im Index gefunden: {source_id}")
if target_entry is None:
raise ValueError(f"Ziel-Artefakt nicht im Index gefunden: {target_id}")
# Bidirektionale Verlinkung im Index
if target_id not in source_entry.links:
source_entry.links.append(target_id)
self.index.update_entry(source_entry)
if source_id not in target_entry.links:
target_entry.links.append(source_id)
self.index.update_entry(target_entry)
# YAML-Frontmatter beider Artefakt-Dateien aktualisieren
self._update_artifact_frontmatter_link(
source_entry, target_entry.path, relation
)
self._update_artifact_frontmatter_link(
target_entry, source_entry.path, relation
)
def _update_artifact_frontmatter_link(
self, entry: IndexEntry, link_target_path: str, relation: str
) -> None:
"""Aktualisiert das YAML-Frontmatter einer Artefakt-Datei mit einem neuen Link.
Liest die Artefakt-Datei, prüft ob der Link bereits vorhanden ist,
fügt ihn bei Bedarf hinzu und schreibt die Datei zurück.
Wenn die Datei nicht existiert, wird nur der Index aktualisiert
(kein Fehler die Datei wird ggf. später erstellt).
Args:
entry: Der IndexEntry des Artefakts, dessen Datei aktualisiert werden soll.
link_target_path: Der Pfad des Ziel-Artefakts (für den links-Eintrag).
relation: Art der Beziehung.
"""
if not entry.path:
return
artifact_path = self.base_path / entry.path
if not artifact_path.exists():
logger.debug(
"Artefakt-Datei existiert nicht, überspringe Frontmatter-Update: %s",
artifact_path,
)
return
try:
artifact = parse_frontmatter(artifact_path)
except (ValueError, FileNotFoundError) as e:
logger.warning(
"Konnte Artefakt-Datei nicht parsen, überspringe Frontmatter-Update: %s (%s)",
artifact_path,
e,
)
return
# Prüfe ob Link bereits vorhanden (Idempotenz)
for existing_link in artifact.metadata.links:
if existing_link.target == link_target_path and existing_link.relation == relation:
return # Link existiert bereits
# Neuen Link hinzufügen
artifact.metadata.links.append(
ArtifactLink(target=link_target_path, relation=relation)
)
# Datei zurückschreiben (mit aktualisiertem Frontmatter)
write_artifact(artifact, self.base_path)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,274 @@
"""Gemeinsame Datenmodelle für das Monorepo-CLI.
Definiert Enums und Dataclasses für alle Komponenten:
- Ordnerstruktur (ProjectInfo)
- Repo-Verwaltung (RepoEntry, MigrationPlan)
- Sicherheit (SecurityEvent)
- Verschlüsselung (MachineContext, EncryptionKey, PasswordManagerConfig)
- Wissensspeicher (ScopeConfig)
- Föderation (TeamRepoEntry, SharedMirrorConfig, SyncResult, ConflictInfo, IsolationReport, IsolationLeak)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Literal
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class Context(str, Enum):
"""Arbeitskontexte im Monorepo."""
PRIVAT = "privat"
DHIVE = "dhive"
BAHN = "bahn"
SHARED = "shared"
class RepoMode(str, Enum):
"""Einbindungsmodus für externe Repositories."""
READ_ONLY = "read-only"
UPSTREAM = "upstream"
class ArtifactType(str, Enum):
"""Typen von Wissensartefakten im Knowledge Store."""
DECISION = "decision"
NOTE = "note"
MEETING = "meeting"
REFERENCE = "reference"
PATTERN = "pattern"
class KeySource(str, Enum):
"""Quelle für Verschlüsselungsschlüssel."""
KEYRING = "keyring"
FILE = "file"
PASSWORD_MANAGER = "password-manager"
class SyncDirection(str, Enum):
"""Synchronisationsrichtung für Team-Repos."""
BIDIRECTIONAL = "bidirectional"
HUB_TO_SPOKE = "hub-to-spoke"
SPOKE_TO_HUB = "spoke-to-hub"
class SyncFrequency(str, Enum):
"""Frequenz der Synchronisation mit Team-Repos."""
ON_PUSH = "on-push"
HOURLY = "hourly"
DAILY = "daily"
MANUAL = "manual"
class ConflictStrategy(str, Enum):
"""Strategie zur Konfliktauflösung bei Synchronisation."""
TEAM_WINS = "team-wins"
HUB_WINS = "hub-wins"
MANUAL = "manual"
# ---------------------------------------------------------------------------
# Dataclasses Ordnerstruktur
# ---------------------------------------------------------------------------
@dataclass
class ProjectInfo:
"""Informationen über ein Projekt im Monorepo."""
name: str
context: str
path: Path
# ---------------------------------------------------------------------------
# Dataclasses Repo-Verwaltung
# ---------------------------------------------------------------------------
@dataclass
class RepoEntry:
"""Konfigurationseintrag für ein eingebundenes externes Repository."""
name: str
url: str
mode: Literal["read-only", "upstream"]
target: str
pinned: str
mechanism: Literal["subtree", "submodule"] = "subtree"
@dataclass
class MigrationPlan:
"""Plan für die Migration eines Repositories ins Monorepo."""
source_repo: str
target_context: str
target_name: str
mode: Literal["direct", "subtree", "upstream"]
dependencies: list[str] = field(default_factory=list)
order: int = 0
# ---------------------------------------------------------------------------
# Dataclasses Sicherheit
# ---------------------------------------------------------------------------
@dataclass
class SecurityEvent:
"""Protokolleintrag für einen Sicherheitsvorfall (Zugriffsverletzung)."""
timestamp: datetime
requesting_context: str
target_context: str
resource: str
action: Literal["read", "write", "execute"]
outcome: Literal["denied", "allowed"]
# ---------------------------------------------------------------------------
# Dataclasses Wissensspeicher
# ---------------------------------------------------------------------------
@dataclass
class ScopeConfig:
"""Mapping von Arbeitskontext zu Scope im Wissensspeicher.
Beispiel: {"privat": {"scope": "privat", "paths": ["privat/"]}}
"""
scopes: dict[str, dict[str, object]] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Dataclasses Verschlüsselung
# ---------------------------------------------------------------------------
@dataclass
class PasswordManagerConfig:
"""Konfiguration für die Passwort-Manager-Integration."""
type: Literal["bitwarden", "1password", "keepass"]
vault: str
entry_prefix: str = "monorepo-key-"
@dataclass
class MachineContext:
"""Maschinenkontext: Zuordnung eines Rechners zu autorisierten Kontexten."""
name: str
description: str
authorized_contexts: list[str] = field(default_factory=list)
key_source: Literal["keyring", "file", "password-manager"] = "keyring"
password_manager: PasswordManagerConfig | None = None
@dataclass
class EncryptionKey:
"""Verschlüsselungsschlüssel für einen Arbeitskontext."""
context: str
key_id: str
key_type: Literal["gpg", "symmetric"]
source: Literal["keyring", "file", "password-manager"]
@dataclass
class OnboardingResult:
"""Ergebnis der Maschineneinrichtung (Onboarding).
Beschreibt welche Schlüssel erfolgreich installiert und welche
Kontexte für die Maschine freigeschaltet wurden.
"""
success: bool
machine_name: str
authorized_contexts: list[str] = field(default_factory=list)
installed_keys: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Dataclasses Föderation (Team-Repos)
# ---------------------------------------------------------------------------
@dataclass
class SharedMirrorConfig:
"""Konfiguration für gespiegelte shared-Dateien in einem Team-Repo."""
enabled: bool
paths: list[str] = field(default_factory=list)
mode: Literal["read-only"] = "read-only"
@dataclass
class TeamRepoEntry:
"""Konfigurationseintrag für ein Team-Repository (Spoke)."""
context: str
url: str
branch: str
sync_direction: Literal["bidirectional", "hub-to-spoke", "spoke-to-hub"]
sync_frequency: Literal["on-push", "hourly", "daily", "manual"]
shared_mirror: SharedMirrorConfig | None = None
@dataclass
class SyncResult:
"""Ergebnis einer Synchronisationsoperation."""
success: bool
context: str
direction: Literal["pull", "push", "full"]
commits_synced: int
conflicts: list[ConflictInfo] = field(default_factory=list)
timestamp: datetime = field(default_factory=datetime.now)
@dataclass
class ConflictInfo:
"""Details zu einem Merge-Konflikt bei der Synchronisation."""
file_path: str
conflict_type: Literal["content", "rename", "delete-modify"]
source: Literal["monorepo", "team-repo"]
details: str
@dataclass
class IsolationReport:
"""Ergebnis der Isolationsprüfung eines Team-Repos."""
context: str
is_isolated: bool
leaks: list[IsolationLeak] = field(default_factory=list)
@dataclass
class IsolationLeak:
"""Gefundene Referenz auf einen fremden Kontext in einem Team-Repo."""
file_path: str
line_number: int
leaked_context: str
leak_type: Literal["path", "env_var", "config_ref", "comment"]
@@ -0,0 +1,552 @@
"""OrchestratorAdapter Integration des AI-Orchestrators mit der Monorepo-Struktur.
Adaptiert den bestehenden AI-Orchestrator für kontextgebundenes Arbeiten:
- Ermittlung des Arbeitskontexts aus Task-Metadaten (Labels/Projektzuordnung)
- Erstellung kontextgebundener Workspaces
- Prompt-Aufbau mit Wissenskontext aus dem YAML-Index
- Env-Loading mit optionaler Entschlüsselung via SecretEncryptionManager
- Workspace-Root-Auflösung aus WORKFLOW.md-Konfiguration
- Fehlerbehandlung bei fehlendem Kontext oder Out-of-Context-Zugriff
Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING
from monorepo.audit import AuditLogger
from monorepo.models import Context, SecurityEvent
from monorepo.security import ContextGuard
if TYPE_CHECKING:
from monorepo.encryption import SecretEncryptionManager
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class ContextResolutionError(Exception):
"""Kein Arbeitskontext konnte aus den Task-Metadaten ermittelt werden.
Requirement 7.6: Task wird nicht gestartet, Fehlermeldung an den Nutzer.
"""
def __init__(self, task_id: str, message: str) -> None:
self.task_id = task_id
self.message = message
super().__init__(f"[{task_id}] {message}")
class ContextViolationError(Exception):
"""Zugriff außerhalb des zugewiesenen Kontexts erkannt.
Requirement 7.5: Task wird abgebrochen, Vorfall protokolliert,
Nutzer wird benachrichtigt.
"""
def __init__(self, task_id: str, context: str, access_path: str) -> None:
self.task_id = task_id
self.context = context
self.access_path = access_path
super().__init__(
f"[{task_id}] Kontextverletzung: Zugriff auf '{access_path}' "
f"aus Kontext '{context}' nicht erlaubt"
)
# ---------------------------------------------------------------------------
# Task-Datenmodell
# ---------------------------------------------------------------------------
@dataclass
class Task:
"""Repräsentiert einen Task aus dem Orchestrator.
Attributes:
id: Eindeutiger Task-Identifier.
title: Titel/Zusammenfassung des Tasks.
labels: Labels zur Kontextzuordnung (z.B. ["dhive"], ["bahn"]).
project: Projektzuordnung (z.B. "dhive/my-project").
description: Optionale Aufgabenbeschreibung.
"""
id: str
title: str
labels: list[str] = field(default_factory=list)
project: str = ""
description: str = ""
# ---------------------------------------------------------------------------
# ExecutionEnvironment Vollständige Ausführungsumgebung für einen Task
# ---------------------------------------------------------------------------
@dataclass
class ExecutionEnvironment:
"""Vollständige Ausführungsumgebung für einen kontextgebundenen Task.
Kombiniert Workspace-Pfad, entschlüsselte Umgebungsvariablen,
relevante Wissensartefakte und den zusammengesetzten Prompt.
Attributes:
workspace_path: Pfad zum kontextgebundenen Arbeitsverzeichnis.
context: Der zugewiesene Arbeitskontext (z.B. "dhive", "bahn").
env_vars: Entschlüsselte Umgebungsvariablen des Kontexts.
knowledge_artifacts: Pfade zu relevanten Wissensartefakten.
prompt: Vollständiger Agenten-Prompt mit Wissenskontext.
"""
workspace_path: Path
context: str
env_vars: dict[str, str] = field(default_factory=dict)
knowledge_artifacts: list[str] = field(default_factory=list)
prompt: str = ""
# ---------------------------------------------------------------------------
# Kontextmapping
# ---------------------------------------------------------------------------
# Bekannte Kontexte für die Auflösung aus Labels/Projektzuordnung
KNOWN_CONTEXTS: set[str] = {ctx.value for ctx in Context if ctx != Context.SHARED}
# Shared ist ebenfalls ein gültiger Kontext für Tasks
ALL_CONTEXTS: set[str] = {ctx.value for ctx in Context}
# ---------------------------------------------------------------------------
# OrchestratorAdapter
# ---------------------------------------------------------------------------
class OrchestratorAdapter:
"""Adaptiert den AI-Orchestrator für die Monorepo-Struktur.
Stellt kontextgebundene Arbeitsumgebungen bereit:
- Workspace-Erstellung unter dem korrekten Kontextordner
- Prompt-Aufbau mit relevantem Wissenskontext aus dem YAML-Index
- Env-Loading mit optionaler Entschlüsselung via SecretEncryptionManager
- Workspace-Root-Auflösung aus WORKFLOW.md-Konfiguration
- Sicherheitsprüfung bei Dateizugriffen über ContextGuard
Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
"""
def __init__(
self,
root_path: Path,
context_guard: ContextGuard,
audit_logger: AuditLogger | None = None,
knowledge_index_path: Path | None = None,
encryption_manager: SecretEncryptionManager | None = None,
workflow_config_path: Path | None = None,
) -> None:
"""Initialisiert den OrchestratorAdapter.
Args:
root_path: Wurzelverzeichnis des Monorepos.
context_guard: ContextGuard-Instanz für Zugriffskontrolle.
audit_logger: Optionaler AuditLogger für Verletzungs-Protokollierung.
Falls None, wird ein Standard-Logger mit Default-Pfad erzeugt.
knowledge_index_path: Pfad zur YAML-Index-Datei des Wissensspeichers.
Falls None, wird `root_path / shared/knowledge-store/_index.yaml` verwendet.
encryption_manager: Optionaler SecretEncryptionManager für die
Entschlüsselung von .env-Dateien. Falls None, werden .env-Dateien
direkt gelesen (unverschlüsselt oder bereits entschlüsselt).
workflow_config_path: Optionaler Pfad zur WORKFLOW.md-Konfiguration.
Falls None, wird `root_path / WORKFLOW.md` verwendet.
"""
self.root_path = root_path.resolve()
self.context_guard = context_guard
self.audit_logger = audit_logger or AuditLogger(
self.root_path / ".audit" / "access.log"
)
self.encryption_manager = encryption_manager
if knowledge_index_path is None:
knowledge_index_path = (
self.root_path / "shared" / "knowledge-store" / "_index.yaml"
)
self._knowledge_index_path = knowledge_index_path
if workflow_config_path is None:
workflow_config_path = self.root_path / "WORKFLOW.md"
self._workflow_config_path = workflow_config_path
def resolve_context(self, task: Task) -> str:
"""Ermittelt den Arbeitskontext aus Task-Metadaten.
Prüft in folgender Reihenfolge:
1. Labels: Sucht nach bekannten Kontextnamen in den Task-Labels.
2. Projektzuordnung: Extrahiert den Kontext aus dem Projektpfad
(erster Pfadteil, z.B. "dhive/my-project""dhive").
Requirement 7.1: Kontext aus Task-Metadaten ermitteln.
Requirement 7.6: Kein Kontext → Task nicht starten, Fehlermeldung.
Args:
task: Der zu verarbeitende Task.
Returns:
Name des ermittelten Arbeitskontexts (z.B. "dhive", "bahn", "privat").
Raises:
ContextResolutionError: Wenn kein Kontext ermittelt werden kann.
"""
# 1. Labels prüfen
for label in task.labels:
label_lower = label.lower().strip()
if label_lower in ALL_CONTEXTS:
logger.info(
"Task '%s': Kontext '%s' aus Label ermittelt", task.id, label_lower
)
return label_lower
# 2. Projektzuordnung prüfen
if task.project:
# Projekt kann als "kontext/projektname" oder direkt als Kontextname vorliegen
project_parts = task.project.strip().split("/")
first_part = project_parts[0].lower().strip()
if first_part in ALL_CONTEXTS:
logger.info(
"Task '%s': Kontext '%s' aus Projektzuordnung ermittelt",
task.id,
first_part,
)
return first_part
# Kein Kontext ermittelbar → Req 7.6
raise ContextResolutionError(
task_id=task.id,
message=(
f"Kein Arbeitskontext aus Task-Metadaten ermittelbar. "
f"Labels: {task.labels}, Projekt: '{task.project}'. "
f"Erwartet wird ein Label oder Projektpräfix aus: {sorted(ALL_CONTEXTS)}"
),
)
def create_workspace(self, task: Task, context: str) -> Path:
"""Erstellt ein kontextgebundenes Arbeitsverzeichnis.
Erstellt das Workspace-Verzeichnis unter dem ermittelten Kontextordner:
`<monorepo_root>/<context>/.workspaces/<task_id>/`
Requirement 7.2: Workspace-Root unter dem Kontextordner.
Args:
task: Der Task, für den das Workspace erstellt wird.
context: Der ermittelte Arbeitskontext.
Returns:
Pfad zum erstellten Workspace-Verzeichnis.
Raises:
ContextResolutionError: Wenn der Kontext ungültig ist.
"""
if context not in ALL_CONTEXTS:
raise ContextResolutionError(
task_id=task.id,
message=f"Ungültiger Kontext '{context}'. Gültig: {sorted(ALL_CONTEXTS)}",
)
# Workspace-Verzeichnis: <root>/<kontext>/.workspaces/<task_id>/
# Sanitize task ID für Verzeichnisnamen
safe_task_id = self._sanitize_dirname(task.id)
workspace_path = self.root_path / context / ".workspaces" / safe_task_id
workspace_path.mkdir(parents=True, exist_ok=True)
logger.info(
"Task '%s': Workspace erstellt unter '%s'", task.id, workspace_path
)
return workspace_path
def build_prompt(self, task: Task, context: str) -> str:
"""Erstellt den Agenten-Prompt mit Wissenskontext.
Der Prompt enthält:
- Workspace-Pfad und Kontextinformationen
- Verfügbare Wissensartefakte aus dem YAML-Index (für den Kontext)
- Sicherheitsbeschränkungen (nur Zugriff innerhalb des Kontexts)
- Aufgabenbeschreibung aus dem Task
Requirement 7.1: Arbeitsverzeichnis auf Kontextordner beschränken.
Requirement 7.3: Nur .env des eigenen Kontexts laden.
Args:
task: Der zu bearbeitende Task.
context: Der ermittelte Arbeitskontext.
Returns:
Vollständiger Agenten-Prompt als String.
"""
workspace_path = self.root_path / context / ".workspaces" / self._sanitize_dirname(task.id)
context_root = self.root_path / context
# Basis-Prompt aufbauen
prompt_parts: list[str] = [
f"# Task: {task.title}",
"",
"## Arbeitskontext",
f"- Kontext: {context}",
f"- Workspace: {workspace_path}",
f"- Kontextordner: {context_root}",
"",
"## Sicherheitsbeschränkungen",
f"- Du darfst NUR auf Dateien innerhalb von '{context_root}' und "
f"'{self.root_path / 'shared'}' zugreifen.",
f"- Lade ausschließlich die .env-Datei des Kontexts '{context}'.",
"- Zugriff auf andere Kontextordner ist VERBOTEN und führt zum Task-Abbruch.",
"",
]
# Wissenskontext einfügen
knowledge_section = self.inject_knowledge(context)
if knowledge_section:
prompt_parts.append("## Verfügbare Wissensartefakte")
prompt_parts.append(knowledge_section)
prompt_parts.append("")
# Aufgabenbeschreibung
if task.description:
prompt_parts.append("## Aufgabe")
prompt_parts.append(task.description)
prompt_parts.append("")
return "\n".join(prompt_parts)
def inject_knowledge(self, context: str) -> str:
"""Fügt relevante Artefakt-Pfade aus dem YAML-Index hinzu.
Liest den YAML-Index und liefert Pfade zu Artefakten, die
im aktuellen Kontext oder im shared-Scope verfügbar sind.
Args:
context: Der aktive Arbeitskontext.
Returns:
Formatierter String mit verfügbaren Artefakt-Pfaden oder
leerer String wenn kein Index vorhanden ist.
"""
if not self._knowledge_index_path.exists():
logger.debug("Kein Wissens-Index gefunden unter: %s", self._knowledge_index_path)
return ""
try:
import yaml
with open(self._knowledge_index_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or "artifacts" not in data:
return ""
# Nur Artefakte aus dem eigenen Kontext und shared anzeigen
allowed_scopes = [context, Context.SHARED.value]
relevant_artifacts: list[str] = []
for artifact in data.get("artifacts", []):
if not isinstance(artifact, dict):
continue
scope = artifact.get("scope", "")
if scope in allowed_scopes:
title = artifact.get("title", "Unbenannt")
path = artifact.get("path", "")
tags = artifact.get("tags", [])
tags_str = f" [{', '.join(tags)}]" if tags else ""
relevant_artifacts.append(f"- {title}{tags_str}: {path}")
if not relevant_artifacts:
return ""
return "\n".join(relevant_artifacts)
except Exception as e:
logger.warning("Fehler beim Lesen des Wissens-Index: %s", e)
return ""
def validate_access(self, task: Task, context: str, target_path: Path) -> None:
"""Prüft ob ein Dateizugriff innerhalb des zugewiesenen Kontexts liegt.
Verwendet den ContextGuard zur Zugriffsprüfung. Bei Verletzung:
- Task wird abgebrochen (Exception)
- Vorfall wird im AuditLog protokolliert
- Nutzer wird über die Exception informiert
Requirement 7.5: Out-of-Context → abbrechen, protokollieren, benachrichtigen.
Args:
task: Der laufende Task.
context: Der zugewiesene Arbeitskontext.
target_path: Der Pfad, auf den zugegriffen werden soll.
Raises:
ContextViolationError: Wenn der Zugriff außerhalb des Kontexts liegt.
"""
if not self.context_guard.check_access(context, target_path):
# Zugriffsverletzung protokollieren
target_path_str = str(target_path)
event = SecurityEvent(
timestamp=datetime.now(timezone.utc),
requesting_context=context,
target_context=self._detect_target_context(target_path),
resource=target_path_str,
action="read",
outcome="denied",
)
self.audit_logger.log_violation(event)
logger.error(
"Task '%s': Kontextverletzung! Zugriff auf '%s' aus Kontext '%s' verweigert.",
task.id,
target_path_str,
context,
)
raise ContextViolationError(
task_id=task.id,
context=context,
access_path=target_path_str,
)
def load_context_env(self, context: str) -> dict[str, str]:
"""Lädt ausschließlich die .env-Datei des zugewiesenen Kontexts.
Bei konfiguriertem SecretEncryptionManager wird die .env-Datei
entschlüsselt gelesen. Ohne Manager wird sie direkt gelesen
(unverschlüsselt oder bereits durch git-crypt entschlüsselt).
Requirement 7.3: Nur .env des eigenen Kontexts laden, keine
Umgebungsvariablen anderer Kontexte. Entschlüsselung via
SecretEncryptionManager.
Args:
context: Der zugewiesene Arbeitskontext.
Returns:
Dict mit den Umgebungsvariablen des Kontexts.
Raises:
ValueError: Wenn der Kontext ungültig ist.
FileNotFoundError: Wenn die .env-Datei nicht existiert.
PermissionError: Wenn die Entschlüsselung fehlschlägt
(Maschinenkontext nicht autorisiert).
"""
if self.encryption_manager is not None:
return self._load_env_with_decryption(context)
return self.context_guard.load_env(context)
# ---------------------------------------------------------------------------
# Private Hilfsmethoden
# ---------------------------------------------------------------------------
@staticmethod
def _sanitize_dirname(name: str) -> str:
"""Macht einen String sicher für die Verwendung als Verzeichnisname.
Ersetzt ungültige Zeichen durch Unterstriche.
Args:
name: Der zu bereinigende String.
Returns:
Sicherer Verzeichnisname.
"""
# Einfache Sanitierung: Nur alphanumerische Zeichen, Bindestrich, Unterstrich
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)
return safe or "unnamed"
def _detect_target_context(self, target_path: Path) -> str:
"""Ermittelt den Kontext eines Zielpfads für die Protokollierung.
Args:
target_path: Der Pfad, dessen Kontext ermittelt werden soll.
Returns:
Name des Zielkontexts oder "unknown".
"""
try:
resolved = target_path.resolve() if target_path.is_absolute() else target_path
rel_path = resolved.relative_to(self.root_path)
parts = rel_path.parts
if parts and parts[0] in ALL_CONTEXTS:
return parts[0]
except (ValueError, OSError):
pass
return "unknown"
def _load_env_with_decryption(self, context: str) -> dict[str, str]:
"""Lädt die .env-Datei des Kontexts mit Entschlüsselung via SecretEncryptionManager.
Ermittelt den Pfad zur .env-Datei aus der ContextGuard-Konfiguration,
prüft die Autorisierung des Maschinenkontexts und entschlüsselt die
Datei bei Bedarf.
Requirement 7.3: Nur .env des eigenen Kontexts laden, entschlüsselt
via SecretEncryptionManager.
Requirement 9.3: Kontext-eigener Schlüssel für Entschlüsselung.
Requirement 9.4: Maschinenkontext-basierte Autorisierung.
Args:
context: Der Arbeitskontext, dessen .env geladen werden soll.
Returns:
Dict mit den entschlüsselten Umgebungsvariablen.
Raises:
ValueError: Wenn der Kontext ungültig ist.
FileNotFoundError: Wenn die .env-Datei nicht existiert.
PermissionError: Wenn die Entschlüsselung fehlschlägt
(Maschinenkontext nicht autorisiert).
"""
assert self.encryption_manager is not None # Caller garantiert
# Autorisierungsprüfung
if not self.encryption_manager.is_authorized(context):
raise PermissionError(
f"Maschinenkontext '{self.encryption_manager.machine_context.name}' "
f"ist nicht für Kontext '{context}' autorisiert. "
f"Env-Loading verweigert."
)
# .env-Pfad aus der ContextGuard-Konfiguration ermitteln
ctx_config = self.context_guard.get_context_config(context)
env_file_rel = ctx_config.get("env_file")
if not env_file_rel:
raise ValueError(
f"Kein env_file für Kontext '{context}' in der Konfiguration definiert."
)
env_path = self.root_path / env_file_rel
if not env_path.exists():
raise FileNotFoundError(
f".env-Datei für Kontext '{context}' nicht gefunden: {env_path}"
)
# Entschlüsselung via SecretEncryptionManager
result = self.encryption_manager.decrypt_file(env_path)
if result.success and result.content is not None:
# Entschlüsselter Inhalt als Text parsen
content_text = result.content.decode("utf-8", errors="replace")
return ContextGuard._parse_env_content(content_text)
# Entschlüsselung fehlgeschlagen Fallback auf ContextGuard.load_env
# (Datei könnte bereits im Klartext vorliegen nach git-crypt unlock)
logger.debug(
"Entschlüsselung für Kontext '%s' nicht erfolgreich (%s). "
"Fallback auf ContextGuard.load_env.",
context,
result.error or "unbekannter Fehler",
)
return self.context_guard.load_env(context)
@@ -0,0 +1,835 @@
"""Externes-Repo-Manager für das Monorepo.
Verwaltet die Einbindung externer Repositories via Git-Subtree (Standard)
oder Git-Submodule, sowie die Synchronisation mit Upstream-Remotes.
Verantwortung: Einbindung, Synchronisation und Schutzmechanismen für
externe Repositories (Read-Only und Upstream).
"""
from __future__ import annotations
import logging
import stat
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
import yaml
from monorepo.models import ConflictInfo, RepoEntry, SyncResult
logger = logging.getLogger(__name__)
# Template für den pre-commit Hook, der Read-Only-Verzeichnisse schützt.
# Platzhalter: {protected_paths} wird durch eine Shell-Array-Definition ersetzt.
_PRE_COMMIT_HOOK_TEMPLATE = """\
#!/bin/sh
# --- Monorepo Read-Only-Schutz (auto-generiert) ---
# Verhindert Commits, die Dateien in geschützten Read-Only-Verzeichnissen ändern.
{protected_paths}
staged_files=$(git diff --cached --name-only)
for file in $staged_files; do
for protected in "${{PROTECTED_PATHS[@]}}"; do
case "$file" in
"$protected"/*)
echo "FEHLER: Repo '$protected' ist read-only. Schreibzugriff auf '$file' nicht erlaubt." >&2
exit 1
;;
esac
done
done
# --- Ende Read-Only-Schutz ---
"""
# Marker-Kommentare für den Read-Only-Abschnitt im Hook
_HOOK_START_MARKER = "# --- Monorepo Read-Only-Schutz (auto-generiert) ---"
_HOOK_END_MARKER = "# --- Ende Read-Only-Schutz ---"
class RepoManager:
"""Verwaltet externe und Upstream-Repository-Einbindungen.
Unterstützt zwei Einbindungsmechanismen:
- Git-Subtree (Standard): Flache Checkouts, bidirektionale Sync möglich
- Git-Submodule: Separate Repository-Referenz
Und zwei Betriebsmodi:
- read-only: Nur Pull von Remote, lokale Änderungen werden blockiert
- upstream: Bidirektionale Synchronisation (Pull/Push)
"""
def __init__(self, config_path: Path, monorepo_root: Path | None = None) -> None:
"""Initialisiert den RepoManager.
Args:
config_path: Pfad zur repos.yaml Konfigurationsdatei.
monorepo_root: Wurzelverzeichnis des Monorepos. Wenn None,
wird das übergeordnete Verzeichnis der Config verwendet.
"""
self.config_path = config_path
self.monorepo_root = monorepo_root or config_path.parent.parent.parent
self.repos = self._load_repos_config(config_path)
def _load_repos_config(self, config_path: Path) -> list[RepoEntry]:
"""Lädt die Repo-Konfiguration aus repos.yaml.
Args:
config_path: Pfad zur repos.yaml.
Returns:
Liste aller konfigurierten RepoEntry-Objekte.
"""
if not config_path.exists():
logger.warning("Repo-Konfiguration nicht gefunden: %s", config_path)
return []
with open(config_path, encoding="utf-8") as f:
data: dict[str, Any] = yaml.safe_load(f) or {}
entries: list[RepoEntry] = []
for item in data.get("repos", []):
entries.append(
RepoEntry(
name=item["name"],
url=item["url"],
mode=item.get("mode", "read-only"),
target=item["target"],
pinned=item.get("pinned", "main"),
mechanism=item.get("mechanism", "subtree"),
)
)
return entries
def _find_repo(self, repo_name: str) -> RepoEntry | None:
"""Findet einen Repo-Eintrag anhand des Namens.
Args:
repo_name: Name des gesuchten Repos.
Returns:
Der RepoEntry oder None wenn nicht gefunden.
"""
for entry in self.repos:
if entry.name == repo_name:
return entry
return None
def _run_git(
self, args: list[str], cwd: Path | None = None
) -> subprocess.CompletedProcess[str]:
"""Führt einen Git-Befehl aus.
Args:
args: Git-Argumente (ohne 'git' Präfix).
cwd: Arbeitsverzeichnis für den Befehl.
Returns:
CompletedProcess mit stdout/stderr.
Raises:
subprocess.CalledProcessError: Bei Fehler im Git-Befehl.
"""
cmd = ["git"] + args
logger.debug("Ausführen: %s (cwd=%s)", " ".join(cmd), cwd or self.monorepo_root)
return subprocess.run(
cmd,
cwd=cwd or self.monorepo_root,
capture_output=True,
text=True,
check=True,
)
def add_repo(self, entry: RepoEntry) -> None:
"""Bindet ein externes Repo via Git-Subtree oder Submodule ein.
Subtree (Standard):
git subtree add --prefix=<target> <url> <pinned> --squash
Submodule:
git submodule add <url> <target>
git -C <target> checkout <pinned>
Args:
entry: RepoEntry mit URL, Zielverzeichnis, Mechanismus, etc.
Raises:
ValueError: Wenn das Zielverzeichnis bereits existiert.
subprocess.CalledProcessError: Bei Fehler im Git-Befehl.
"""
target_path = self.monorepo_root / entry.target
if target_path.exists():
raise ValueError(
f"Zielverzeichnis '{entry.target}' existiert bereits. "
f"Repo '{entry.name}' kann nicht eingebunden werden."
)
logger.info(
"Binde Repo '%s' ein via %s%s",
entry.name,
entry.mechanism,
entry.target,
)
if entry.mechanism == "subtree":
self._add_subtree(entry)
elif entry.mechanism == "submodule":
self._add_submodule(entry)
else:
raise ValueError(
f"Unbekannter Mechanismus '{entry.mechanism}'. "
f"Erlaubt: 'subtree', 'submodule'."
)
# Repo in Konfiguration aufnehmen (falls noch nicht vorhanden)
if not self._find_repo(entry.name):
self.repos.append(entry)
self._save_repos_config()
logger.info("Repo '%s' erfolgreich eingebunden.", entry.name)
def _add_subtree(self, entry: RepoEntry) -> None:
"""Bindet ein Repo als Git-Subtree ein.
Args:
entry: RepoEntry mit den Subtree-Parametern.
"""
self._run_git([
"subtree",
"add",
f"--prefix={entry.target}",
entry.url,
entry.pinned,
"--squash",
])
def _add_submodule(self, entry: RepoEntry) -> None:
"""Bindet ein Repo als Git-Submodule ein.
Args:
entry: RepoEntry mit den Submodule-Parametern.
"""
self._run_git([
"submodule",
"add",
entry.url,
entry.target,
])
# Auf gepinnte Version auschecken
target_path = self.monorepo_root / entry.target
self._run_git(["checkout", entry.pinned], cwd=target_path)
def sync(self, repo_name: str) -> SyncResult:
"""Synchronisiert ein Repo mit seinem Remote.
Für Read-Only-Repos: Pull von Upstream auf gepinnte Version.
Für Upstream-Repos: Bidirektionale Synchronisation (Pull dann Push).
Bei Merge-Konflikten wird die Synchronisation abgebrochen,
der Konflikt protokolliert und manuelle Auflösung ermöglicht.
Args:
repo_name: Name des zu synchronisierenden Repos.
Returns:
SyncResult mit Status (Erfolg/Misserfolg/unbekannt),
Richtung, Anzahl synchronisierter Commits und ggf. Konflikte.
"""
entry = self._find_repo(repo_name)
if entry is None:
logger.error("Repo '%s' nicht in Konfiguration gefunden.", repo_name)
return SyncResult(
success=False,
context=repo_name,
direction="pull",
commits_synced=0,
conflicts=[
ConflictInfo(
file_path="",
conflict_type="content",
source="monorepo",
details=f"Repo '{repo_name}' nicht in Konfiguration gefunden.",
)
],
timestamp=datetime.now(),
)
if entry.mode == "upstream":
return self._sync_upstream(entry)
else:
return self._sync_readonly(entry)
def _sync_readonly(self, entry: RepoEntry) -> SyncResult:
"""Synchronisiert ein Read-Only-Repo (nur Pull).
Args:
entry: RepoEntry des zu synchronisierenden Repos.
Returns:
SyncResult mit Ergebnis der Pull-Operation.
"""
logger.info("Synchronisiere Read-Only-Repo '%s' auf '%s'.", entry.name, entry.pinned)
try:
if entry.mechanism == "subtree":
result = self._subtree_pull(entry)
else:
result = self._submodule_pull(entry)
commits = self._count_commits_from_output(result.stdout)
logger.info(
"Repo '%s' erfolgreich synchronisiert (%d Commits).",
entry.name,
commits,
)
return SyncResult(
success=True,
context=entry.name,
direction="pull",
commits_synced=commits,
conflicts=[],
timestamp=datetime.now(),
)
except subprocess.CalledProcessError as e:
return self._handle_sync_error(entry, e, direction="pull")
def _sync_upstream(self, entry: RepoEntry) -> SyncResult:
"""Synchronisiert ein Upstream-Repo bidirektional (Pull + Push).
Ablauf:
1. Pull von Upstream (Subtree-Pull oder Submodule-Update)
2. Bei Merge-Konflikt: Abbruch, Protokollierung, manuelle Auflösung
3. Push lokaler Änderungen zu Upstream
Args:
entry: RepoEntry des Upstream-Repos.
Returns:
SyncResult mit Gesamtergebnis der bidirektionalen Sync.
"""
logger.info("Bidirektionale Synchronisation für Upstream-Repo '%s'.", entry.name)
total_commits = 0
# Phase 1: Pull von Upstream
try:
if entry.mechanism == "subtree":
pull_result = self._subtree_pull(entry)
else:
pull_result = self._submodule_pull(entry)
total_commits += self._count_commits_from_output(pull_result.stdout)
logger.info("Pull von '%s' erfolgreich.", entry.name)
except subprocess.CalledProcessError as e:
# Prüfe ob es ein Merge-Konflikt ist
if self._is_merge_conflict(e):
return self._handle_merge_conflict(entry, e)
return self._handle_sync_error(entry, e, direction="full")
# Phase 2: Push lokaler Änderungen
try:
if entry.mechanism == "subtree":
push_result = self._subtree_push(entry)
else:
push_result = self._submodule_push(entry)
total_commits += self._count_commits_from_output(push_result.stdout)
logger.info("Push zu '%s' erfolgreich.", entry.name)
except subprocess.CalledProcessError as e:
if self._is_merge_conflict(e):
return self._handle_merge_conflict(entry, e)
return self._handle_sync_error(entry, e, direction="push")
return SyncResult(
success=True,
context=entry.name,
direction="full",
commits_synced=total_commits,
conflicts=[],
timestamp=datetime.now(),
)
def _subtree_pull(self, entry: RepoEntry) -> subprocess.CompletedProcess[str]:
"""Führt einen git subtree pull aus.
Args:
entry: RepoEntry mit Subtree-Parametern.
Returns:
CompletedProcess mit Ergebnis.
"""
return self._run_git([
"subtree",
"pull",
f"--prefix={entry.target}",
entry.url,
entry.pinned,
"--squash",
])
def _subtree_push(self, entry: RepoEntry) -> subprocess.CompletedProcess[str]:
"""Führt einen git subtree push aus.
Args:
entry: RepoEntry mit Subtree-Parametern.
Returns:
CompletedProcess mit Ergebnis.
"""
return self._run_git([
"subtree",
"push",
f"--prefix={entry.target}",
entry.url,
entry.pinned,
])
def _submodule_pull(self, entry: RepoEntry) -> subprocess.CompletedProcess[str]:
"""Aktualisiert ein Submodule auf die gepinnte Version.
Args:
entry: RepoEntry mit Submodule-Parametern.
Returns:
CompletedProcess mit Ergebnis.
"""
target_path = self.monorepo_root / entry.target
# Fetch latest from remote
self._run_git(["fetch", "origin"], cwd=target_path)
# Checkout pinned version
return self._run_git(["checkout", entry.pinned], cwd=target_path)
def _submodule_push(self, entry: RepoEntry) -> subprocess.CompletedProcess[str]:
"""Pusht lokale Änderungen in einem Submodule zu Upstream.
Args:
entry: RepoEntry mit Submodule-Parametern.
Returns:
CompletedProcess mit Ergebnis.
"""
target_path = self.monorepo_root / entry.target
return self._run_git(["push", "origin", entry.pinned], cwd=target_path)
def _is_merge_conflict(self, error: subprocess.CalledProcessError) -> bool:
"""Prüft ob ein Git-Fehler ein Merge-Konflikt ist.
Args:
error: Der aufgetretene CalledProcessError.
Returns:
True wenn es sich um einen Merge-Konflikt handelt.
"""
conflict_indicators = [
"CONFLICT",
"merge conflict",
"Automatic merge failed",
"fix conflicts",
]
combined_output = (error.stdout or "") + (error.stderr or "")
return any(indicator in combined_output for indicator in conflict_indicators)
def _handle_merge_conflict(
self, entry: RepoEntry, error: subprocess.CalledProcessError
) -> SyncResult:
"""Behandelt einen Merge-Konflikt: Abbruch, Protokollierung.
Bei Merge-Konflikten wird:
1. Die Merge-Operation abgebrochen (git merge --abort)
2. Der Konflikt protokolliert
3. Ein SyncResult mit Konfliktdetails zurückgegeben
Args:
entry: RepoEntry des betroffenen Repos.
error: Der CalledProcessError mit Konfliktdetails.
Returns:
SyncResult mit success=False und Konfliktinformationen.
"""
logger.warning(
"Merge-Konflikt bei Synchronisation von '%s'. Breche ab.",
entry.name,
)
# Merge abbrechen um sauberen Zustand herzustellen
try:
self._run_git(["merge", "--abort"])
except subprocess.CalledProcessError:
# Falls kein Merge aktiv ist, ignorieren
logger.debug("Kein aktiver Merge zum Abbrechen.")
# Konfliktdetails aus Fehlerausgabe extrahieren
combined_output = (error.stdout or "") + (error.stderr or "")
conflict_files = self._extract_conflict_files(combined_output)
conflicts = [
ConflictInfo(
file_path=f,
conflict_type="content",
source="monorepo",
details=f"Merge-Konflikt in '{f}' bei Sync von '{entry.name}'.",
)
for f in conflict_files
] or [
ConflictInfo(
file_path="",
conflict_type="content",
source="monorepo",
details=f"Merge-Konflikt bei Synchronisation von '{entry.name}': "
f"{combined_output[:200]}",
)
]
return SyncResult(
success=False,
context=entry.name,
direction="full" if entry.mode == "upstream" else "pull",
commits_synced=0,
conflicts=conflicts,
timestamp=datetime.now(),
)
def _handle_sync_error(
self,
entry: RepoEntry,
error: subprocess.CalledProcessError,
direction: str,
) -> SyncResult:
"""Behandelt einen allgemeinen Sync-Fehler.
Protokolliert den Fehler in der Sync-Error-Log-Datei und gibt
ein SyncResult mit success=False zurück. Der lokale Stand wird
nicht verändert (Req 4.9).
Args:
entry: RepoEntry des betroffenen Repos.
error: Der aufgetretene Fehler.
direction: Richtung der fehlgeschlagenen Operation.
Returns:
SyncResult mit success=False und Fehlerdetails.
"""
combined_output = (error.stdout or "") + (error.stderr or "")
error_detail = combined_output.strip()[:300] or "Unbekannter Fehler"
logger.error(
"Synchronisation von '%s' fehlgeschlagen: %s",
entry.name,
error_detail,
)
# Fehler in Audit-Datei protokollieren (Req 4.9)
self.log_sync_error(
repo_name=entry.name,
error_reason=error_detail,
direction=direction,
)
return SyncResult(
success=False,
context=entry.name,
direction=direction, # type: ignore[arg-type]
commits_synced=0,
conflicts=[
ConflictInfo(
file_path="",
conflict_type="content",
source="monorepo",
details=f"Synchronisation fehlgeschlagen: {error_detail}",
)
],
timestamp=datetime.now(),
)
def _extract_conflict_files(self, output: str) -> list[str]:
"""Extrahiert Dateinamen aus Git-Merge-Konflikt-Ausgabe.
Args:
output: Die kombinierte stdout/stderr-Ausgabe von Git.
Returns:
Liste der Dateipfade mit Konflikten.
"""
conflict_files: list[str] = []
for line in output.splitlines():
# Git zeigt "CONFLICT (content): Merge conflict in <file>"
if "CONFLICT" in line and "Merge conflict in" in line:
parts = line.split("Merge conflict in")
if len(parts) > 1:
file_path = parts[1].strip().rstrip(".")
conflict_files.append(file_path)
# Git zeigt auch "CONFLICT (modify/delete): <file> deleted in ..."
elif "CONFLICT" in line and ":" in line:
parts = line.split(":")
if len(parts) > 1:
# Versuche Dateiname zu extrahieren
segment = parts[1].strip()
tokens = segment.split()
if tokens:
conflict_files.append(tokens[0])
return conflict_files
def _count_commits_from_output(self, output: str) -> int:
"""Zählt synchronisierte Commits anhand der Git-Ausgabe.
Sucht nach typischen Git-Meldungen wie "X commits" oder
Shortstat-Zeilen. Gibt 1 zurück wenn der Output auf
Aktivität hindeutet, sonst 0.
Args:
output: Die stdout-Ausgabe des Git-Befehls.
Returns:
Geschätzte Anzahl synchronisierter Commits.
"""
if not output:
return 0
# Suche nach "X files changed" als Indikator für Änderungen
for line in output.splitlines():
if "files changed" in line or "file changed" in line:
return 1
if "Already up to date" in line or "Already up-to-date" in line:
return 0
# Wenn es Output gibt aber keine klaren Indikatoren,
# gehen wir von mindestens einer Änderung aus
if output.strip():
return 1
return 0
def _save_repos_config(self) -> None:
"""Speichert die aktuelle Repo-Konfiguration in repos.yaml."""
data = {
"repos": [
{
"name": entry.name,
"url": entry.url,
"mode": entry.mode,
"target": entry.target,
"pinned": entry.pinned,
"mechanism": entry.mechanism,
}
for entry in self.repos
]
}
self.config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
logger.debug("Repo-Konfiguration gespeichert: %s", self.config_path)
# -----------------------------------------------------------------------
# Read-Only-Schutzmechanismus (Req 4.2, 4.5)
# -----------------------------------------------------------------------
def protect_readonly(self, repo_path: Path) -> None:
"""Installiert einen Git pre-commit Hook zum Schutz eines Read-Only-Verzeichnisses.
Der Hook prüft bei jedem Commit, ob gestagte Dateien innerhalb des
geschützten Verzeichnisses liegen. Ist das der Fall, wird der Commit
mit einer Fehlermeldung abgebrochen, die den Repo-Namen und den
Read-Only-Status benennt.
Wenn bereits ein pre-commit Hook existiert, wird der Schutzabschnitt
angehängt bzw. aktualisiert. Die geschützten Pfade werden als
Shell-Array in den Hook geschrieben.
Args:
repo_path: Absoluter Pfad zum geschützten Verzeichnis (Target-Pfad
relativ zum Monorepo-Root wird automatisch berechnet).
Raises:
ValueError: Wenn der repo_path nicht innerhalb des Monorepo-Roots liegt.
"""
# Berechne den relativen Pfad zum Monorepo-Root
try:
relative_target = repo_path.relative_to(self.monorepo_root)
except ValueError:
raise ValueError(
f"Pfad '{repo_path}' liegt nicht innerhalb des "
f"Monorepo-Roots '{self.monorepo_root}'."
)
target_str = str(relative_target).replace("\\", "/")
# Git-Hook-Verzeichnis ermitteln
hooks_dir = self.monorepo_root / ".git" / "hooks"
hooks_dir.mkdir(parents=True, exist_ok=True)
hook_path = hooks_dir / "pre-commit"
# Geschützte Pfade sammeln (aus bestehendem Hook + neuer Pfad)
protected_paths = self._get_existing_protected_paths(hook_path)
if target_str not in protected_paths:
protected_paths.append(target_str)
# Hook-Script generieren und schreiben
self._write_pre_commit_hook(hook_path, protected_paths)
logger.info(
"Read-Only-Schutz installiert für '%s' (Hook: %s).",
target_str,
hook_path,
)
def install_all_readonly_hooks(self) -> None:
"""Installiert Read-Only-Schutz für alle als read-only konfigurierten Repos.
Iteriert über alle konfigurierten Repos und installiert für jene mit
mode="read-only" den pre-commit-Hook-Schutz. Bereits geschützte Pfade
werden nicht doppelt eingetragen.
"""
readonly_repos = [entry for entry in self.repos if entry.mode == "read-only"]
if not readonly_repos:
logger.info("Keine Read-Only-Repos konfiguriert kein Hook notwendig.")
return
for entry in readonly_repos:
repo_path = self.monorepo_root / entry.target
self.protect_readonly(repo_path)
logger.info(
"Read-Only-Schutz für %d Repos installiert: %s",
len(readonly_repos),
", ".join(e.name for e in readonly_repos),
)
def _get_existing_protected_paths(self, hook_path: Path) -> list[str]:
"""Extrahiert bereits geschützte Pfade aus einem bestehenden Hook.
Args:
hook_path: Pfad zur pre-commit Hook-Datei.
Returns:
Liste der bereits geschützten Pfade (relativ zum Monorepo-Root).
"""
if not hook_path.exists():
return []
content = hook_path.read_text(encoding="utf-8")
# Suche nach PROTECTED_PATHS-Array im bestehenden Hook
paths: list[str] = []
in_array = False
for line in content.splitlines():
if "PROTECTED_PATHS=(" in line:
in_array = True
# Pfade in derselben Zeile: PROTECTED_PATHS=("path1" "path2")
inner = line.split("(", 1)[1].rstrip(")")
for token in inner.split():
cleaned = token.strip('"').strip("'")
if cleaned:
paths.append(cleaned)
if ")" in line:
in_array = False
continue
if in_array:
if ")" in line:
inner = line.split(")")[0]
for token in inner.split():
cleaned = token.strip('"').strip("'")
if cleaned:
paths.append(cleaned)
in_array = False
else:
for token in line.split():
cleaned = token.strip('"').strip("'")
if cleaned:
paths.append(cleaned)
return paths
def _write_pre_commit_hook(self, hook_path: Path, protected_paths: list[str]) -> None:
"""Schreibt oder aktualisiert den pre-commit Hook mit Schutzmechanismus.
Wenn bereits ein Hook existiert, wird der Read-Only-Abschnitt
(zwischen Start- und End-Marker) ersetzt. Andernfalls wird ein
neuer Hook erstellt.
Args:
hook_path: Pfad zur Hook-Datei.
protected_paths: Liste der zu schützenden relativen Pfade.
"""
# Generiere Shell-Array-Definition
quoted_paths = " ".join(f'"{p}"' for p in protected_paths)
paths_definition = f'PROTECTED_PATHS=({quoted_paths})'
# Generiere neuen Hook-Abschnitt
new_section = _PRE_COMMIT_HOOK_TEMPLATE.format(protected_paths=paths_definition)
if hook_path.exists():
existing_content = hook_path.read_text(encoding="utf-8")
if _HOOK_START_MARKER in existing_content:
# Ersetze den bestehenden Abschnitt
start_idx = existing_content.index(_HOOK_START_MARKER)
end_idx = existing_content.index(_HOOK_END_MARKER) + len(_HOOK_END_MARKER)
updated_content = (
existing_content[:start_idx]
+ new_section.strip()
+ existing_content[end_idx:]
)
else:
# Hänge den Abschnitt an den bestehenden Hook an
updated_content = existing_content.rstrip() + "\n\n" + new_section
else:
updated_content = new_section
hook_path.write_text(updated_content, encoding="utf-8")
# Hook ausführbar machen (wichtig für Unix-Systeme)
current_mode = hook_path.stat().st_mode
hook_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
# -----------------------------------------------------------------------
# Sync-Fehlerprotokollierung (Req 4.9)
# -----------------------------------------------------------------------
def log_sync_error(
self,
repo_name: str,
error_reason: str,
direction: str = "pull",
) -> None:
"""Protokolliert einen Synchronisationsfehler in der Audit-Datei.
Schreibt einen strukturierten Eintrag (Zeitstempel, Repo-Name,
Richtung, Fehlergrund) in `.audit/sync-errors.log`. Der lokale
Stand des Repos wird dabei nicht verändert.
Args:
repo_name: Name des betroffenen Repos.
error_reason: Beschreibung des Fehlergrunds (Netzwerk, Auth, Merge).
direction: Richtung der fehlgeschlagenen Operation (pull/push/full).
"""
audit_dir = self.monorepo_root / ".audit"
audit_dir.mkdir(parents=True, exist_ok=True)
log_path = audit_dir / "sync-errors.log"
timestamp = datetime.now().isoformat(timespec="seconds")
log_entry = (
f"[{timestamp}] repo={repo_name} direction={direction} "
f"error={error_reason}\n"
)
with open(log_path, "a", encoding="utf-8") as f:
f.write(log_entry)
logger.error(
"Sync-Fehler protokolliert: repo=%s, direction=%s, error=%s",
repo_name,
direction,
error_reason,
)
@@ -0,0 +1,361 @@
"""Sicherheits-Guard für die Zugriffskontrolle zwischen Arbeitskontexten.
Implementiert den ContextGuard, der Sicherheitsgrenzen zwischen den
Arbeitskontexten (privat, dhive, bahn, shared) erzwingt. Die Zugriffskontrolle
basiert auf der zentralen access-config.yaml.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import yaml
from monorepo.models import Context
logger = logging.getLogger(__name__)
class ContextGuard:
"""Erzwingt Sicherheitsgrenzen zwischen Arbeitskontexten.
Prüft ob ein anfragender Kontext auf einen gegebenen Pfad zugreifen darf,
basierend auf den Regeln in access-config.yaml:
- Ein Kontext darf immer auf eigene Dateien zugreifen.
- Ein Kontext darf auf shared-Pfade zugreifen, die in allowed_shared gelistet sind.
- Der shared-Kontext mit ["*"] darf auf alles in shared zugreifen.
- Alle anderen kontextübergreifenden Zugriffe werden verweigert.
"""
def __init__(
self,
root_path: Path,
access_config_path: Path | None = None,
encryption_manager: Any | None = None,
) -> None:
"""Initialisiert den ContextGuard.
Args:
root_path: Wurzelverzeichnis des Monorepos.
access_config_path: Pfad zur access-config.yaml.
Falls None, wird `root_path / shared/config/access-config.yaml` verwendet.
encryption_manager: Optionaler SecretEncryptionManager für die
Entschlüsselung von .env-Dateien. Falls None, werden .env-Dateien
direkt gelesen (unverschlüsselt oder bereits durch git-crypt entschlüsselt).
"""
self.root_path = root_path.resolve()
if access_config_path is None:
access_config_path = self.root_path / "shared" / "config" / "access-config.yaml"
self.access_config_path = access_config_path.resolve()
self.encryption_manager = encryption_manager
self._config: dict[str, Any] = self._load_access_config()
def _load_access_config(self) -> dict[str, Any]:
"""Lädt die access-config.yaml.
Returns:
Dict mit der Zugriffskonfiguration pro Kontext.
Raises:
FileNotFoundError: Wenn die Konfigurationsdatei nicht existiert.
ValueError: Wenn das YAML-Format ungültig ist.
"""
if not self.access_config_path.exists():
raise FileNotFoundError(
f"Access-Konfiguration nicht gefunden: {self.access_config_path}"
)
with open(self.access_config_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or "contexts" not in data:
raise ValueError(
f"Ungültiges access-config.yaml-Format: "
f"Erwartet dict mit 'contexts'-Schlüssel in {self.access_config_path}"
)
return data["contexts"]
def check_access(self, requesting_context: str, target_path: Path) -> bool:
"""Prüft ob der Zugriff auf target_path vom requesting_context erlaubt ist.
Regeln:
1. Ein Kontext darf immer auf eigene Dateien zugreifen (Pfad unter eigenem Kontextordner).
2. Ein Kontext darf auf shared-Pfade zugreifen, wenn diese in allowed_shared gelistet sind.
3. Der shared-Kontext mit allowed_shared: ["*"] darf auf alles in shared zugreifen.
4. Alle anderen kontextübergreifenden Zugriffe werden verweigert.
Args:
requesting_context: Name des anfragenden Kontexts (z.B. "privat", "dhive", "bahn", "shared").
target_path: Pfad auf den zugegriffen werden soll (absolut oder relativ zum root_path).
Returns:
True wenn der Zugriff erlaubt ist, False wenn verweigert.
"""
# Pfad relativ zum Root normalisieren
rel_path = self._resolve_relative_path(target_path)
rel_path_str = rel_path.as_posix()
# Kontext des Zielpfads ermitteln
target_context = self._get_path_context(rel_path_str)
# Regel 1: Zugriff auf eigenen Kontext immer erlaubt
if target_context == requesting_context:
return True
# Regel 2+3: Zugriff auf shared-Bereich prüfen
if target_context == Context.SHARED.value:
return self._check_shared_access(requesting_context, rel_path_str)
# Regel 4: Kontextübergreifender Zugriff verweigert
return False
def load_env(self, context: str) -> dict[str, str]:
"""Lädt die .env-Datei des gegebenen Kontexts (mit optionaler Entschlüsselung).
Wenn ein SecretEncryptionManager konfiguriert ist und der Maschinenkontext
für den angefragten Kontext autorisiert ist, wird die .env-Datei via
decrypt_file entschlüsselt. Andernfalls wird die Datei direkt gelesen
(unverschlüsselt oder bereits durch git-crypt entschlüsselt im Working Tree).
Parst eine Standard-.env-Datei (KEY=VALUE-Format) und gibt die
Umgebungsvariablen als Dict zurück. Kommentare (#) und Leerzeilen
werden übersprungen.
Args:
context: Name des Kontexts, dessen .env geladen werden soll.
Returns:
Dict mit den Umgebungsvariablen (Schlüssel → Wert).
Raises:
ValueError: Wenn der Kontext nicht in der Konfiguration definiert ist.
FileNotFoundError: Wenn die .env-Datei nicht existiert.
PermissionError: Wenn die Entschlüsselung fehlschlägt
(Maschinenkontext nicht autorisiert).
"""
if context not in self._config:
raise ValueError(
f"Unbekannter Kontext '{context}'. "
f"Gültige Kontexte: {list(self._config.keys())}"
)
env_file_rel = self._config[context].get("env_file")
if not env_file_rel:
raise ValueError(
f"Kein env_file für Kontext '{context}' in der Konfiguration definiert."
)
env_path = self.root_path / env_file_rel
if not env_path.exists():
raise FileNotFoundError(
f".env-Datei für Kontext '{context}' nicht gefunden: {env_path}"
)
# Integration mit SecretEncryptionManager für Entschlüsselung (Req 2.2, 9.3).
if self.encryption_manager is not None:
return self._load_env_with_decryption(env_path, context)
# Fallback: Datei direkt lesen (unverschlüsselt oder bereits entschlüsselt)
return self._parse_env_file(env_path)
def _load_env_with_decryption(
self, env_path: Path, context: str
) -> dict[str, str]:
"""Lädt und entschlüsselt eine .env-Datei via SecretEncryptionManager.
Prüft zuerst die Autorisierung des Maschinenkontexts. Wenn autorisiert,
wird die Datei entschlüsselt und geparst. Andernfalls wird ein
PermissionError ausgelöst.
Args:
env_path: Absoluter Pfad zur .env-Datei.
context: Arbeitskontext der .env-Datei.
Returns:
Dict mit den entschlüsselten Umgebungsvariablen.
Raises:
PermissionError: Wenn der Maschinenkontext nicht autorisiert ist.
"""
encryption_mgr = self.encryption_manager
# Autorisierungsprüfung
if not encryption_mgr.is_authorized(context):
raise PermissionError(
f"Maschinenkontext '{encryption_mgr.machine_context.name}' ist nicht "
f"für Kontext '{context}' autorisiert. Entschlüsselung verweigert."
)
# Entschlüsselung versuchen
result = encryption_mgr.decrypt_file(env_path)
if result.success and result.content is not None:
# Entschlüsselter Inhalt als Text parsen
content_text = result.content.decode("utf-8", errors="replace")
return self._parse_env_content(content_text)
# Entschlüsselung fehlgeschlagen Fallback auf direkte Leseoperation.
# Dies tritt auf wenn die Datei bereits im Klartext vorliegt (z.B.
# nach git-crypt unlock) oder wenn git-crypt nicht verfügbar ist.
logger.debug(
"Entschlüsselung für '%s' nicht erfolgreich (%s). "
"Fallback auf direktes Lesen.",
env_path,
result.error or "unbekannter Fehler",
)
return self._parse_env_file(env_path)
def get_context_config(self, context: str) -> dict[str, Any]:
"""Gibt die Konfiguration für einen bestimmten Kontext zurück.
Args:
context: Name des Kontexts.
Returns:
Dict mit env_file und allowed_shared für den Kontext.
Raises:
ValueError: Wenn der Kontext nicht in der Konfiguration definiert ist.
"""
if context not in self._config:
raise ValueError(
f"Unbekannter Kontext '{context}'. "
f"Gültige Kontexte: {list(self._config.keys())}"
)
return self._config[context]
def _resolve_relative_path(self, target_path: Path) -> Path:
"""Löst einen Pfad relativ zum Monorepo-Root auf.
Args:
target_path: Absoluter oder relativer Pfad.
Returns:
Pfad relativ zum Root (immer mit Forward-Slashes via as_posix).
"""
resolved = Path(target_path).resolve() if target_path.is_absolute() else target_path
try:
return resolved.relative_to(self.root_path)
except ValueError:
# Pfad ist bereits relativ oder liegt außerhalb des Roots
return target_path
def _get_path_context(self, rel_path_str: str) -> str:
"""Ermittelt den Kontext eines relativen Pfads.
Der Kontext wird anhand des obersten Verzeichnisses bestimmt.
Gültige Kontexte: privat, dhive, bahn, shared.
Args:
rel_path_str: Relativer Pfad als String (mit Forward-Slashes).
Returns:
Name des Kontexts oder leerer String wenn nicht zuordenbar.
"""
parts = rel_path_str.split("/")
if not parts:
return ""
first_part = parts[0]
valid_contexts = {ctx.value for ctx in Context}
if first_part in valid_contexts:
return first_part
return ""
def _check_shared_access(self, requesting_context: str, rel_path_str: str) -> bool:
"""Prüft ob ein Kontext auf einen shared-Pfad zugreifen darf.
Args:
requesting_context: Name des anfragenden Kontexts.
rel_path_str: Relativer Pfad (beginnt mit "shared/").
Returns:
True wenn der Zugriff erlaubt ist.
"""
if requesting_context not in self._config:
return False
allowed_shared = self._config[requesting_context].get("allowed_shared", [])
# Wildcard: Zugriff auf alles in shared erlaubt
if allowed_shared == ["*"]:
return True
# Prüfe ob der Pfad unter einem der erlaubten shared-Pfade liegt
for allowed_path in allowed_shared:
# Normalisiere: Stelle sicher dass der Pfad mit / endet für Prefix-Matching
allowed_normalized = allowed_path.rstrip("/") + "/"
# Pfad liegt unter dem erlaubten Verzeichnis oder ist es selbst
if rel_path_str.startswith(allowed_normalized) or rel_path_str.rstrip("/") + "/" == allowed_normalized:
return True
return False
@staticmethod
def _parse_env_file(env_path: Path) -> dict[str, str]:
"""Parst eine .env-Datei im Standard-Format.
Format:
- KEY=VALUE (ein Paar pro Zeile)
- Zeilen die mit # beginnen sind Kommentare
- Leerzeilen werden übersprungen
- Führende/nachfolgende Leerzeichen werden entfernt
- Werte in Anführungszeichen (einfach oder doppelt) werden entquotet
Args:
env_path: Pfad zur .env-Datei.
Returns:
Dict mit geparsten Umgebungsvariablen.
"""
with open(env_path, encoding="utf-8") as f:
content = f.read()
return ContextGuard._parse_env_content(content)
@staticmethod
def _parse_env_content(content: str) -> dict[str, str]:
"""Parst .env-Inhalt im Standard-Format aus einem String.
Format:
- KEY=VALUE (ein Paar pro Zeile)
- Zeilen die mit # beginnen sind Kommentare
- Leerzeilen werden übersprungen
- Führende/nachfolgende Leerzeichen werden entfernt
- Werte in Anführungszeichen (einfach oder doppelt) werden entquotet
Args:
content: Textinhalt der .env-Datei.
Returns:
Dict mit geparsten Umgebungsvariablen.
"""
env_vars: dict[str, str] = {}
for line in content.splitlines():
line = line.strip()
# Leerzeilen und Kommentare überspringen
if not line or line.startswith("#"):
continue
# KEY=VALUE aufteilen (nur beim ersten = splitten)
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
# Anführungszeichen entfernen
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
if key:
env_vars[key] = value
return env_vars
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
"""Ordnerstruktur-Manager für das Monorepo.
Verwaltet die 3-Ebenen-Ordnerhierarchie:
Ebene 1 (Kontextordner) → Ebene 2 (Projektordner) → Ebene 3 (Modulordner)
Verantwortung: Anlegen, Validieren und Verwalten der Monorepo-Ordnerstruktur.
"""
from __future__ import annotations
import re
from pathlib import Path
from monorepo.models import Context, ProjectInfo
class StructureManager:
"""Verwaltet die 3-Ebenen-Ordnerhierarchie des Monorepos."""
CONTEXTS = ("privat", "dhive", "bahn", "shared")
NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$")
def __init__(self, root_path: Path) -> None:
"""Initialisiert den StructureManager.
Args:
root_path: Wurzelverzeichnis des Monorepos.
"""
self.root_path = root_path
def validate_name(self, name: str) -> bool:
"""Prüft ob ein Name der kebab-case-Konvention entspricht.
Regeln:
- Ausschließlich Kleinbuchstaben, Ziffern und Bindestriche
- Zwischen 2 und 50 Zeichen lang
- Beginnt und endet nicht mit einem Bindestrich
Args:
name: Der zu prüfende Projektname.
Returns:
True wenn der Name gültig ist, False sonst.
"""
if not name:
return False
return self.NAME_PATTERN.match(name) is not None
def create_project(self, context: str, name: str) -> Path:
"""Erstellt ein neues Projekt im gegebenen Kontext.
Validiert den Namen, prüft auf Namenskonflikte und erstellt
den Projektordner im korrekten Kontextordner.
Args:
context: Der Arbeitskontext (privat, dhive, bahn, shared).
name: Der Projektname in kebab-case.
Returns:
Pfad zum erstellten Projektordner.
Raises:
ValueError: Wenn der Kontext ungültig ist, der Name die
Namenskonvention verletzt, oder ein Projekt mit
diesem Namen bereits existiert.
"""
# Kontext validieren
if context not in self.CONTEXTS:
raise ValueError(
f"Ungültiger Kontext '{context}'. "
f"Erlaubte Kontexte: {', '.join(self.CONTEXTS)}"
)
# Name validieren
if not self.validate_name(name):
raise ValueError(
f"Ungültiger Projektname '{name}'. "
f"Der Name muss kebab-case sein (Kleinbuchstaben, Ziffern, "
f"Bindestriche), zwischen 2 und 50 Zeichen lang, und darf "
f"nicht mit einem Bindestrich beginnen oder enden."
)
# Namenskonflikt prüfen
project_path = self.root_path / context / name
if project_path.exists():
raise ValueError(
f"Namenskonflikt: Ein Projekt mit dem Namen '{name}' "
f"existiert bereits im Kontext '{context}' "
f"(Pfad: {project_path})."
)
# Projektordner erstellen
project_path.mkdir(parents=True, exist_ok=False)
return project_path
def list_projects(self, context: str | None = None) -> list[ProjectInfo]:
"""Listet alle Projekte, optional gefiltert nach Kontext.
Args:
context: Optionaler Kontext-Filter. Wenn None, werden alle
Projekte aus allen Kontexten aufgelistet.
Returns:
Liste von ProjectInfo-Objekten für alle gefundenen Projekte.
Raises:
ValueError: Wenn ein ungültiger Kontext angegeben wird.
"""
if context is not None and context not in self.CONTEXTS:
raise ValueError(
f"Ungültiger Kontext '{context}'. "
f"Erlaubte Kontexte: {', '.join(self.CONTEXTS)}"
)
contexts_to_scan = (context,) if context else self.CONTEXTS
projects: list[ProjectInfo] = []
for ctx in contexts_to_scan:
context_dir = self.root_path / ctx
if not context_dir.exists():
continue
for entry in sorted(context_dir.iterdir()):
if entry.is_dir() and not entry.name.startswith("."):
projects.append(
ProjectInfo(
name=entry.name,
context=ctx,
path=entry,
)
)
return projects
def resolve_context(self, project_path: Path) -> str:
"""Ermittelt den Arbeitskontext eines Projekts anhand seines Pfads.
Analysiert den Pfad relativ zum Monorepo-Root und extrahiert
den Kontextordner (erste Ebene).
Args:
project_path: Absoluter oder relativer Pfad zum Projekt.
Returns:
Der ermittelte Arbeitskontext als String.
Raises:
ValueError: Wenn der Pfad nicht innerhalb eines bekannten
Kontextordners liegt.
"""
# Pfad auflösen (absolut machen)
resolved = project_path.resolve()
root_resolved = self.root_path.resolve()
# Prüfen ob der Pfad innerhalb des Monorepos liegt
try:
relative = resolved.relative_to(root_resolved)
except ValueError:
raise ValueError(
f"Der Pfad '{project_path}' liegt nicht innerhalb "
f"des Monorepos ({self.root_path})."
)
# Ersten Pfadteil (Kontextordner) extrahieren
parts = relative.parts
if not parts:
raise ValueError(
f"Der Pfad '{project_path}' zeigt auf das Monorepo-Root, "
f"nicht auf ein Projekt in einem Kontext."
)
context_name = parts[0]
if context_name not in self.CONTEXTS:
raise ValueError(
f"Der Pfad '{project_path}' liegt nicht in einem bekannten "
f"Kontextordner. Gefunden: '{context_name}', "
f"erwartet: {', '.join(self.CONTEXTS)}."
)
return context_name
@@ -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 (decisionsdecision, inboxnote, meetingsmeeting, 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}"
)