PR closure had no first-class capability: agents could close PRs through gitea_edit_pr(state=closed) with no close-specific capability proof, and gitea_resolve_task_capability(close_pr) failed as unknown, leaving the broad edit path as an untracked close fallback. Add close_pr to the resolver TASK_MAP (gitea.pr.close, author-side) and gate gitea_edit_pr(state=closed) on the same operation: without it the close fails closed before any auth or API call, with reasons and a structured permission_report; with it the close proceeds and is audited as a distinct close_pr action carrying the required capability. Reject invalid state values outright so case variants cannot bypass the gate. Generalize the profile gate helper (_profile_operation_gate) and document the PR comment / PR edit / PR close capability split. Co-Authored-By: Claude Fable 5 <[email protected]>
155 lines
6.5 KiB
Python
155 lines
6.5 KiB
Python
"""Tests for the gated PR close path (Issue #216).
|
|
|
|
``gitea_edit_pr(state='closed')`` requires the distinct ``gitea.pr.close``
|
|
capability: without it the close fails closed (no auth lookup, no API call,
|
|
structured permission report). With it — the explicit operator-directed
|
|
contaminated-PR closure path — the close proceeds and is audited as a
|
|
distinct ``close_pr`` action carrying the required capability, so final
|
|
reports can prove exactly which mutation capability was exercised.
|
|
|
|
Ordinary edits (title/body/base) and reopening never require the close
|
|
capability: PR comment, PR edit, and PR close remain distinct capabilities.
|
|
"""
|
|
import sys
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
|
|
import mcp_server
|
|
from mcp_server import gitea_edit_pr
|
|
|
|
FAKE_AUTH = "token fake"
|
|
|
|
AUTHOR_NO_CLOSE = {
|
|
"profile_name": "prgs-author",
|
|
"allowed_operations": [
|
|
"gitea.read", "gitea.pr.create", "gitea.pr.comment",
|
|
"gitea.branch.push", "gitea.issue.comment",
|
|
],
|
|
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
|
"audit_label": "prgs-author",
|
|
}
|
|
|
|
AUTHOR_WITH_CLOSE = {
|
|
"profile_name": "prgs-author-closer",
|
|
"allowed_operations": AUTHOR_NO_CLOSE["allowed_operations"] + ["gitea.pr.close"],
|
|
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
|
"audit_label": "prgs-author-closer",
|
|
}
|
|
|
|
|
|
class TestEditPrCloseGate(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.mock_api = patch("mcp_server.api_request").start()
|
|
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
|
# Deterministic permission reports: no real operator config, no switching.
|
|
patch("gitea_config.load_config", return_value={}).start()
|
|
patch("gitea_config.is_runtime_switching_enabled", return_value=False).start()
|
|
patch("gitea_audit.audit_enabled", return_value=False).start()
|
|
mcp_server._IDENTITY_CACHE.clear()
|
|
|
|
def tearDown(self):
|
|
patch.stopall()
|
|
mcp_server._IDENTITY_CACHE.clear()
|
|
|
|
def _set_profile(self, profile):
|
|
patch("mcp_server.get_profile", return_value=profile).start()
|
|
|
|
def test_close_blocked_without_close_capability(self):
|
|
# Author profile without gitea.pr.close: the broad edit path must not
|
|
# be usable as an untracked close fallback.
|
|
self._set_profile(AUTHOR_NO_CLOSE)
|
|
res = gitea_edit_pr(pr_number=205, state="closed", remote="prgs")
|
|
self.assertFalse(res["success"])
|
|
self.assertFalse(res["performed"])
|
|
self.assertEqual(res["requested_state"], "closed")
|
|
self.assertEqual(res["required_permission"], "gitea.pr.close")
|
|
self.assertTrue(res["reasons"])
|
|
report = res["permission_report"]
|
|
self.assertEqual(report["required_permission"], "gitea.pr.close")
|
|
self.assertEqual(report["active_profile"], "prgs-author")
|
|
self.mock_api.assert_not_called()
|
|
self.mock_auth.assert_not_called()
|
|
|
|
def test_close_blocked_when_close_forbidden(self):
|
|
profile = dict(AUTHOR_WITH_CLOSE)
|
|
profile["forbidden_operations"] = ["gitea.pr.close"]
|
|
self._set_profile(profile)
|
|
res = gitea_edit_pr(pr_number=205, state="closed", remote="prgs")
|
|
self.assertFalse(res["success"])
|
|
self.assertIn("profile forbids 'gitea.pr.close'", res["reasons"])
|
|
self.mock_api.assert_not_called()
|
|
|
|
def test_close_blocked_when_profile_unresolvable(self):
|
|
patch("mcp_server.get_profile", side_effect=RuntimeError("no profile")).start()
|
|
res = gitea_edit_pr(pr_number=205, state="closed", remote="prgs")
|
|
self.assertFalse(res["success"])
|
|
self.assertTrue(any("fail closed" in r for r in res["reasons"]))
|
|
self.mock_api.assert_not_called()
|
|
|
|
def test_operator_directed_close_allowed_and_audited(self):
|
|
# Explicit operator-directed contaminated-PR closure: the operator
|
|
# granted gitea.pr.close, so the close proceeds and the audit trail
|
|
# records a distinct close_pr action with the capability proof.
|
|
self._set_profile(AUTHOR_WITH_CLOSE)
|
|
patch("gitea_audit.audit_enabled", return_value=True).start()
|
|
mock_write = patch("gitea_audit.write_event").start()
|
|
|
|
def api_side_effect(method, url, auth, payload=None):
|
|
if method == "GET" and "/user" in url:
|
|
return {"login": "jcwalker3"}
|
|
if method == "PATCH" and "pulls/205" in url:
|
|
self.assertEqual(payload["state"], "closed")
|
|
return {
|
|
"number": 205,
|
|
"title": "Contaminated PR",
|
|
"state": "closed",
|
|
"html_url": "url",
|
|
"body": "No issue link",
|
|
"head": {"ref": "feat/invalid-provenance"},
|
|
}
|
|
return {}
|
|
self.mock_api.side_effect = api_side_effect
|
|
|
|
res = gitea_edit_pr(pr_number=205, state="closed", remote="prgs")
|
|
self.assertTrue(res["success"])
|
|
self.assertEqual(res["state"], "closed")
|
|
mock_write.assert_called()
|
|
event = mock_write.call_args[0][0]
|
|
self.assertEqual(event["action"], "close_pr")
|
|
self.assertEqual(
|
|
event["request_metadata"]["required_permission"], "gitea.pr.close")
|
|
|
|
def test_title_edit_needs_no_close_capability(self):
|
|
# PR edit and PR close are distinct capabilities: retitling stays on
|
|
# the ordinary edit path.
|
|
self._set_profile(AUTHOR_NO_CLOSE)
|
|
self.mock_api.return_value = {
|
|
"number": 7, "title": "Renamed", "state": "open",
|
|
"body": "", "html_url": "u"}
|
|
res = gitea_edit_pr(pr_number=7, title="Renamed", remote="prgs")
|
|
self.assertTrue(res["success"])
|
|
|
|
def test_reopen_needs_no_close_capability(self):
|
|
self._set_profile(AUTHOR_NO_CLOSE)
|
|
self.mock_api.return_value = {
|
|
"number": 7, "title": "T", "state": "open",
|
|
"body": "", "html_url": "u"}
|
|
res = gitea_edit_pr(pr_number=7, state="open", remote="prgs")
|
|
self.assertTrue(res["success"])
|
|
|
|
def test_invalid_state_fails_closed_before_auth(self):
|
|
# A case-variant state can neither bypass the close gate nor reach
|
|
# the API: it is rejected as pure validation, before auth.
|
|
self._set_profile(AUTHOR_WITH_CLOSE)
|
|
with self.assertRaises(ValueError):
|
|
gitea_edit_pr(pr_number=7, state="CLOSED", remote="prgs")
|
|
self.mock_api.assert_not_called()
|
|
self.mock_auth.assert_not_called()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|