426 lines
18 KiB
Python
426 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from services.control_api.catalog.loader import CatalogLoader
|
|
from services.control_api.catalog.reconciler import CatalogReconciler
|
|
from services.control_api.catalog.router import build_catalog_router
|
|
from services.control_api.catalog.store import MemoryReconcileRunStore
|
|
from services.control_api.contextforge.client import ContextForgeClient
|
|
from services.control_api.contextforge.fake import FakeContextForge
|
|
from services.control_api.contextforge.models import ContextForgeSettings
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def adapter_for(fake: FakeContextForge) -> ContextForgeClient:
|
|
return ContextForgeClient(
|
|
ContextForgeSettings(base_url="https://gateway.invalid", auth_token_env="TOKEN"), # noqa: S106
|
|
transport=fake.transport(),
|
|
environ={"TOKEN": "fake-token"},
|
|
)
|
|
|
|
|
|
class CatalogLoaderTests(unittest.TestCase):
|
|
def test_schema_store_loads_once_per_catalog_load_without_changing_validation(self) -> None:
|
|
class CountingCatalogLoader(CatalogLoader):
|
|
store_loads = 0
|
|
|
|
def _schema_store(self) -> dict[str, Any]:
|
|
self.store_loads += 1
|
|
return super()._schema_store()
|
|
|
|
loader = CountingCatalogLoader(ROOT / "catalog")
|
|
report = loader.load()
|
|
self.assertTrue(report.valid, report.issues)
|
|
self.assertEqual(len(report.documents), 281)
|
|
self.assertEqual(report.issues, ())
|
|
self.assertEqual(loader.store_loads, 1)
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory) / "catalog"
|
|
project_dir = root / "projects"
|
|
project_dir.mkdir(parents=True)
|
|
payload = json.loads(
|
|
(ROOT / "catalog/projects/general-infrastructure.yaml").read_text(encoding="utf-8")
|
|
)
|
|
payload["spec"]["virtualServers"][0]["endpointAlias"] = "not-an-alias"
|
|
payload["spec"]["capabilityPacks"] = []
|
|
payload["spec"]["connectorBindings"] = []
|
|
payload["spec"]["credentialToken"] = "plain-secret-value"
|
|
(project_dir / "bad.yaml").write_text(json.dumps(payload), encoding="utf-8")
|
|
invalid_loader = CountingCatalogLoader(root, schema_root=ROOT / "contracts/schemas")
|
|
invalid_report = invalid_loader.load()
|
|
|
|
self.assertFalse(invalid_report.valid)
|
|
self.assertEqual(
|
|
{
|
|
(issue.code, issue.source.file, issue.source.pointer)
|
|
for issue in invalid_report.issues
|
|
},
|
|
{
|
|
(
|
|
"schema_error",
|
|
"projects/bad.yaml",
|
|
"/spec/virtualServers/0/endpointAlias",
|
|
),
|
|
("schema_error", "projects/bad.yaml", "/spec/capabilityPacks"),
|
|
("schema_error", "projects/bad.yaml", "/spec"),
|
|
("secret_value", "projects/bad.yaml", "/spec/credentialToken"),
|
|
},
|
|
)
|
|
self.assertEqual(invalid_loader.store_loads, 1)
|
|
self.assertNotIn(
|
|
"plain-secret-value",
|
|
json.dumps([document.payload for document in invalid_report.documents]),
|
|
)
|
|
|
|
def test_committed_catalog_validates_without_network_or_secret_values(self) -> None:
|
|
report = CatalogLoader(ROOT / "catalog").load()
|
|
self.assertTrue(report.valid, report.issues)
|
|
self.assertEqual(len(report.documents), 281)
|
|
self.assertEqual(len(report.issues), 0)
|
|
self.assertIsNotNone(report.desired_hash)
|
|
|
|
def test_openchamber_catalog_stops_at_external_codex_review(self) -> None:
|
|
project = json.loads(
|
|
(ROOT / "catalog/projects/openchamber-agent.yaml").read_text(encoding="utf-8")
|
|
)
|
|
allowed = project["spec"]["virtualServers"][0]["toolAllowPatterns"]
|
|
manifests = {
|
|
json.loads(path.read_text(encoding="utf-8"))["spec"]["canonicalName"]
|
|
for path in (ROOT / "catalog/tools").glob("*.yaml")
|
|
}
|
|
|
|
self.assertNotIn("opencode.delivery.control.merge", allowed)
|
|
self.assertNotIn("opencode.delivery.control.merge", manifests)
|
|
self.assertIn("opencode.delivery.control.result", allowed)
|
|
|
|
def test_stitch_controlled_writes_are_exact_and_policy_bound(self) -> None:
|
|
policy = json.loads(
|
|
(ROOT / "catalog/policies/042-stitch-governed-design-writes.yaml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
)
|
|
match = policy["spec"]["match"]
|
|
self.assertEqual(match["project"], "stitch-design")
|
|
self.assertEqual(match["connectors"], ["stitch"])
|
|
self.assertEqual(match["riskClasses"], ["R2"])
|
|
self.assertEqual(
|
|
set(match["toolPatterns"]),
|
|
{
|
|
"stitch.project.create",
|
|
"stitch.screen.edit",
|
|
"stitch.screen.generate",
|
|
"stitch.screen.variants",
|
|
},
|
|
)
|
|
self.assertTrue(policy["spec"]["conditions"]["requireEnabledClient"])
|
|
self.assertTrue(policy["spec"]["conditions"]["requireBinding"])
|
|
|
|
def test_gitea_analysis_profile_stays_small_and_job_only(self) -> None:
|
|
profile = json.loads(
|
|
(ROOT / "catalog/projects/gitea-analysis.yaml").read_text(encoding="utf-8")
|
|
)
|
|
server = profile["spec"]["virtualServers"][0]
|
|
|
|
self.assertEqual(server["endpointAlias"], "/mcp/gitea-analysis/prod")
|
|
self.assertEqual(
|
|
server["toolAllowPatterns"],
|
|
[
|
|
"projectbrain.analysis.start",
|
|
"projectbrain.analysis.list",
|
|
"projectbrain.analysis.status",
|
|
"projectbrain.analysis.results",
|
|
"projectbrain.analysis.cancel",
|
|
"projectbrain.analysis.render",
|
|
],
|
|
)
|
|
|
|
def test_gitea_loop_profile_is_focused_and_closed_loop_complete(self) -> None:
|
|
profile = json.loads(
|
|
(ROOT / "catalog/projects/gitea-loop.yaml").read_text(encoding="utf-8")
|
|
)
|
|
server = profile["spec"]["virtualServers"][0]
|
|
|
|
self.assertEqual(server["endpointAlias"], "/mcp/gitea-loop/prod")
|
|
self.assertLessEqual(len(server["toolAllowPatterns"]), 16)
|
|
self.assertTrue(
|
|
{
|
|
"gitea.repository.create",
|
|
"gitea.change.review",
|
|
"gitea.change.reconcile",
|
|
"gitea.validation.failure.get",
|
|
"gitea.pullrequest.sync",
|
|
"gitea.pullrequest.merge",
|
|
"gitea.pullrequest.validation.ensure",
|
|
"gitea.workspace.status",
|
|
"gitea.workspace.run",
|
|
"projectbrain.*",
|
|
}.issubset(server["toolAllowPatterns"])
|
|
)
|
|
self.assertEqual(len(server["toolAllowPatterns"]), 16)
|
|
self.assertIn("gitea-controlled-writer", profile["spec"]["capabilityPacks"])
|
|
self.assertEqual(
|
|
{binding["ref"] for binding in profile["spec"]["connectorBindings"]},
|
|
{"gitea-default", "projectbrain-default"},
|
|
)
|
|
|
|
def test_gitea_loop_tools_have_explicit_browser_safe_manifests(self) -> None:
|
|
profile = json.loads(
|
|
(ROOT / "catalog/projects/gitea-loop.yaml").read_text(encoding="utf-8")
|
|
)
|
|
patterns = set(profile["spec"]["virtualServers"][0]["toolAllowPatterns"])
|
|
manifests = {}
|
|
for path in (ROOT / "catalog/tools").glob("*.yaml"):
|
|
document = json.loads(path.read_text(encoding="utf-8"))
|
|
manifests[document["spec"]["canonicalName"]] = document["spec"]
|
|
|
|
allowed = {
|
|
name
|
|
for name in manifests
|
|
if any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns)
|
|
}
|
|
explicit = {pattern for pattern in patterns if not any(char in pattern for char in "*?[")}
|
|
self.assertEqual(explicit - manifests.keys(), set())
|
|
self.assertTrue({name for name in manifests if name.startswith("projectbrain.")} <= allowed)
|
|
for name in allowed:
|
|
limits = manifests[name]["limits"]
|
|
self.assertLessEqual(limits["maxOutputBytes"], 524_288, name)
|
|
self.assertLessEqual(limits["maxItems"], 100, name)
|
|
for name in ("gitea.workspace.status", "projectbrain.analysis.status"):
|
|
self.assertIn("pollAfterMs", manifests[name]["description"])
|
|
|
|
def test_server_ops_profile_is_small_and_appops_only(self) -> None:
|
|
profile = json.loads(
|
|
(ROOT / "catalog/projects/server-ops.yaml").read_text(encoding="utf-8")
|
|
)
|
|
server = profile["spec"]["virtualServers"][0]
|
|
|
|
self.assertEqual(server["endpointAlias"], "/mcp/server-ops/prod")
|
|
self.assertEqual(server["toolAllowPatterns"], ["server.*"])
|
|
self.assertEqual(profile["spec"]["capabilityPacks"], ["appops-operator"])
|
|
self.assertEqual(
|
|
{binding["ref"] for binding in profile["spec"]["connectorBindings"]},
|
|
{"appops-default"},
|
|
)
|
|
|
|
def test_invalid_field_and_secret_report_file_and_pointer(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory) / "catalog"
|
|
project_dir = root / "projects"
|
|
project_dir.mkdir(parents=True)
|
|
payload = json.loads(
|
|
(ROOT / "catalog/projects/general-infrastructure.yaml").read_text()
|
|
)
|
|
payload["spec"]["virtualServers"][0]["endpointAlias"] = "not-an-alias"
|
|
payload["spec"]["credentialToken"] = "plain-secret-value"
|
|
(project_dir / "bad.yaml").write_text(json.dumps(payload), encoding="utf-8")
|
|
report = CatalogLoader(root, schema_root=ROOT / "contracts/schemas").load()
|
|
self.assertFalse(report.valid)
|
|
self.assertTrue(
|
|
any(
|
|
issue.code == "schema_error" and issue.source.file == "projects/bad.yaml"
|
|
for issue in report.issues
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
any(
|
|
issue.code == "secret_value" and issue.source.pointer == "/spec/credentialToken"
|
|
for issue in report.issues
|
|
)
|
|
)
|
|
|
|
self.assertNotIn(
|
|
"plain-secret-value", json.dumps([document.payload for document in report.documents])
|
|
)
|
|
|
|
def test_duplicate_identity_is_rejected(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory) / "catalog/projects"
|
|
root.mkdir(parents=True)
|
|
source = json.loads((ROOT / "catalog/projects/general-infrastructure.yaml").read_text())
|
|
(root / "one.yaml").write_text(json.dumps(source), encoding="utf-8")
|
|
(root / "two.yaml").write_text(json.dumps(source), encoding="utf-8")
|
|
report = CatalogLoader(root.parent, schema_root=ROOT / "contracts/schemas").load()
|
|
self.assertFalse(report.valid)
|
|
self.assertTrue(any(issue.code == "duplicate_identity" for issue in report.issues))
|
|
|
|
def test_semantically_identical_key_order_has_same_hash(self) -> None:
|
|
with (
|
|
tempfile.TemporaryDirectory() as first_directory,
|
|
tempfile.TemporaryDirectory() as second_directory,
|
|
):
|
|
first_root = Path(first_directory) / "catalog"
|
|
second_root = Path(second_directory) / "catalog"
|
|
import shutil
|
|
|
|
shutil.copytree(ROOT / "catalog", first_root)
|
|
shutil.copytree(ROOT / "catalog", second_root)
|
|
source = json.loads((ROOT / "catalog/projects/general-infrastructure.yaml").read_text())
|
|
(first_root / "projects/general-infrastructure.yaml").write_text(
|
|
json.dumps(source), encoding="utf-8"
|
|
)
|
|
(second_root / "projects/general-infrastructure.yaml").write_text(
|
|
json.dumps(source, sort_keys=True), encoding="utf-8"
|
|
)
|
|
first = CatalogLoader(first_root, schema_root=ROOT / "contracts/schemas").load()
|
|
second = CatalogLoader(second_root, schema_root=ROOT / "contracts/schemas").load()
|
|
self.assertTrue(first.valid, first.issues)
|
|
self.assertEqual(first.desired_hash, second.desired_hash)
|
|
|
|
|
|
class CatalogReconcilerTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.fake = FakeContextForge()
|
|
self.adapter = adapter_for(self.fake)
|
|
self.loader = CatalogLoader(ROOT / "catalog", public_base_url="https://mcp.example.com")
|
|
self.reconciler = CatalogReconciler(
|
|
self.loader, self.adapter, store=MemoryReconcileRunStore()
|
|
)
|
|
self.workspace_id = uuid4()
|
|
self.project_id = uuid4()
|
|
|
|
def tearDown(self) -> None:
|
|
self.adapter.close()
|
|
|
|
def test_apply_then_apply_again_is_noop(self) -> None:
|
|
first = self.reconciler.apply(self.workspace_id, self.project_id)
|
|
self.assertEqual(first.status, "succeeded")
|
|
self.assertTrue(any(item["action"] == "create" for item in first.applied_changes))
|
|
requests_after_first = len(self.fake.requests)
|
|
second = self.reconciler.apply(self.workspace_id, self.project_id)
|
|
self.assertEqual(second.status, "succeeded")
|
|
self.assertEqual(second.applied_changes, ())
|
|
self.assertEqual(len(self.fake.requests), requests_after_first + 2)
|
|
|
|
def test_partial_gateway_failure_is_truthful_and_retryable(self) -> None:
|
|
class FailingAdapter:
|
|
def __init__(self, client: ContextForgeClient) -> None:
|
|
self.client = client
|
|
self.failed = False
|
|
|
|
def list_gateways(self, **kwargs: object):
|
|
return self.client.list_gateways(**kwargs)
|
|
|
|
def ensure_gateway(self, desired):
|
|
if not self.failed:
|
|
self.failed = True
|
|
raise RuntimeError("simulated partial failure")
|
|
return self.client.ensure_gateway(desired)
|
|
|
|
failing = FailingAdapter(self.adapter)
|
|
failing_reconciler = CatalogReconciler(
|
|
self.loader, failing, store=MemoryReconcileRunStore()
|
|
)
|
|
failed = failing_reconciler.apply(self.workspace_id, self.project_id)
|
|
self.assertEqual(failed.status, "failed")
|
|
self.assertTrue(failed.verification["resumable"])
|
|
self.assertEqual(failed.applied_changes[0]["status"], "failed")
|
|
recovered = failing_reconciler.apply(self.workspace_id, self.project_id)
|
|
self.assertEqual(recovered.status, "succeeded")
|
|
|
|
def test_unknown_gateway_is_drift_and_not_deleted(self) -> None:
|
|
from services.control_api.contextforge.models import GatewayDesiredState
|
|
|
|
self.adapter.ensure_gateway(
|
|
GatewayDesiredState(
|
|
slug="operator-owned", url="https://example.com/mcp", description="unknown"
|
|
)
|
|
)
|
|
plan = self.reconciler.preview()
|
|
self.assertTrue(any(item.slug == "operator-owned" for item in plan.drift))
|
|
self.reconciler.apply(self.workspace_id, self.project_id)
|
|
self.assertIn("operator-owned", {item["name"] for item in self.fake.gateways.values()})
|
|
|
|
def test_lock_recovers_after_exception_and_authoritative_run_store_survives_restart(
|
|
self,
|
|
) -> None:
|
|
from datetime import UTC, datetime
|
|
|
|
from packages.hub_core.domain import Project, ReconcileRun, Scope, Workspace
|
|
from services.control_api.catalog.reconciler import ReconciliationLock
|
|
from services.control_api.catalog.store import SqlAlchemyReconcileRunStore
|
|
from services.control_api.persistence.db import create_engine_from_url, session_factory
|
|
from services.control_api.persistence.models import Base
|
|
from services.control_api.persistence.repositories import (
|
|
ProjectRepository,
|
|
WorkspaceRepository,
|
|
)
|
|
|
|
engine = create_engine_from_url("sqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
sessions = session_factory(engine)
|
|
now = datetime.now(UTC)
|
|
with self.assertRaises(RuntimeError):
|
|
with ReconciliationLock("wp060-recovery"):
|
|
raise RuntimeError("simulated redis/cache loss")
|
|
with ReconciliationLock("wp060-recovery"):
|
|
pass
|
|
with sessions.begin() as session:
|
|
WorkspaceRepository(session, Scope.workspace(self.workspace_id)).create(
|
|
Workspace(
|
|
id=self.workspace_id,
|
|
slug="itworx",
|
|
name="ITWorx",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
)
|
|
ProjectRepository(session, Scope.workspace(self.workspace_id)).create(
|
|
Project(
|
|
id=self.project_id,
|
|
workspace_id=self.workspace_id,
|
|
slug="catalog",
|
|
name="Catalog",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
)
|
|
store = SqlAlchemyReconcileRunStore(
|
|
session, Scope.project(self.workspace_id, self.project_id)
|
|
)
|
|
run = ReconcileRun(
|
|
workspace_id=self.workspace_id,
|
|
project_id=self.project_id,
|
|
desired_hash="a" * 64,
|
|
status="created",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
stored = store.create(run)
|
|
updated = stored.model_copy(
|
|
update={"status": "planning", "revision": 2, "updated_at": now}
|
|
)
|
|
store.save(updated)
|
|
engine.dispose()
|
|
|
|
def test_router_requires_authorization_and_exposes_scoped_operations(self) -> None:
|
|
app = FastAPI()
|
|
app.include_router(build_catalog_router(self.reconciler, authorize=lambda _: None))
|
|
client = TestClient(app)
|
|
denied_app = FastAPI()
|
|
denied_app.include_router(build_catalog_router(self.reconciler))
|
|
denied = TestClient(denied_app).post("/catalog/validate")
|
|
self.assertEqual(denied.status_code, 403)
|
|
valid = client.post("/catalog/validate")
|
|
self.assertEqual(valid.status_code, 200)
|
|
body = {"workspace_id": str(self.workspace_id), "project_id": str(self.project_id)}
|
|
applied = client.post("/catalog/apply", json=body)
|
|
self.assertEqual(applied.status_code, 200)
|
|
run = client.get(f"/catalog/status/{applied.json()['id']}")
|
|
self.assertEqual(run.status_code, 200)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|