398 lines
13 KiB
Python
398 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from mcp import Client
|
|
from test_hub_component_transport import call
|
|
from test_hub_component_transport import controller as _controller_fixture
|
|
|
|
from backend.app import mcp_server
|
|
from backend.app.services.deployment_broker import DeploymentBrokerClient
|
|
|
|
REPOSITORY = "jens/registered-app"
|
|
REQUEST_ID = "11111111-1111-4111-8111-111111111111"
|
|
PREFIX = "/v1/appops/registered-legacy/"
|
|
CAS = {
|
|
"expectedSourceFingerprint": "a" * 64,
|
|
"expectedRuntimeFingerprint": "b" * 64,
|
|
"expectedStateSha256": "c" * 64,
|
|
"expectedContainerIds": {"registered-app": "d" * 64},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def controller(monkeypatch):
|
|
return _controller_fixture.__wrapped__(monkeypatch)
|
|
|
|
|
|
@pytest.fixture
|
|
def broker_calls(monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
DeploymentBrokerClient,
|
|
"_request",
|
|
lambda _self, method, path, **kwargs: calls.append((method, path, kwargs))
|
|
or {"status": "observed"},
|
|
)
|
|
return calls
|
|
|
|
|
|
def test_observe_client_accepts_only_repository_and_exact_route(broker_calls):
|
|
result = DeploymentBrokerClient().registered_legacy_operation(
|
|
"observe", {"repository": REPOSITORY}
|
|
)
|
|
assert result == {"status": "observed"}
|
|
assert broker_calls[0][0:2] == ("POST", PREFIX + "observe")
|
|
assert broker_calls[0][2]["payload"] == {"repository": REPOSITORY}
|
|
with pytest.raises(ValueError):
|
|
DeploymentBrokerClient().registered_legacy_operation(
|
|
"observe", {"repository": REPOSITORY, "environment": {"secret": "value"}}
|
|
)
|
|
assert len(broker_calls) == 1
|
|
|
|
|
|
def test_baseline_default_mcp_preserves_original_payload(broker_calls):
|
|
mcp_server.appops_registered_legacy_baseline(REPOSITORY, REQUEST_ID)
|
|
assert broker_calls[0][2]["payload"] == {
|
|
"repository": REPOSITORY,
|
|
"requestId": REQUEST_ID,
|
|
}
|
|
|
|
|
|
def test_baseline_capture_mcp_forwards_exact_optin_bindings(broker_calls):
|
|
mcp_server.appops_registered_legacy_baseline(
|
|
REPOSITORY,
|
|
REQUEST_ID,
|
|
capture_runtime_environment=True,
|
|
expected_source_fingerprint=CAS["expectedSourceFingerprint"],
|
|
expected_runtime_fingerprint=CAS["expectedRuntimeFingerprint"],
|
|
expected_state_sha256=CAS["expectedStateSha256"],
|
|
expected_container_ids=CAS["expectedContainerIds"],
|
|
)
|
|
assert broker_calls[0][2]["payload"] == {
|
|
"repository": REPOSITORY,
|
|
"requestId": REQUEST_ID,
|
|
"captureRuntimeEnvironment": True,
|
|
**CAS,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"capture,bindings", [(False, CAS), (True, {}), (1, CAS), ("true", CAS)]
|
|
)
|
|
def test_capture_contract_rejects_before_broker(capture, bindings, broker_calls):
|
|
with pytest.raises(ValueError):
|
|
mcp_server.appops_registered_legacy_baseline(
|
|
REPOSITORY,
|
|
REQUEST_ID,
|
|
capture_runtime_environment=capture,
|
|
expected_source_fingerprint=bindings.get("expectedSourceFingerprint"),
|
|
expected_runtime_fingerprint=bindings.get("expectedRuntimeFingerprint"),
|
|
expected_state_sha256=bindings.get("expectedStateSha256"),
|
|
expected_container_ids=bindings.get("expectedContainerIds"),
|
|
)
|
|
assert not broker_calls
|
|
|
|
|
|
def test_observe_http_has_no_request_id_or_mutating_helpers(controller, monkeypatch):
|
|
module, _projects = controller
|
|
calls = []
|
|
|
|
def observe(payload, **kwargs):
|
|
calls.append((payload, kwargs))
|
|
return {
|
|
"status": "observed",
|
|
"repository": REPOSITORY,
|
|
"sourceFingerprint": CAS["expectedSourceFingerprint"],
|
|
"runtimeFingerprint": CAS["expectedRuntimeFingerprint"],
|
|
"expectedStateSha256": CAS["expectedStateSha256"],
|
|
"containerIDs": CAS["expectedContainerIds"],
|
|
"imageIDs": {"registered-app": "sha256:" + "e" * 64},
|
|
"environmentMismatchKeys": ["registered-app/SERVER_URL"],
|
|
}
|
|
|
|
fake = SimpleNamespace(
|
|
observe=observe,
|
|
read_registered_state=lambda *_args: ({}, None),
|
|
RegisteredLegacyAdoptionError=ValueError,
|
|
)
|
|
monkeypatch.setitem(sys.modules, "registered_legacy_adoption", fake)
|
|
monkeypatch.setattr(
|
|
module,
|
|
"_registered_legacy_api_reader",
|
|
lambda *_args: pytest.fail("observe must not read Git API"),
|
|
)
|
|
code, result = call(
|
|
module,
|
|
PREFIX + "observe",
|
|
{"repository": REPOSITORY},
|
|
"_handle_registered_legacy",
|
|
)
|
|
assert code == 200 and result["status"] == "observed"
|
|
assert calls[0][0] == {"repository": REPOSITORY}
|
|
assert "environment" not in result
|
|
assert result["environmentMismatchKeys"] == ["registered-app/SERVER_URL"]
|
|
assert (
|
|
call(
|
|
module,
|
|
PREFIX + "observe",
|
|
{"repository": REPOSITORY, "requestId": REQUEST_ID},
|
|
"_handle_registered_legacy",
|
|
)[0]
|
|
== 400
|
|
)
|
|
|
|
|
|
def test_observe_mcp_schema_is_readonly_and_calls_no_other_operation(broker_calls):
|
|
async def exercise():
|
|
async with Client(mcp_server.appops_mcp) as client:
|
|
listing = await client.list_tools()
|
|
tool = next(
|
|
item
|
|
for item in listing.tools
|
|
if item.name == "appops.registered_legacy.observe"
|
|
)
|
|
assert tool.annotations.read_only_hint is True
|
|
assert tool.annotations.destructive_hint is False
|
|
assert set(tool.input_schema["properties"]) == {"repository"}
|
|
result = await client.call_tool(tool.name, {"repository": REPOSITORY})
|
|
assert not result.is_error
|
|
|
|
asyncio.run(exercise())
|
|
assert len(broker_calls) == 1
|
|
assert broker_calls[0][2]["payload"] == {"repository": REPOSITORY}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"changes",
|
|
[
|
|
{"captureRuntimeEnvironment": "true", **CAS},
|
|
{"captureRuntimeEnvironment": 1, **CAS},
|
|
{"captureRuntimeEnvironment": False, **CAS},
|
|
{"captureRuntimeEnvironment": True},
|
|
{"captureRuntimeEnvironment": True, **CAS, "expectedSourceFingerprint": "bad"},
|
|
{"captureRuntimeEnvironment": True, **CAS, "expectedContainerIds": {}},
|
|
{"environment": {"PRIVATE_KEY": "caller-value-must-not-escape"}},
|
|
],
|
|
)
|
|
def test_broker_baseline_rejects_incomplete_or_untyped_capture_before_transport(
|
|
changes, broker_calls
|
|
):
|
|
with pytest.raises(ValueError) as error:
|
|
DeploymentBrokerClient().registered_legacy_operation(
|
|
"baseline", {"repository": REPOSITORY, "requestId": REQUEST_ID, **changes}
|
|
)
|
|
assert "caller-value-must-not-escape" not in str(error.value)
|
|
assert not broker_calls
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"changes",
|
|
[
|
|
{"captureRuntimeEnvironment": "true", **CAS},
|
|
{"captureRuntimeEnvironment": 1, **CAS},
|
|
{"captureRuntimeEnvironment": False, **CAS},
|
|
{"captureRuntimeEnvironment": True},
|
|
{
|
|
"captureRuntimeEnvironment": True,
|
|
**CAS,
|
|
"environment": {"PRIVATE_KEY": "caller-value-must-not-escape"},
|
|
},
|
|
],
|
|
)
|
|
def test_http_capture_shape_rejects_before_registry_or_core(
|
|
changes, controller, monkeypatch
|
|
):
|
|
module, _ = controller
|
|
monkeypatch.setattr(
|
|
module,
|
|
"_load_projects",
|
|
lambda: pytest.fail("invalid capture must not reach core"),
|
|
)
|
|
code, result = call(
|
|
module,
|
|
PREFIX + "baseline",
|
|
{"repository": REPOSITORY, "requestId": REQUEST_ID, **changes},
|
|
"_handle_registered_legacy",
|
|
)
|
|
assert code == 400 and result == {"status": "invalid_contract"}
|
|
assert "caller-value-must-not-escape" not in json.dumps(result)
|
|
|
|
|
|
def test_http_observation_extra_environment_is_rejected_without_value_echo(
|
|
controller, monkeypatch
|
|
):
|
|
module, _ = controller
|
|
fake = SimpleNamespace(
|
|
observe=lambda *_args, **_kwargs: {
|
|
"status": "observed",
|
|
"environment": {"PRIVATE_KEY": "private-observer-value"},
|
|
},
|
|
read_registered_state=lambda *_args: ({}, None),
|
|
RegisteredLegacyAdoptionError=type("AdoptionError", (RuntimeError,), {}),
|
|
)
|
|
monkeypatch.setitem(sys.modules, "registered_legacy_adoption", fake)
|
|
code, result = call(
|
|
module,
|
|
PREFIX + "observe",
|
|
{"repository": REPOSITORY},
|
|
"_handle_registered_legacy",
|
|
)
|
|
assert code == 503 and result == {"status": "registered_controller_unavailable"}
|
|
assert "private-observer-value" not in json.dumps(result)
|
|
|
|
|
|
def test_observe_requires_existing_signed_controller_authentication(
|
|
controller, monkeypatch
|
|
):
|
|
module, _ = controller
|
|
monkeypatch.setattr(
|
|
module, "_self_upgrade_signature_is_valid", lambda *_args, **_kwargs: False
|
|
)
|
|
monkeypatch.setattr(
|
|
module,
|
|
"_load_projects",
|
|
lambda: pytest.fail("unauthenticated observe must not read registry"),
|
|
)
|
|
assert (
|
|
call(
|
|
module,
|
|
PREFIX + "observe",
|
|
{"repository": REPOSITORY},
|
|
"_handle_registered_legacy",
|
|
)[0]
|
|
== 403
|
|
)
|
|
|
|
|
|
def test_mcp_wire_capture_boolean_does_not_coerce_strings_or_numbers(broker_calls):
|
|
async def exercise():
|
|
async with Client(mcp_server.appops_mcp) as client:
|
|
listing = await client.list_tools()
|
|
tool = next(
|
|
item
|
|
for item in listing.tools
|
|
if item.name == "appops.registered_legacy.baseline"
|
|
)
|
|
assert tool.annotations.read_only_hint is True
|
|
assert (
|
|
tool.input_schema["properties"]["capture_runtime_environment"][
|
|
"default"
|
|
]
|
|
is False
|
|
)
|
|
for value in ("true", 1):
|
|
result = await client.call_tool(
|
|
tool.name,
|
|
{
|
|
"repository": REPOSITORY,
|
|
"request_id": REQUEST_ID,
|
|
"capture_runtime_environment": value,
|
|
},
|
|
)
|
|
assert result.is_error
|
|
|
|
asyncio.run(exercise())
|
|
assert not broker_calls
|
|
|
|
|
|
def test_http_observe_real_registered_runtime_returns_only_safe_keynames(
|
|
controller, monkeypatch, tmp_path
|
|
):
|
|
from contextlib import nullcontext
|
|
from functools import partial
|
|
from test_registered_legacy_runtime import (
|
|
_load_module,
|
|
production_runtime as runtime_fixture,
|
|
)
|
|
|
|
core = _load_module()
|
|
runtime, boundary, project, model = runtime_fixture.__wrapped__(
|
|
core, monkeypatch, tmp_path
|
|
)
|
|
repository = "jens/quiz-monitor"
|
|
model["services"]["quiz-server"]["environment"]["KNOWLEDGE_OPTIMIZER_MODEL"] = (
|
|
"intended-test-model"
|
|
)
|
|
boundary.live["Config"]["Env"].append(
|
|
"KNOWLEDGE_OPTIMIZER_MODEL=observed-test-model"
|
|
)
|
|
monkeypatch.setattr(core, "_global_deploy_lock", lambda: nullcontext())
|
|
# Reuse the adapter fixture's validated registration against its temporary source tree.
|
|
monkeypatch.setattr(
|
|
core,
|
|
"validate_registered_legacy_target",
|
|
lambda repo, target: target["slug"]
|
|
if repo == repository
|
|
else pytest.fail("wrong registered target"),
|
|
)
|
|
monkeypatch.setattr(
|
|
core, "read_registered_state", lambda *_args, **_kwargs: ({}, None)
|
|
)
|
|
monkeypatch.setattr(
|
|
core,
|
|
"observe",
|
|
partial(core.observe, runtime=runtime, state_root=tmp_path / "state"),
|
|
)
|
|
monkeypatch.setitem(sys.modules, "registered_legacy_adoption", core)
|
|
module, _projects = controller
|
|
monkeypatch.setattr(module, "_load_projects", lambda: {repository: project})
|
|
code, result = call(
|
|
module,
|
|
PREFIX + "observe",
|
|
{"repository": repository},
|
|
"_handle_registered_legacy",
|
|
)
|
|
assert code == 200 and result["status"] == "observed"
|
|
assert result["environmentMismatchKeys"] == [
|
|
"quiz-server/KNOWLEDGE_OPTIMIZER_MODEL"
|
|
]
|
|
assert result["expectedStateSha256"] == core._state_digest(None)
|
|
assert set(result) == {
|
|
"status",
|
|
"repository",
|
|
"expectedStateSha256",
|
|
"sourceFingerprint",
|
|
"runtimeFingerprint",
|
|
"containerIDs",
|
|
"imageIDs",
|
|
"environmentMismatchKeys",
|
|
}
|
|
serialized = json.dumps(result)
|
|
assert "intended-test-model" not in serialized
|
|
assert "observed-test-model" not in serialized
|
|
assert "198.19.1.2" not in serialized
|
|
|
|
|
|
def test_http_complete_capture_forwards_no_environment_values(controller, monkeypatch):
|
|
module, _projects = controller
|
|
called = []
|
|
fake = SimpleNamespace(
|
|
baseline=lambda payload, **_kwargs: called.append(payload)
|
|
or {
|
|
"status": "baseline_captured",
|
|
"capturedEnvironmentKeys": ["service/SETTING"],
|
|
},
|
|
read_registered_state=lambda *_args: ({}, None),
|
|
RegisteredLegacyAdoptionError=ValueError,
|
|
)
|
|
monkeypatch.setitem(sys.modules, "registered_legacy_adoption", fake)
|
|
payload = {
|
|
"repository": REPOSITORY,
|
|
"requestId": REQUEST_ID,
|
|
"captureRuntimeEnvironment": True,
|
|
**CAS,
|
|
}
|
|
code, result = call(
|
|
module, PREFIX + "baseline", payload, "_handle_registered_legacy"
|
|
)
|
|
assert code == 200 and called == [payload]
|
|
assert result == {
|
|
"status": "baseline_captured",
|
|
"capturedEnvironmentKeys": ["service/SETTING"],
|
|
}
|