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