A required namespace absent from the connected-service inventory was never counted as missing, because missing_namespaces was populated only while walking connected_servers. The session therefore reported attachment_healthy true, discovery_status namespaces_attached and error_type None while holding no attachment proof for that namespace, and the gate text told the operator "the host reports Connected" about a service nothing had reported Connected. assess_connected_namespace_attachment now classifies the two conditions apart. missing_namespaces keeps its meaning: required, Connected at the host, absent from the session tool surface -- the actual #708 defect, still typed mcp_connected_namespaces_missing. Required namespaces absent from the connected inventory land in the new not_connected_namespaces list, typed mcp_required_namespaces_not_connected, so a caller is never pointed at an attachment recovery for a service that never connected. attachment_healthy is false whenever any required namespace lacks attachment proof under either condition, error_types reports every condition present so neither hides the other, and namespace_conditions carries the per-namespace connected/attached verdict. Evidence that disagrees with itself -- attached in the session yet absent from the connected inventory -- is reported as contradictory and fails closed rather than being read as proof. attachment_gate_from_session states only what the recorded evidence supports: the Connected wording appears solely when connected is true, the not-connected wording when it is false, and a neutral refusal when connected status was never recorded. Review and merge stay fail-closed in all three cases. _record_live_namespace_attachment consumes the per-namespace verdicts instead of pinning the session-wide error_type onto every unattached namespace and instead of deriving health from a missing list that tracked only one of the two conditions. The two existing cases in test_issue_708_mcp_namespace_attachment.py declared no required_namespaces, so they inherited a default set containing a namespace their connected list omitted. Under the corrected rule that is a false-healthy assertion, so each now declares the required set it actually means. Closes #708 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
284 lines
12 KiB
Python
284 lines
12 KiB
Python
"""Regression tests for Issue #708 B2: not-connected is not Connected-but-unattached.
|
|
|
|
The first #708 slice counted a namespace as ``missing`` only when it appeared in
|
|
``connected_servers``. A *required* namespace absent from that inventory was therefore
|
|
never counted at all, so the session reported ``attachment_healthy: true`` with
|
|
``error_type: None`` while holding no attachment proof for it — and the gate text told the
|
|
operator "the host reports Connected" about a service nothing had reported Connected.
|
|
|
|
These tests pin the corrected distinction:
|
|
|
|
* required and not connected never yields a healthy verdict,
|
|
* it is typed ``mcp_required_namespaces_not_connected``, never the #708 condition,
|
|
* ``mcp_connected_namespaces_missing`` stays reserved for genuinely Connected namespaces,
|
|
* both categories still fail review and merge closed, with their own reason,
|
|
* per-namespace ``connected``/``attached`` evidence is reported accurately,
|
|
* one session's evidence cannot clear another session's block.
|
|
"""
|
|
|
|
import mcp_namespace_health
|
|
|
|
|
|
ROLES = ["gitea-author", "gitea-reviewer", "gitea-merger", "gitea-tools"]
|
|
|
|
NOT_CONNECTED = mcp_namespace_health.ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED
|
|
CONNECTED_MISSING = mcp_namespace_health.ERROR_CONNECTED_NAMESPACES_MISSING
|
|
|
|
|
|
def _assess(connected, attached, required):
|
|
return mcp_namespace_health.assess_connected_namespace_attachment(
|
|
connected_servers=connected,
|
|
attached_session_namespaces=attached,
|
|
required_namespaces=required,
|
|
)
|
|
|
|
|
|
def _store(assessment):
|
|
"""The recorder contract: per-namespace verdicts feed the gate."""
|
|
return dict(assessment["namespace_conditions"])
|
|
|
|
|
|
# --- the four evidence combinations ----------------------------------------
|
|
|
|
|
|
def test_connected_and_attached_is_healthy():
|
|
res = _assess(ROLES, ROLES, ROLES)
|
|
assert res["attachment_healthy"] is True
|
|
assert res["error_type"] is None
|
|
assert res["missing_namespaces"] == []
|
|
assert res["not_connected_namespaces"] == []
|
|
assert res["discovery_status"] == "namespaces_attached"
|
|
|
|
|
|
def test_connected_but_unattached_keeps_the_708_condition():
|
|
res = _assess(ROLES, ["gitea-author"], ROLES)
|
|
assert res["attachment_healthy"] is False
|
|
assert res["error_type"] == CONNECTED_MISSING
|
|
assert res["not_connected_namespaces"] == []
|
|
assert sorted(res["missing_namespaces"]) == [
|
|
"gitea-merger",
|
|
"gitea-reviewer",
|
|
"gitea-tools",
|
|
]
|
|
assert res["discovery_status"] == "connected_but_namespaces_missing"
|
|
|
|
|
|
def test_required_but_not_connected_is_never_healthy():
|
|
"""The reviewer's exact reproduction from review 637."""
|
|
res = _assess(
|
|
["gitea-reviewer"], ["gitea-reviewer"], ["gitea-reviewer", "gitea-merger"]
|
|
)
|
|
assert res["attachment_healthy"] is False
|
|
assert res["success"] is False
|
|
assert res["error_type"] is not None
|
|
assert res["telemetry"]["not_connected_count"] == 1
|
|
|
|
|
|
def test_required_but_not_connected_is_typed_distinctly():
|
|
res = _assess(
|
|
["gitea-reviewer"], ["gitea-reviewer"], ["gitea-reviewer", "gitea-merger"]
|
|
)
|
|
assert res["error_type"] == NOT_CONNECTED
|
|
assert res["discovery_status"] == "required_namespaces_not_connected"
|
|
assert res["not_connected_namespaces"] == ["gitea-merger"]
|
|
# Not collapsed into the #708 condition, nor into config drift (#672) or
|
|
# transport-closed (#584).
|
|
assert CONNECTED_MISSING not in res["error_types"]
|
|
assert res["missing_namespaces"] == []
|
|
assert res["error_type"] not in {"mcp_config_drift", "transport_closed"}
|
|
|
|
|
|
def test_not_connected_reason_makes_no_connected_claim():
|
|
res = _assess(
|
|
["gitea-reviewer"], ["gitea-reviewer"], ["gitea-reviewer", "gitea-merger"]
|
|
)
|
|
about_merger = [r for r in res["reasons"] if "gitea-merger" in r]
|
|
assert about_merger, "the not-connected namespace must be named in the reasons"
|
|
for reason in about_merger:
|
|
assert "report Connected at host/CLI layer" not in reason
|
|
assert "gitea-merger" in res["exact_next_action"]
|
|
|
|
|
|
def test_neither_connected_nor_attached_reports_disconnected():
|
|
res = _assess([], [], ROLES)
|
|
assert res["attachment_healthy"] is False
|
|
assert res["discovery_status"] == "disconnected"
|
|
assert res["error_type"] == NOT_CONNECTED
|
|
assert sorted(res["not_connected_namespaces"]) == sorted(ROLES)
|
|
assert res["missing_namespaces"] == []
|
|
|
|
|
|
# --- mixed required namespaces ---------------------------------------------
|
|
|
|
|
|
def test_mixed_connected_attached_and_not_connected():
|
|
res = _assess(
|
|
["gitea-author"], ["gitea-author"], ["gitea-author", "gitea-merger"]
|
|
)
|
|
assert res["attachment_healthy"] is False
|
|
assert res["not_connected_namespaces"] == ["gitea-merger"]
|
|
assert res["missing_namespaces"] == []
|
|
conditions = res["namespace_conditions"]
|
|
assert conditions["gitea-author"]["attachment_healthy"] is True
|
|
assert conditions["gitea-author"]["condition"] is None
|
|
assert conditions["gitea-merger"]["attachment_healthy"] is False
|
|
assert conditions["gitea-merger"]["condition"] == NOT_CONNECTED
|
|
|
|
|
|
def test_both_conditions_present_are_both_reported():
|
|
"""One namespace Connected-but-unattached, another never connected."""
|
|
res = _assess(
|
|
["gitea-author", "gitea-reviewer"],
|
|
["gitea-author"],
|
|
["gitea-author", "gitea-reviewer", "gitea-merger"],
|
|
)
|
|
assert res["missing_namespaces"] == ["gitea-reviewer"]
|
|
assert res["not_connected_namespaces"] == ["gitea-merger"]
|
|
# Neither condition is hidden by the other; error_type names the primary one.
|
|
assert sorted(res["error_types"]) == sorted([NOT_CONNECTED, CONNECTED_MISSING])
|
|
assert res["error_type"] == NOT_CONNECTED
|
|
|
|
|
|
# --- unknown, partial, malformed, contradictory evidence --------------------
|
|
|
|
|
|
def test_unknown_attachment_evidence_is_not_healthy():
|
|
"""No session tool surface reported at all is unproven, not proven good."""
|
|
res = _assess(ROLES, None, ROLES)
|
|
assert res["attachment_healthy"] is False
|
|
assert res["telemetry"]["attached_count"] == 0
|
|
assert res["error_type"] == CONNECTED_MISSING
|
|
|
|
|
|
def test_partial_evidence_gates_only_the_unproven_roles():
|
|
res = _assess(ROLES, ["gitea-author", "gitea-tools"], ROLES)
|
|
store = _store(res)
|
|
assert mcp_namespace_health.attachment_gate_from_session("work_issue", store) == []
|
|
assert mcp_namespace_health.attachment_gate_from_session("review_pr", store)
|
|
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
|
|
|
|
|
def test_malformed_namespace_entries_are_discarded_not_trusted():
|
|
"""Blank and whitespace-only names must not become namespaces or proof."""
|
|
res = mcp_namespace_health.assess_connected_namespace_attachment(
|
|
connected_servers=["gitea-author", "", " "],
|
|
attached_session_namespaces=["gitea-author", ""],
|
|
required_namespaces=["gitea-author", " "],
|
|
)
|
|
assert "" not in res["proof_of_connected_vs_attached"]
|
|
assert " " not in res["proof_of_connected_vs_attached"]
|
|
assert res["attachment_healthy"] is True
|
|
assert res["telemetry"]["required_count"] == 1
|
|
|
|
|
|
def test_contradictory_attached_without_connected_fails_closed():
|
|
"""Attached in the session yet absent from the connected inventory."""
|
|
res = _assess(["gitea-author"], ["gitea-author", "gitea-merger"], ROLES)
|
|
assert res["attachment_healthy"] is False
|
|
assert "gitea-merger" in res["contradictory_namespaces"]
|
|
assert "gitea-merger" in res["not_connected_namespaces"]
|
|
assert res["namespace_conditions"]["gitea-merger"]["contradictory_evidence"] is True
|
|
assert res["namespace_conditions"]["gitea-merger"]["attachment_healthy"] is False
|
|
assert any("contradictory evidence" in r for r in res["reasons"])
|
|
assert res["telemetry"]["contradictory_count"] >= 1
|
|
|
|
|
|
def test_duplicate_required_entries_are_counted_once():
|
|
res = _assess(["gitea-author"], ["gitea-author"], ["gitea-author", "gitea-author"])
|
|
assert res["telemetry"]["required_count"] == 1
|
|
assert res["attachment_healthy"] is True
|
|
|
|
|
|
# --- review and merge behaviour for both failure categories -----------------
|
|
|
|
|
|
def test_merge_fails_closed_when_required_namespace_never_connected():
|
|
store = _store(_assess(["gitea-author"], ["gitea-author"], ROLES))
|
|
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
|
assert reasons
|
|
assert NOT_CONNECTED in reasons[0]
|
|
assert "fail closed, #708" in reasons[0]
|
|
|
|
|
|
def test_review_fails_closed_when_required_namespace_never_connected():
|
|
store = _store(_assess(["gitea-author"], ["gitea-author"], ROLES))
|
|
reasons = mcp_namespace_health.attachment_gate_from_session("review_pr", store)
|
|
assert reasons
|
|
assert NOT_CONNECTED in reasons[0]
|
|
assert "fail closed, #708" in reasons[0]
|
|
|
|
|
|
def test_not_connected_block_never_claims_the_host_reports_connected():
|
|
store = _store(_assess(["gitea-author"], ["gitea-author"], ROLES))
|
|
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
|
assert "the host reports Connected" not in reasons[0]
|
|
assert "absent from the connected-service inventory" in reasons[0]
|
|
|
|
|
|
def test_connected_but_unattached_block_still_says_connected():
|
|
store = _store(_assess(ROLES, ["gitea-author"], ROLES))
|
|
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
|
assert CONNECTED_MISSING in reasons[0]
|
|
assert "the host reports Connected" in reasons[0]
|
|
|
|
|
|
def test_both_categories_offer_only_the_sanctioned_recovery():
|
|
for store in (
|
|
_store(_assess(ROLES, [], ROLES)),
|
|
_store(_assess(["gitea-author"], ["gitea-author"], ROLES)),
|
|
):
|
|
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
|
joined = " ".join(reasons)
|
|
assert "reconnect" in joined.lower()
|
|
assert "Workflow Safety Hard Stop (#708)" in joined
|
|
for forbidden in ("pkill", "chmod", "curl ", ".env", "sys.path"):
|
|
assert forbidden not in reasons[0]
|
|
|
|
|
|
def test_entry_without_connected_evidence_blocks_without_asserting_either():
|
|
"""A legacy/partial store entry must fail closed and claim nothing it cannot prove."""
|
|
store = {"gitea-merger": {"namespace": "gitea-merger", "attached": False}}
|
|
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
|
|
assert reasons
|
|
assert "no connected-status evidence" in reasons[0]
|
|
assert "the host reports Connected" not in reasons[0]
|
|
assert "fail closed, #708" in reasons[0]
|
|
|
|
|
|
# --- session isolation ------------------------------------------------------
|
|
|
|
|
|
def test_one_session_evidence_does_not_clear_another_session_block():
|
|
"""Attachment evidence is per-session state; it must not travel between sessions."""
|
|
blocked_session = _store(_assess(["gitea-author"], ["gitea-author"], ROLES))
|
|
healthy_session = _store(_assess(ROLES, ROLES, ROLES))
|
|
|
|
assert (
|
|
mcp_namespace_health.attachment_gate_from_session("merge_pr", healthy_session)
|
|
== []
|
|
)
|
|
# The healthy session's verdict is not consulted for the blocked session.
|
|
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", blocked_session)
|
|
# And the blocked session's store is unchanged by the healthy one existing.
|
|
assert blocked_session["gitea-merger"]["attachment_healthy"] is False
|
|
|
|
|
|
def test_gate_reads_only_the_store_it_is_given():
|
|
healthy_session = _store(_assess(ROLES, ROLES, ROLES))
|
|
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", {}) == []
|
|
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", None) == []
|
|
assert (
|
|
mcp_namespace_health.attachment_gate_from_session("merge_pr", healthy_session)
|
|
== []
|
|
)
|
|
|
|
|
|
# --- telemetry stays secret-free -------------------------------------------
|
|
|
|
|
|
def test_not_connected_telemetry_leaks_no_secrets():
|
|
res = _assess(["gitea-author"], ["gitea-author"], ROLES)
|
|
blob = repr(res["telemetry"]).lower()
|
|
for leak in ("token", "authorization", "password", "secret", "/users/", "http"):
|
|
assert leak not in blob
|