309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""Regression coverage for issue #723 role and capability invariants."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import gitea_mcp_server as mcp_server
|
|
import task_capability_map
|
|
|
|
|
|
REVIEWER_PROFILE = {
|
|
"profile_name": "prgs-reviewer",
|
|
"role": "reviewer",
|
|
"allowed_operations": [
|
|
"gitea.read",
|
|
"gitea.pr.review",
|
|
"gitea.pr.approve",
|
|
"gitea.pr.request_changes",
|
|
"gitea.pr.comment",
|
|
"gitea.issue.comment",
|
|
],
|
|
"forbidden_operations": [
|
|
"gitea.branch.create",
|
|
"gitea.branch.push",
|
|
"gitea.repo.commit",
|
|
"gitea.pr.create",
|
|
"gitea.pr.merge",
|
|
],
|
|
}
|
|
|
|
CONFIG = {
|
|
"profiles": {
|
|
"prgs-reviewer": {
|
|
"role": "reviewer",
|
|
"allowed_operations": REVIEWER_PROFILE["allowed_operations"],
|
|
"forbidden_operations": REVIEWER_PROFILE["forbidden_operations"],
|
|
},
|
|
"prgs-merger": {
|
|
"role": "merger",
|
|
"allowed_operations": [
|
|
"gitea.read",
|
|
"gitea.pr.merge",
|
|
"gitea.pr.comment",
|
|
"gitea.issue.comment",
|
|
],
|
|
"forbidden_operations": [
|
|
"gitea.pr.approve",
|
|
"gitea.pr.review",
|
|
"gitea.pr.request_changes",
|
|
],
|
|
},
|
|
}
|
|
}
|
|
|
|
|
|
def _reset_preflight() -> None:
|
|
mcp_server._clear_preflight_capability_state()
|
|
mcp_server._preflight_whoami_called = False
|
|
mcp_server._preflight_whoami_violation = False
|
|
mcp_server.capability_stop_terminal.clear()
|
|
mcp_server.role_session_router.clear_route_state()
|
|
|
|
|
|
class _ResolveHarness(unittest.TestCase):
|
|
def setUp(self):
|
|
_reset_preflight()
|
|
|
|
def tearDown(self):
|
|
_reset_preflight()
|
|
|
|
def _resolve(
|
|
self,
|
|
task,
|
|
profile=REVIEWER_PROFILE,
|
|
required_role=None,
|
|
init_side_effect=None,
|
|
):
|
|
patches = [
|
|
patch.object(mcp_server, "get_profile", return_value=profile),
|
|
patch.object(
|
|
mcp_server.gitea_config, "load_config", return_value=CONFIG
|
|
),
|
|
patch.object(
|
|
mcp_server, "_authenticated_username", return_value="tester"
|
|
),
|
|
patch.object(
|
|
mcp_server,
|
|
"init_review_decision_lock",
|
|
return_value=None,
|
|
side_effect=init_side_effect,
|
|
),
|
|
patch.object(
|
|
mcp_server, "record_mutation_authority", return_value=None
|
|
),
|
|
patch.object(
|
|
mcp_server, "_check_mcp_runtimes_diagnostics", return_value=[]
|
|
),
|
|
]
|
|
if required_role is not None:
|
|
patches.append(
|
|
patch.object(
|
|
mcp_server.task_capability_map,
|
|
"required_role",
|
|
side_effect=lambda candidate: (
|
|
required_role
|
|
if candidate == task
|
|
else task_capability_map.TASK_CAPABILITY_MAP[candidate][
|
|
"role"
|
|
]
|
|
),
|
|
)
|
|
)
|
|
for context in patches:
|
|
context.__enter__()
|
|
try:
|
|
return mcp_server.gitea_resolve_task_capability(
|
|
task=task, remote="prgs"
|
|
)
|
|
finally:
|
|
for context in reversed(patches):
|
|
context.__exit__(None, None, None)
|
|
|
|
|
|
class TestCapabilityRoleStampSafety(_ResolveHarness):
|
|
def test_allowed_resolution_records_the_correct_stamp(self):
|
|
result = self._resolve("review_pr")
|
|
self.assertTrue(result["allowed_in_current_session"], result)
|
|
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
|
self.assertEqual(mcp_server._preflight_resolved_task, "review_pr")
|
|
|
|
def test_denied_resolution_records_no_stamp(self):
|
|
with patch.object(
|
|
mcp_server,
|
|
"record_preflight_check",
|
|
wraps=mcp_server.record_preflight_check,
|
|
) as record:
|
|
result = self._resolve("review_pr", required_role="merger")
|
|
|
|
self.assertFalse(result["allowed_in_current_session"], result)
|
|
stamped_calls = [
|
|
call
|
|
for call in record.call_args_list
|
|
if len(call.args) > 1 and call.args[1] is not None
|
|
]
|
|
self.assertEqual(
|
|
stamped_calls,
|
|
[],
|
|
"a denied resolution must never transiently record a role stamp",
|
|
)
|
|
self.assertIsNone(mcp_server._preflight_resolved_role)
|
|
self.assertIsNone(mcp_server._preflight_resolved_task)
|
|
|
|
def test_denied_resolution_clears_an_existing_stamp(self):
|
|
allowed = self._resolve("review_pr")
|
|
self.assertTrue(allowed["allowed_in_current_session"], allowed)
|
|
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
|
|
|
denied = self._resolve("merge_pr")
|
|
self.assertFalse(denied["allowed_in_current_session"], denied)
|
|
self.assertIsNone(mcp_server._preflight_resolved_role)
|
|
self.assertIsNone(mcp_server._preflight_resolved_task)
|
|
|
|
def test_denial_cannot_poison_a_later_allowed_task(self):
|
|
denied = self._resolve("merge_pr")
|
|
self.assertFalse(denied["allowed_in_current_session"], denied)
|
|
|
|
allowed = self._resolve("review_pr")
|
|
self.assertTrue(allowed["allowed_in_current_session"], allowed)
|
|
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
|
self.assertEqual(mcp_server._preflight_resolved_task, "review_pr")
|
|
|
|
def test_unexpected_resolver_failure_leaves_no_stamp(self):
|
|
with self.assertRaisesRegex(RuntimeError, "malformed decision state"):
|
|
self._resolve(
|
|
"review_pr",
|
|
init_side_effect=RuntimeError("malformed decision state"),
|
|
)
|
|
self.assertIsNone(mcp_server._preflight_resolved_role)
|
|
self.assertIsNone(mcp_server._preflight_resolved_task)
|
|
|
|
|
|
class TestStructuredWorkspaceRoleFailures(unittest.TestCase):
|
|
def test_review_submission_returns_workspace_role_binding_failure(self):
|
|
error = RuntimeError(
|
|
"namespace workspace binding blocked: merger role in reviewer workspace"
|
|
)
|
|
with patch.object(
|
|
mcp_server, "_verify_role_mutation_workspace", side_effect=error
|
|
):
|
|
result = mcp_server._evaluate_pr_review_submission(
|
|
pr_number=721,
|
|
action="approve",
|
|
expected_head_sha="8" * 40,
|
|
remote="prgs",
|
|
live=True,
|
|
final_review_decision_ready=True,
|
|
)
|
|
|
|
self.assertFalse(result["performed"])
|
|
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
|
|
self.assertTrue(
|
|
any("workspace/role binding failed" in reason for reason in result["reasons"]),
|
|
result,
|
|
)
|
|
self.assertTrue(any("merger role" in reason for reason in result["reasons"]))
|
|
|
|
def test_adopt_merger_lease_returns_workspace_role_binding_failure(self):
|
|
error = RuntimeError("merger workspace binding rejected")
|
|
with patch.object(
|
|
mcp_server, "_profile_operation_gate", return_value=[]
|
|
), patch.object(
|
|
mcp_server, "_verify_role_mutation_workspace", side_effect=error
|
|
), patch.object(mcp_server, "_resolve") as resolve:
|
|
result = mcp_server.gitea_adopt_merger_pr_lease(
|
|
pr_number=718,
|
|
worktree="branches/merge-pr-718",
|
|
expected_head_sha="7" * 40,
|
|
remote="prgs",
|
|
)
|
|
|
|
self.assertFalse(result["success"])
|
|
self.assertFalse(result["adopted"])
|
|
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
|
|
self.assertEqual(result["pr_number"], 718)
|
|
self.assertEqual(result["expected_head_sha"], "7" * 40)
|
|
self.assertIsNone(result["live_head_sha"])
|
|
self.assertTrue(any("binding rejected" in reason for reason in result["reasons"]))
|
|
resolve.assert_not_called()
|
|
|
|
def test_unexpected_verifier_failure_remains_fail_closed(self):
|
|
with patch.object(
|
|
mcp_server,
|
|
"_verify_role_mutation_workspace",
|
|
side_effect=ValueError("unexpected verifier state"),
|
|
), patch.object(mcp_server, "_resolve") as resolve:
|
|
with self.assertRaisesRegex(ValueError, "unexpected verifier state"):
|
|
mcp_server._evaluate_pr_review_submission(
|
|
pr_number=721,
|
|
action="approve",
|
|
remote="prgs",
|
|
live=True,
|
|
)
|
|
resolve.assert_not_called()
|
|
|
|
|
|
class TestRuntimeCapabilityRoleFiltering(unittest.TestCase):
|
|
def test_runtime_role_filter_denies_permission_bearing_wrong_role(self):
|
|
allowed = REVIEWER_PROFILE["allowed_operations"] + ["gitea.pr.merge"]
|
|
capabilities = mcp_server._build_runtime_task_capabilities(
|
|
allowed,
|
|
[],
|
|
CONFIG,
|
|
remote="prgs",
|
|
active_role_kind="reviewer",
|
|
)
|
|
merge_entry = next(
|
|
item
|
|
for item in capabilities["task_capabilities"]
|
|
if item["task"] == "merge_pr"
|
|
)
|
|
self.assertTrue(merge_entry["role_exclusive"])
|
|
self.assertEqual(merge_entry["capability_view"], "role_filtered")
|
|
self.assertFalse(merge_entry["allowed_in_current_session"])
|
|
self.assertFalse(capabilities["can_merge_prs"])
|
|
|
|
def test_permission_only_view_is_explicit(self):
|
|
capabilities = mcp_server._build_runtime_task_capabilities(
|
|
["gitea.read", "gitea.pr.merge"],
|
|
[],
|
|
CONFIG,
|
|
active_role_kind=None,
|
|
)
|
|
merge_entry = next(
|
|
item
|
|
for item in capabilities["task_capabilities"]
|
|
if item["task"] == "merge_pr"
|
|
)
|
|
self.assertEqual(merge_entry["capability_view"], "permission_only")
|
|
self.assertTrue(merge_entry["allowed_in_current_session"])
|
|
|
|
def test_matching_profiles_honor_declared_roles(self):
|
|
capabilities = mcp_server._build_runtime_task_capabilities(
|
|
["gitea.read"],
|
|
[],
|
|
CONFIG,
|
|
active_role_kind="author",
|
|
)
|
|
review_entry = next(
|
|
item
|
|
for item in capabilities["task_capabilities"]
|
|
if item["task"] == "review_pr"
|
|
)
|
|
merge_entry = next(
|
|
item
|
|
for item in capabilities["task_capabilities"]
|
|
if item["task"] == "merge_pr"
|
|
)
|
|
self.assertEqual(
|
|
review_entry["matching_configured_profiles"], ["prgs-reviewer"]
|
|
)
|
|
self.assertEqual(
|
|
merge_entry["matching_configured_profiles"], ["prgs-merger"]
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|