"""Regression tests for #540: comment_issue preflight must not poison the actual reconciler role for #274 branch-only / #475 root-checkout exemptions. `resolve_task_capability("comment_issue")` stamps ``_preflight_resolved_role = "author"`` because ``comment_issue`` has ``required_role_kind = author``. Before the fix, ``_effective_workspace_role`` preferred that stamp and a genuine ``prgs-reconciler`` session was treated as an author inside the #274 branch-only mutation guard and the #475 root checkout guard, blocking ``gitea_create_issue_comment`` from the control checkout. The fix keys the role exemptions off the *actual profile role* as well, so the exemption survives the poisoned task role while author profiles stay blocked. """ from __future__ import annotations import os import sys import unittest from pathlib import Path from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import gitea_mcp_server as srv # noqa: E402 import root_checkout_guard as rcg # noqa: E402 FAKE_AUTH = "token test" current_file_path = Path(__file__).resolve() if "branches" in current_file_path.parts: CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3]) else: CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1]) MASTER_SHA = "a" * 40 OTHER_SHA = "b" * 40 RECONCILER_PROFILE = { "profile_name": "prgs-reconciler", "allowed_operations": [ "gitea.read", "gitea.pr.close", "gitea.pr.comment", "gitea.issue.comment", ], "forbidden_operations": [ "gitea.pr.approve", "gitea.pr.merge", "gitea.pr.create", "gitea.branch.push", "gitea.repo.commit", ], "audit_label": "prgs-reconciler", } AUTHOR_PROFILE = { "profile_name": "prgs-author", "allowed_operations": [ "gitea.read", "gitea.issue.comment", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.repo.commit", ], "forbidden_operations": [ "gitea.pr.approve", "gitea.pr.merge", ], "audit_label": "prgs-author", } REVIEWER_PROFILE = { "profile_name": "prgs-reviewer", "allowed_operations": ["gitea.read", "gitea.pr.approve", "gitea.pr.merge"], "forbidden_operations": ["gitea.pr.create", "gitea.branch.push"], "audit_label": "prgs-reviewer", } class TestActualProfileRole(unittest.TestCase): """`_actual_profile_role` ignores the poisoned preflight task role (#540).""" def tearDown(self): srv._preflight_resolved_role = None @patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE) def test_reconciler_profile_role_survives_author_task_stamp(self, _profile): srv._preflight_resolved_role = "author" # comment_issue poison self.assertEqual(srv._actual_profile_role(), "reconciler") # _effective_workspace_role is still poisoned to author (unchanged #510)... self.assertEqual(srv._effective_workspace_role(), "author") @patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE) def test_author_profile_role_is_author(self, _profile): srv._preflight_resolved_role = "author" self.assertEqual(srv._actual_profile_role(), "author") @patch("gitea_mcp_server.get_profile", return_value=REVIEWER_PROFILE) def test_reviewer_profile_role_is_reviewer(self, _profile): srv._preflight_resolved_role = "author" self.assertEqual(srv._actual_profile_role(), "reviewer") class TestBranchesOnlyExemptionRealRole(unittest.TestCase): """#274 branches-only exemption keys off the actual profile role (#540).""" def tearDown(self): srv._preflight_resolved_role = None @patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE) def test_reconciler_exempt_despite_author_task_stamp(self, _profile): srv._preflight_resolved_role = "author" # poison # Must return without raising and without resolving an author worktree. with patch("gitea_mcp_server._resolve_namespace_mutation_context") as ctx: srv._enforce_branches_only_author_mutation() ctx.assert_not_called() @patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE) def test_author_not_exempt(self, _profile): srv._preflight_resolved_role = "author" sentinel = RuntimeError("author-mutation-guard-reached") def _blow_up(*_a, **_k): raise sentinel # Author is not exempt: the guard proceeds to resolve/assess the # workspace (proven by reaching the patched context resolver). with patch( "gitea_mcp_server._resolve_namespace_mutation_context", side_effect=_blow_up, ): with self.assertRaises(RuntimeError) as raised: srv._enforce_branches_only_author_mutation() self.assertIs(raised.exception, sentinel) class TestRootCheckoutGuardRealRole(unittest.TestCase): """#475 root guard honours the actual profile role too (#540).""" def _assess(self, **kwargs): defaults = { "workspace_path": CONTROL_CHECKOUT_ROOT, "canonical_repo_root": CONTROL_CHECKOUT_ROOT, "current_branch": "feat/some-branch", "head_sha": OTHER_SHA, "porcelain_status": " M gitea_mcp_server.py\n", "remote_master_sha": MASTER_SHA, "resolved_role": "author", # poisoned task role } defaults.update(kwargs) return rcg.assess_root_checkout_guard(**defaults) def test_actual_reconciler_exempt_despite_poisoned_resolved_author(self): result = self._assess(actual_role="reconciler") self.assertTrue(result["proven"]) self.assertFalse(result["block"]) def test_actual_author_still_blocked_on_contaminated_root(self): result = self._assess(actual_role="author") self.assertTrue(result["block"]) def test_merger_strictness_stays_keyed_on_resolved_task_role(self): # When the merge task resolves the merger role, the branches/ auto # exemption is denied and a clean control checkout is required. blocked = self._assess( workspace_path=f"{CONTROL_CHECKOUT_ROOT}/branches/review-pr-1", current_branch="review-pr-1", porcelain_status="", head_sha=OTHER_SHA, resolved_role="merger", ) self.assertTrue(blocked["block"]) def test_actual_merger_does_not_over_tighten_non_merge_task(self): # A merger profile whose current task did NOT resolve to merger keeps # the branches/ workspace exemption (regression guard for #540): the # actual_role signal must not force merger strictness here. result = self._assess( workspace_path=f"{CONTROL_CHECKOUT_ROOT}/branches/review-pr-1", current_branch="review-pr-1", porcelain_status="", head_sha=OTHER_SHA, resolved_role="reviewer", actual_role="merger", ) self.assertTrue(result["proven"]) self.assertFalse(result["block"]) def test_backward_compatible_without_actual_role(self): # No actual_role supplied: behaviour falls back to resolved_role. result = self._assess(resolved_role="reconciler") self.assertTrue(result["proven"]) class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase): """Integration: reconciler comment survives the poisoned author task role.""" def setUp(self): srv._preflight_whoami_called = True srv._preflight_capability_called = True srv._preflight_whoami_violation = False srv._preflight_capability_violation = False srv._preflight_capability_baseline_porcelain = "" self._orig_in_test = srv._preflight_in_test_mode srv._preflight_in_test_mode = lambda: False self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False) self._env_patch.start() def tearDown(self): srv._preflight_in_test_mode = self._orig_in_test srv._preflight_resolved_role = None self._env_patch.stop() @patch("gitea_mcp_server._get_workspace_porcelain", return_value="") @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @patch("gitea_mcp_server.api_request") @patch( "gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA, ) @patch( "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={ "current_branch": "master", "head_sha": MASTER_SHA, "porcelain_status": "", }, ) @patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE) def test_reconciler_comment_from_control_checkout_succeeds( self, _profile, _git, _remote_sha, mock_api, _auth, _porcelain ): srv._preflight_resolved_role = "author" # comment_issue poison mock_api.return_value = {"id": 9001, "html_url": "https://x/y"} with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): with patch.dict(os.environ, {}, clear=False): os.environ.pop("GITEA_AUTHOR_WORKTREE", None) os.environ.pop("GITEA_ACTIVE_WORKTREE", None) os.environ.pop("GITEA_RECONCILER_WORKTREE", None) result = srv.gitea_create_issue_comment( 515, "reconciler audit comment", remote="prgs" ) self.assertTrue(result["success"]) self.assertEqual(result["comment_id"], 9001) mock_api.assert_called_once() @patch("gitea_mcp_server._get_workspace_porcelain", return_value="") @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @patch("gitea_mcp_server.api_request") @patch( "gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA, ) @patch( "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={ "current_branch": "master", "head_sha": MASTER_SHA, "porcelain_status": "", }, ) @patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE) def test_author_comment_from_control_checkout_blocked( self, _profile, _git, _remote_sha, mock_api, _auth, _porcelain ): srv._preflight_resolved_role = "author" with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): with patch.dict(os.environ, {}, clear=False): os.environ.pop("GITEA_AUTHOR_WORKTREE", None) os.environ.pop("GITEA_ACTIVE_WORKTREE", None) try: res = srv.gitea_create_issue_comment( 515, "author note", remote="prgs" ) except RuntimeError: pass # legacy raise path else: # #683: typed blocker at mutation entrypoint self.assertFalse(res.get("success")) self.assertFalse(res.get("performed")) self.assertTrue( res.get("blocker_kind") or res.get("reasons") ) mock_api.assert_not_called() if __name__ == "__main__": unittest.main()