The MCP server loads capability-gate code into memory at startup, so a newly merged gate (e.g. the branch-delete capability gate #408/#410) does not take effect until the process restarts. A long-running server could therefore still perform a mutation the updated codebase forbids. Runtime profile/config data is already read live from disk each call (gitea_config.load_config re-reads the JSON), so allowed_operations changes apply without a restart. This closes the remaining gap: *code* parity. - master_parity_gate.py: capture the process's startup commit and, at mutation time, compare it against the on-disk master HEAD; pure, injectable assessment plus block-reasons and a restart-required report. - gitea_mcp_server.py: capture _STARTUP_PARITY at import; _profile_operation_gate fails closed for mutating ops when stale while leaving gitea.read allowed; gitea_get_runtime_context surfaces master_parity; new read-only gitea_assess_master_parity tool reports parity/restart-required. - Escape hatches: GITEA_MCP_DISABLE_PARITY_GATE (ops), GITEA_TEST_CURRENT_HEAD (tests). - tests/test_master_parity_gate.py: 18 cases (pure logic, block/report, read override, and server wiring: reads pass, mutations blocked when stale). Validation: venv/bin/python -m pytest -q -> 1618 passed, 6 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
153 lines
6.1 KiB
Python
153 lines
6.1 KiB
Python
"""Tests for the master-parity staleness gate (#420).
|
|
|
|
Covers the pure assessment logic, the mutation-block helper, the env escape
|
|
hatches, and the server-side wiring: reads are never blocked by staleness, a
|
|
mutation is refused when the on-disk master has advanced, and the read-only
|
|
gitea_assess_master_parity tool reports the stale state.
|
|
"""
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
|
|
import master_parity_gate as mp # noqa: E402
|
|
|
|
|
|
SHA_A = "a" * 40
|
|
SHA_B = "b" * 40
|
|
|
|
|
|
class TestAssessMasterParity(unittest.TestCase):
|
|
def test_in_parity_when_heads_match(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
|
self.assertTrue(res["in_parity"])
|
|
self.assertFalse(res["stale"])
|
|
self.assertFalse(res["restart_required"])
|
|
self.assertTrue(res["determinable"])
|
|
|
|
def test_stale_when_master_advanced(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
|
self.assertFalse(res["in_parity"])
|
|
self.assertTrue(res["stale"])
|
|
self.assertTrue(res["restart_required"])
|
|
self.assertTrue(res["determinable"])
|
|
self.assertTrue(any("restart" in r for r in res["reasons"]))
|
|
|
|
def test_missing_startup_head_not_determinable(self):
|
|
res = mp.assess_master_parity({"startup_head": None}, SHA_B)
|
|
self.assertFalse(res["determinable"])
|
|
self.assertFalse(res["stale"])
|
|
self.assertTrue(res["in_parity"])
|
|
|
|
def test_missing_current_head_not_determinable(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, None)
|
|
self.assertFalse(res["determinable"])
|
|
self.assertFalse(res["stale"])
|
|
self.assertTrue(res["in_parity"])
|
|
|
|
def test_none_startup_baseline(self):
|
|
res = mp.assess_master_parity(None, SHA_A)
|
|
self.assertFalse(res["determinable"])
|
|
self.assertFalse(res["stale"])
|
|
|
|
|
|
class TestBlockReasonsAndReport(unittest.TestCase):
|
|
def test_stale_produces_block_reasons(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
|
self.assertTrue(mp.parity_block_reasons(res))
|
|
|
|
def test_in_parity_no_block_reasons(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
|
self.assertEqual(mp.parity_block_reasons(res), [])
|
|
|
|
def test_disable_env_suppresses_block(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
|
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
|
|
self.assertTrue(mp.gate_disabled())
|
|
self.assertEqual(mp.parity_block_reasons(res), [])
|
|
|
|
def test_report_shape_when_stale(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
|
report = mp.parity_report(res)
|
|
self.assertEqual(report["kind"], "server_stale")
|
|
self.assertTrue(report["restart_required"])
|
|
self.assertEqual(report["startup_head"], SHA_A)
|
|
self.assertEqual(report["current_head"], SHA_B)
|
|
self.assertTrue(report["recovery"])
|
|
|
|
|
|
class TestReadGitHead(unittest.TestCase):
|
|
def test_test_override_takes_precedence(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
|
self.assertEqual(mp.read_git_head("/nonexistent"), SHA_B)
|
|
|
|
def test_blank_override_is_none(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: " "}):
|
|
self.assertIsNone(mp.read_git_head("/nonexistent"))
|
|
|
|
def test_empty_root_is_none(self):
|
|
# No override set; empty root cannot resolve a HEAD.
|
|
env = {k: v for k, v in os.environ.items()
|
|
if k != mp.ENV_TEST_CURRENT_HEAD}
|
|
with patch.dict(os.environ, env, clear=True):
|
|
self.assertIsNone(mp.read_git_head(""))
|
|
|
|
|
|
class TestServerWiring(unittest.TestCase):
|
|
"""Integration with the gate choke point in the server namespace."""
|
|
|
|
def setUp(self):
|
|
import mcp_server
|
|
self.srv = mcp_server
|
|
# Pin a known startup baseline so the current-HEAD override can diverge.
|
|
self._saved = self.srv._STARTUP_PARITY
|
|
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
|
|
"startup_head": SHA_A}
|
|
|
|
def tearDown(self):
|
|
self.srv._STARTUP_PARITY = self._saved
|
|
|
|
def test_reads_not_blocked_when_stale(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
|
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
|
|
|
|
def test_mutation_blocked_when_stale(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
|
reasons = self.srv._master_parity_block("gitea.pr.create")
|
|
self.assertTrue(reasons)
|
|
# The profile-operation choke point surfaces the same block.
|
|
self.assertTrue(self.srv._profile_operation_gate("gitea.pr.create"))
|
|
|
|
def test_mutation_allowed_when_in_parity(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
|
|
self.assertEqual(
|
|
self.srv._master_parity_block("gitea.pr.create"), [])
|
|
|
|
def test_disable_env_lets_mutation_through(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B,
|
|
mp.ENV_DISABLE: "1"}):
|
|
self.assertEqual(
|
|
self.srv._master_parity_block("gitea.pr.create"), [])
|
|
|
|
def test_assess_tool_reports_stale(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
|
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
|
self.assertTrue(out["stale"])
|
|
self.assertTrue(out["restart_required"])
|
|
self.assertEqual(out["startup_head"], SHA_A)
|
|
self.assertEqual(out["current_head"], SHA_B)
|
|
self.assertIn("report", out)
|
|
|
|
def test_assess_tool_reports_in_parity(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
|
|
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
|
self.assertFalse(out["stale"])
|
|
self.assertTrue(out["in_parity"])
|
|
self.assertNotIn("report", out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|