_verify_role_mutation_workspace dropped org/repo, so anti-stomp resolved bare remote=prgs to Timesheet and failed closed wrong_repo on Gitea-Tools. Forward explicit org/repo for merger/reviewer lease acquire, adopt, review, merge, and lease release. Add regression for merger acquire forwarding. Fixes #718 Refs #723
467 lines
20 KiB
Python
467 lines
20 KiB
Python
"""Merger lease acquisition + capability resolution tests (#718, #723)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
|
|
import gitea_mcp_server as mcp_server
|
|
import reviewer_pr_lease as leases
|
|
import task_capability_map as tcm
|
|
|
|
|
|
HEAD = "a" * 40
|
|
LIVE_HEAD = HEAD
|
|
|
|
|
|
class TestMergerLeaseAcquireTool(unittest.TestCase):
|
|
def setUp(self):
|
|
mcp_server._preflight_whoami_called = True
|
|
mcp_server._preflight_capability_called = True
|
|
mcp_server._preflight_resolved_role = "merger"
|
|
mcp_server._preflight_resolved_task = "acquire_merger_pr_lease"
|
|
leases.clear_session_lease()
|
|
|
|
def tearDown(self):
|
|
leases.clear_session_lease()
|
|
|
|
def _merger_profile(self, **extra):
|
|
p = {
|
|
"username": "merger-user",
|
|
"profile_name": "prgs-merger",
|
|
"role": "merger",
|
|
"allowed_operations": [
|
|
"gitea.read",
|
|
"gitea.pr.comment",
|
|
"gitea.pr.merge",
|
|
],
|
|
"forbidden_operations": [
|
|
"gitea.pr.approve",
|
|
"gitea.pr.review",
|
|
"gitea.pr.request_changes",
|
|
],
|
|
}
|
|
p.update(extra)
|
|
return p
|
|
|
|
@patch("gitea_mcp_server.api_request")
|
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
|
@patch("gitea_mcp_server.get_profile")
|
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
|
def test_acquire_merger_lease_success(
|
|
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
|
):
|
|
mock_profile.return_value = self._merger_profile()
|
|
mock_api.side_effect = [
|
|
{"state": "open", "head": {"sha": LIVE_HEAD}},
|
|
{"id": 456},
|
|
]
|
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
|
pr_number=718,
|
|
worktree="branches/issue-718",
|
|
session_id="merger-session",
|
|
candidate_head=HEAD,
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
)
|
|
self.assertTrue(res["success"], res)
|
|
self.assertTrue(res["acquired"])
|
|
self.assertEqual(res["comment_id"], 456)
|
|
self.assertEqual(res["session_id"], "merger-session")
|
|
self.assertEqual(res.get("repo"), "Scaled-Tech-Consulting/Gitea-Tools")
|
|
body = mock_api.call_args_list[-1][0][3]["body"]
|
|
self.assertIn("reviewer_identity: merger-user", body)
|
|
self.assertIn("profile: prgs-merger", body)
|
|
session_lease = leases._SESSION_LEASE
|
|
self.assertIsNotNone(session_lease)
|
|
provenance = session_lease.get("lease_provenance") or {}
|
|
self.assertEqual(provenance.get("source"), "gitea_acquire_merger_pr_lease")
|
|
|
|
@patch("gitea_mcp_server.api_request")
|
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
|
@patch("gitea_mcp_server.get_profile")
|
|
@patch("gitea_mcp_server._fetch_pr_comments")
|
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
|
def test_acquire_merger_lease_fails_if_active_exists(
|
|
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
|
):
|
|
mock_profile.return_value = self._merger_profile()
|
|
active_body = leases.format_lease_body(
|
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
|
pr_number=718,
|
|
issue_number=None,
|
|
reviewer_identity="other-user",
|
|
profile="prgs-reviewer",
|
|
session_id="other-session",
|
|
worktree="branches/review-1",
|
|
phase="claimed",
|
|
candidate_head=HEAD,
|
|
target_branch="master",
|
|
target_branch_sha="b" * 40,
|
|
last_activity=datetime.now(timezone.utc),
|
|
)
|
|
_comments.return_value = [
|
|
{"id": 1, "body": active_body, "user": {"login": "other-user"}}
|
|
]
|
|
mock_api.return_value = {"state": "open", "head": {"sha": LIVE_HEAD}}
|
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
|
pr_number=718,
|
|
worktree="branches/issue-718",
|
|
session_id="merger-session",
|
|
candidate_head=HEAD,
|
|
)
|
|
self.assertFalse(res["success"])
|
|
self.assertFalse(res["acquired"])
|
|
self.assertIn("already has active reviewer lease", " ".join(res["reasons"]))
|
|
self.assertEqual(
|
|
[c for c in mock_api.call_args_list if c[0][0] == "POST"], []
|
|
)
|
|
self.assertIsNone(leases._SESSION_LEASE)
|
|
|
|
@patch("gitea_mcp_server.get_profile")
|
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
|
def test_acquire_merger_lease_fails_workspace_binding(self, _verify, mock_profile):
|
|
mock_profile.return_value = self._merger_profile()
|
|
_verify.side_effect = RuntimeError("Branches-only mutation guard")
|
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
|
pr_number=718,
|
|
worktree="branches/issue-718",
|
|
session_id="merger-session",
|
|
candidate_head=HEAD,
|
|
)
|
|
self.assertFalse(res["success"])
|
|
self.assertIn("Branches-only mutation guard", res["reasons"][0])
|
|
self.assertIsNone(leases._SESSION_LEASE)
|
|
|
|
@patch("gitea_mcp_server.api_request")
|
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
|
@patch("gitea_mcp_server.get_profile")
|
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
|
def test_acquire_merger_forwards_explicit_org_repo_to_workspace_verify(
|
|
self, mock_verify, _comments, mock_profile, _resolve, _auth, mock_api
|
|
):
|
|
"""Explicit org/repo must reach anti-stomp (prgs bare default is Timesheet)."""
|
|
mock_profile.return_value = self._merger_profile()
|
|
mock_api.side_effect = [
|
|
{"state": "open", "head": {"sha": LIVE_HEAD}},
|
|
{"id": 789},
|
|
]
|
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
|
pr_number=718,
|
|
worktree="branches/issue-718",
|
|
session_id="merger-session",
|
|
candidate_head=HEAD,
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
)
|
|
self.assertTrue(res["success"], res)
|
|
kwargs = mock_verify.call_args.kwargs
|
|
self.assertEqual(kwargs.get("org"), "Scaled-Tech-Consulting")
|
|
self.assertEqual(kwargs.get("repo"), "Gitea-Tools")
|
|
self.assertEqual(kwargs.get("task"), "acquire_merger_pr_lease")
|
|
|
|
@patch("gitea_mcp_server.get_profile")
|
|
def test_acquire_merger_requires_candidate_head(self, mock_profile):
|
|
mock_profile.return_value = self._merger_profile()
|
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
|
pr_number=718,
|
|
worktree="branches/issue-718",
|
|
candidate_head=None,
|
|
)
|
|
self.assertFalse(res["success"])
|
|
self.assertTrue(any("candidate_head is required" in r for r in res["reasons"]))
|
|
|
|
@patch("gitea_mcp_server.api_request")
|
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
|
@patch("gitea_mcp_server.get_profile")
|
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
|
def test_acquire_merger_head_mismatch(
|
|
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
|
):
|
|
mock_profile.return_value = self._merger_profile()
|
|
mock_api.return_value = {"state": "open", "head": {"sha": "b" * 40}}
|
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
|
pr_number=718,
|
|
worktree="branches/issue-718",
|
|
candidate_head=HEAD,
|
|
)
|
|
self.assertFalse(res["success"])
|
|
self.assertTrue(any("does not match live PR head" in r for r in res["reasons"]))
|
|
self.assertIsNone(leases._SESSION_LEASE)
|
|
|
|
@patch("gitea_mcp_server.api_request")
|
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
|
@patch("gitea_mcp_server.get_profile")
|
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
|
def test_acquire_merger_fails_closed_when_live_head_unresolvable(
|
|
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
|
):
|
|
"""Empty/missing live head must not silently proceed past exact-head gate."""
|
|
mock_profile.return_value = self._merger_profile()
|
|
# PR payload present but head.sha missing / empty / non-dict.
|
|
mock_api.return_value = {"state": "open", "head": {}}
|
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
|
pr_number=718,
|
|
worktree="branches/issue-718",
|
|
session_id="merger-session",
|
|
candidate_head=HEAD,
|
|
)
|
|
self.assertFalse(res["success"], res)
|
|
self.assertFalse(res.get("acquired"), res)
|
|
self.assertTrue(
|
|
any("live PR head could not be resolved" in r for r in res["reasons"]),
|
|
res,
|
|
)
|
|
self.assertIsNone(res.get("live_head"))
|
|
self.assertEqual(res.get("candidate_head"), HEAD)
|
|
self.assertEqual(
|
|
[c for c in mock_api.call_args_list if c[0][0] == "POST"], []
|
|
)
|
|
self.assertIsNone(leases._SESSION_LEASE)
|
|
|
|
# Completely empty GET /pulls body also fails closed.
|
|
mock_api.return_value = {}
|
|
res2 = mcp_server.gitea_acquire_merger_pr_lease(
|
|
pr_number=718,
|
|
worktree="branches/issue-718",
|
|
candidate_head=HEAD,
|
|
)
|
|
self.assertFalse(res2["success"], res2)
|
|
self.assertTrue(
|
|
any("live PR head could not be resolved" in r for r in res2["reasons"]),
|
|
res2,
|
|
)
|
|
self.assertIsNone(leases._SESSION_LEASE)
|
|
|
|
@patch("gitea_mcp_server._profile_operation_gate")
|
|
def test_author_denied_merge_permission(self, mock_gate):
|
|
"""Author profile lacks gitea.pr.merge → fail closed before mutation."""
|
|
def gate(op):
|
|
if op == "gitea.pr.merge":
|
|
return [f"profile lacks {op}"]
|
|
return None
|
|
mock_gate.side_effect = gate
|
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
|
pr_number=718,
|
|
worktree="branches/issue-718",
|
|
candidate_head=HEAD,
|
|
)
|
|
self.assertFalse(res["success"])
|
|
self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))
|
|
|
|
@patch("gitea_mcp_server._profile_operation_gate")
|
|
def test_reviewer_denied_merge_permission(self, mock_gate):
|
|
def gate(op):
|
|
if op == "gitea.pr.merge":
|
|
return [f"profile lacks {op}"]
|
|
return None
|
|
mock_gate.side_effect = gate
|
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
|
pr_number=718,
|
|
worktree="branches/issue-718",
|
|
candidate_head=HEAD,
|
|
)
|
|
self.assertFalse(res["success"])
|
|
self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))
|
|
|
|
|
|
class TestCapabilityMapLeaseTasks(unittest.TestCase):
|
|
def test_merger_acquire_map_entries(self):
|
|
for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
|
|
self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
|
|
self.assertEqual(tcm.required_role(task), "merger")
|
|
|
|
def test_reviewer_acquire_map_entries(self):
|
|
for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
|
|
self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
|
|
self.assertEqual(tcm.required_role(task), "reviewer")
|
|
|
|
def test_adopt_merger_aliases(self):
|
|
for task in ("adopt_merger_pr_lease", "gitea_adopt_merger_pr_lease"):
|
|
self.assertEqual(tcm.required_role(task), "merger")
|
|
|
|
|
|
class TestResolveTaskCapabilityLeaseAndUnknown(unittest.TestCase):
|
|
def setUp(self):
|
|
mcp_server._process_boot_head_sha = None
|
|
if hasattr(mcp_server, "capability_stop_terminal"):
|
|
mcp_server.capability_stop_terminal.clear()
|
|
|
|
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
|
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
|
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
|
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
|
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
|
|
@patch.object(mcp_server, "get_profile")
|
|
@patch.object(mcp_server.gitea_config, "load_config")
|
|
def test_resolve_acquire_reviewer_authorized(
|
|
self, mock_cfg, mock_profile, *_mocks
|
|
):
|
|
mock_profile.return_value = {
|
|
"profile_name": "prgs-reviewer",
|
|
"role": "reviewer",
|
|
"allowed_operations": ["gitea.pr.comment", "gitea.read", "gitea.pr.review"],
|
|
"forbidden_operations": [],
|
|
}
|
|
mock_cfg.return_value = {
|
|
"profiles": {
|
|
"prgs-reviewer": {
|
|
"role": "reviewer",
|
|
"allowed_operations": [
|
|
"gitea.pr.comment",
|
|
"gitea.read",
|
|
"gitea.pr.review",
|
|
],
|
|
"forbidden_operations": [],
|
|
}
|
|
}
|
|
}
|
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-reviewer"}, clear=False):
|
|
for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
|
|
res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
|
self.assertEqual(res["required_role_kind"], "reviewer", res)
|
|
self.assertEqual(
|
|
res["required_operation_permission"], "gitea.pr.comment", res
|
|
)
|
|
self.assertTrue(res.get("allowed_in_current_session"), res)
|
|
|
|
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
|
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
|
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
|
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
|
@patch.object(mcp_server, "_authenticated_username", return_value="jcwalker3")
|
|
@patch.object(mcp_server, "get_profile")
|
|
@patch.object(mcp_server.gitea_config, "load_config")
|
|
def test_resolve_acquire_reviewer_author_denied(
|
|
self, mock_cfg, mock_profile, *_mocks
|
|
):
|
|
mock_profile.return_value = {
|
|
"profile_name": "prgs-author",
|
|
"role": "author",
|
|
"allowed_operations": ["gitea.pr.comment", "gitea.branch.push", "gitea.read"],
|
|
"forbidden_operations": [],
|
|
}
|
|
mock_cfg.return_value = {
|
|
"profiles": {
|
|
"prgs-author": {
|
|
"role": "author",
|
|
"allowed_operations": [
|
|
"gitea.pr.comment",
|
|
"gitea.branch.push",
|
|
"gitea.read",
|
|
],
|
|
"forbidden_operations": [],
|
|
},
|
|
"prgs-reviewer": {
|
|
"role": "reviewer",
|
|
"allowed_operations": ["gitea.pr.comment", "gitea.pr.review"],
|
|
"forbidden_operations": [],
|
|
},
|
|
}
|
|
}
|
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False):
|
|
res = mcp_server.gitea_resolve_task_capability(
|
|
task="acquire_reviewer_pr_lease", remote="prgs"
|
|
)
|
|
self.assertFalse(res.get("allowed_in_current_session"), res)
|
|
self.assertEqual(res["required_role_kind"], "reviewer")
|
|
guidance = " ".join(res.get("task_role_guidance") or [])
|
|
self.assertTrue(
|
|
"cannot perform reviewer" in guidance.lower()
|
|
or "reviewer" in guidance.lower()
|
|
or res.get("stop_required"),
|
|
res,
|
|
)
|
|
|
|
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
|
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
|
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
|
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
|
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
|
|
@patch.object(mcp_server, "get_profile")
|
|
def test_resolve_unknown_task_structured_no_raise(self, mock_profile, *_mocks):
|
|
mock_profile.return_value = {
|
|
"profile_name": "prgs-reviewer",
|
|
"role": "reviewer",
|
|
"allowed_operations": ["gitea.pr.comment"],
|
|
"forbidden_operations": [],
|
|
}
|
|
# Must not raise ValueError into the tool boundary.
|
|
res = mcp_server.gitea_resolve_task_capability(
|
|
task="some_made_up_task", remote="prgs"
|
|
)
|
|
self.assertIsInstance(res, dict)
|
|
self.assertEqual(res.get("reason_code"), "unknown_task", res)
|
|
self.assertFalse(res.get("allowed_in_current_session"))
|
|
self.assertTrue(res.get("stop_required"))
|
|
self.assertIs(res.get("mutation_performed"), False)
|
|
self.assertNotIn("token", str(res).lower())
|
|
self.assertNotIn("password", str(res).lower())
|
|
guidance = " ".join(res.get("task_role_guidance") or [])
|
|
self.assertIn("Unknown task/action", guidance)
|
|
|
|
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
|
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
|
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
|
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
|
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
|
|
@patch.object(mcp_server, "get_profile")
|
|
@patch.object(mcp_server.gitea_config, "load_config")
|
|
def test_resolve_merger_acquire_aliases(
|
|
self, mock_cfg, mock_profile, *_mocks
|
|
):
|
|
mock_profile.return_value = {
|
|
"profile_name": "prgs-merger",
|
|
"role": "merger",
|
|
"allowed_operations": [
|
|
"gitea.read",
|
|
"gitea.pr.comment",
|
|
"gitea.pr.merge",
|
|
],
|
|
"forbidden_operations": [],
|
|
}
|
|
mock_cfg.return_value = {
|
|
"profiles": {
|
|
"prgs-merger": {
|
|
"role": "merger",
|
|
"allowed_operations": [
|
|
"gitea.read",
|
|
"gitea.pr.comment",
|
|
"gitea.pr.merge",
|
|
],
|
|
"forbidden_operations": [],
|
|
}
|
|
}
|
|
}
|
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-merger"}, clear=False):
|
|
for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
|
|
res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
|
self.assertEqual(res["required_role_kind"], "merger", res)
|
|
self.assertEqual(
|
|
res["required_operation_permission"], "gitea.pr.comment", res
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|