import os import sys import unittest import re from unittest.mock import patch, MagicMock sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) from mcp_server import ( gitea_list_prs, gitea_view_pr, gitea_review_pr, gitea_check_pr_eligibility, ) import gitea_config FAKE_AUTH = "Basic dGVzdDp0ZXN0" def _final_page_fetch(items): return (items, { "page": 1, "per_page": 50, "returned_count": len(items), "has_more": False, "next_page": None, "is_final_page": True, }) class TestPRQueueInventory(unittest.TestCase): @patch("mcp_server.api_fetch_page") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_author_profile_can_list_open_prs(self, mock_get_profile, _auth, mock_fetch): mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read", "gitea.pr.comment"], "forbidden_operations": [], "base_url": None, } mock_fetch.return_value = _final_page_fetch([ {"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True} ]) result = gitea_list_prs(state="open", remote="prgs") self.assertEqual(len(result["prs"]), 1) self.assertEqual(result["prs"][0]["number"], 1) @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") @patch("gitea_config.is_runtime_switching_enabled", return_value=True) def test_author_profile_can_produce_pending_reviewer_queue_report(self, mock_switch, mock_get_profile, _auth, mock_api, mock_fetch): mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } mock_fetch.return_value = _final_page_fetch([ { "number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}, "labels": [{"name": "bug"}], "body": "Fixes #10", "updated_at": "2026-07-05T10:00:00Z" }, { "number": 2, "title": "PR 2", "state": "open", "head": {"ref": "branch2", "sha": "abc2"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other_user"}, "labels": [], "body": "", "updated_at": "2026-07-05T11:00:00Z" } ]) mock_api.return_value = {"login": "jcwalker3"} # whoami result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs") self.assertFalse(result["success"]) self.assertIn("=== PR Queue Inventory ===", result["message"]) self.assertIn("Repository:", result["message"]) self.assertIn("Open PRs found: 2", result["message"]) self.assertIn("Review/merge blocked (Self-author). Requires a genuine non-author reviewer", result["message"]) self.assertIn("Review/merge blocked (Current profile is Author)", result["message"]) self.assertIn("gitea_activate_profile", result["message"]) # updated_at surfaced for reconciliation (#166) self.assertIn("Updated: 2026-07-05T10:00:00Z", result["message"]) @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_author_profile_never_calls_mutation_tools_during_inventory(self, mock_get_profile, _auth, mock_api, mock_fetch): mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } mock_fetch.return_value = _final_page_fetch([]) mock_api.return_value = {"login": "jcwalker3"} gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs") for call in mock_api.call_args_list: method = call.args[0] self.assertEqual(method, "GET") @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_reviewer_profile_can_proceed_from_inventory_to_review_gates(self, mock_get_profile, _auth, mock_api, mock_fetch): mock_get_profile.return_value = { "profile_name": "gitea-reviewer", "allowed_operations": ["read", "approve", "review"], "forbidden_operations": [], "base_url": None, } mock_fetch.return_value = _final_page_fetch([ {"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other_user"}} ]) # mock_api: inventory whoami, eligibility whoami, eligibility PR, # POST review (#244: state + visible-verdict GET reviews). mock_api.side_effect = [ {"login": "reviewer1"}, {"login": "reviewer1"}, { "user": {"login": "other_user"}, "state": "open", "head": {"sha": "abc1"}, "mergeable": True, }, {"id": 100, "state": "APPROVED"}, [ { "id": 100, "user": {"login": "reviewer1"}, "state": "APPROVED", "commit_id": "abc1", "submitted_at": "2026-07-06T10:00:00Z", "dismissed": False, } ], ] from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision init_review_decision_lock("prgs", "review_pr") gitea_mark_final_review_decision(1, "approve", remote="prgs") result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs", final_review_decision_ready=True) self.assertTrue(result["success"]) self.assertIn("=== PR Queue Inventory ===", result["message"]) self.assertIn("Repository:", result["message"]) self.assertIn("Successfully submitted review", result["message"]) @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") @patch("gitea_config.is_runtime_switching_enabled", return_value=False) def test_static_runtime_rejects_or_hides_profile_activation(self, mock_switch, mock_get_profile, _auth, mock_api, mock_fetch): mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } mock_fetch.return_value = _final_page_fetch([]) mock_api.return_value = {"login": "jcwalker3"} result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs") self.assertFalse(result["success"]) self.assertIn("Repository:", result["message"]) self.assertNotIn("gitea_activate_profile", result["message"]) self.assertIn("Switch to the reviewer MCP session", result["message"]) @patch("mcp_server.get_profile") def test_stopped_before_listing_is_not_reported_as_no_prs(self, mock_get_profile): mock_get_profile.return_value = { "profile_name": "gitea-restricted", "allowed_operations": [], "forbidden_operations": [], "base_url": None, } result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs") self.assertFalse(result["success"]) self.assertIn("PR inventory not attempted", result["message"]) self.assertNotIn("Open PRs found: 0", result["message"]) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_reviewer_eligibility_check_not_run_for_author_profile(self, mock_get_profile, _auth, mock_api): mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } result = gitea_check_pr_eligibility(pr_number=1, action="approve", remote="prgs") self.assertFalse(result["eligible"]) self.assertEqual(mock_api.call_count, 0) @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_inventory_failure_output_is_redacted_no_raw_exception_leak(self, mock_get_profile, _auth, mock_api, mock_fetch): """Inventory failure path must use redaction and not leak raw exception text.""" mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } # Exception contains something that should be redacted or not leaked raw mock_fetch.side_effect = Exception("connection error token supersecrettoken123 at https://internal.example/repo") mock_api.return_value = {"login": "jcwalker3"} result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs") self.assertFalse(result["success"]) msg = result.get("message", "") self.assertIn("=== PR Queue Inventory ===", msg) self.assertIn("Repository:", msg) self.assertIn("PR inventory failed:", msg) # raw secret and potentially sensitive details must not appear self.assertNotIn("supersecrettoken123", msg) self.assertNotIn("token=supersecrettoken123", msg) # uses project's redaction pattern (token prefix -> [REDACTED]) # even if url leaks, the exception is redacted per pattern @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_inventory_failure_output_redacts_real_service_urls(self, mock_get_profile, _auth, mock_api, mock_fetch): """Failure paths must fully redact real service hostnames and URLs from inventory output.""" mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } mock_fetch.side_effect = Exception("failed to connect to https://gitea.prgs.cc/api/v1/repos/x") mock_api.return_value = {"login": "jcwalker3"} result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs") self.assertFalse(result["success"]) msg = result.get("message", "") self.assertIn("=== PR Queue Inventory ===", msg) self.assertIn("[REDACTED_URL]", msg) self.assertNotIn("gitea.prgs.cc", msg) @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_author_profiles_can_perform_read_only_pr_inventory(self, mock_get_profile, _auth, mock_api, mock_fetch): """Author profiles with read can perform read-only PR inventory (report produced, no mutations).""" mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } mock_fetch.return_value = _final_page_fetch([ {"number": 7, "title": "Some PR", "state": "open", "head": {"ref": "f", "sha": "def"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "someone"}, "labels": [], "body": ""} ]) mock_api.return_value = {"login": "jcwalker3"} result = gitea_review_pr(pr_number=7, event="APPROVE", remote="prgs") self.assertFalse(result["success"]) msg = result["message"] self.assertIn("=== PR Queue Inventory ===", msg) self.assertIn("Repository:", msg) self.assertIn("Open PRs found: 1", msg) # no mutation calls (covered by other test but reconfirm here) for call in mock_api.call_args_list: self.assertEqual(call.args[0], "GET") @patch("mcp_server._local_git_remote_url") @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_empty_inventory_runs_trust_gate_and_blocks_without_trusted_empty( self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url ): mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } mock_fetch.return_value = _final_page_fetch([]) mock_api.return_value = {"login": "jcwalker3"} mock_local_url.return_value = None result = gitea_review_pr( pr_number=1, event="APPROVE", remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", ) self.assertFalse(result["success"]) msg = result["message"] self.assertIn("pr_inventory_trust_gate.status: untrusted_empty", msg) self.assertIn("Empty-queue claim blocked", msg) self.assertNotIn("Open PRs found: 0 (trusted_empty)", msg) @patch("mcp_server._local_git_remote_url") @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_empty_inventory_trusted_empty_when_gate_passes( self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url ): mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } mock_fetch.return_value = _final_page_fetch([]) mock_api.return_value = {"login": "jcwalker3"} mock_local_url.return_value = ( "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git" ) result = gitea_review_pr( pr_number=1, event="APPROVE", remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", ) self.assertFalse(result["success"]) msg = result["message"] self.assertIn("pr_inventory_trust_gate.status: trusted_empty", msg) self.assertIn("Open PRs found: 0 (trusted_empty)", msg) @patch("mcp_server._local_git_remote_url") @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_author_profiles_cannot_approve_request_changes_merge_or_bypass_gates(self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url): """Author profiles still cannot approve, request_changes, merge, or bypass gates even with inventory.""" mock_local_url.return_value = ( "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git" ) for event in ["APPROVE", "REQUEST_CHANGES"]: mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } mock_fetch.return_value = _final_page_fetch([]) mock_api.return_value = {"login": "jcwalker3"} result = gitea_review_pr(pr_number=1, event=event, remote="prgs") self.assertFalse(result["success"]) self.assertIn("Review/Merge Blocked", result["message"]) self.assertIn("does not have review or merge permissions", result["message"]) # ensure no mutation attempted mutation_calls = [c for c in mock_api.call_args_list if c.args[0] != "GET"] self.assertEqual(len(mutation_calls), 0) # quick check merge path is still hard-gated (via eligibility in submit, but author profile blocks early) # we already block before calling submit for non-reviewer # --- Requirement-5 conflict/staleness scenario tests (Issue #166 / #170) --- # These prove coverage for the five cases; they exercise live list/view # (with staleness fields added in #166) under mocked conflicting states. # Tests use direct list/view calls + inventory path to ensure fields and # live data are available for detection without trusting prior handoffs. @patch("mcp_server.api_fetch_page") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_r5_s1_stale_prior_handoff_merged_but_live_pr_open(self, mock_get_profile, _auth, mock_fetch): """Scenario 1: stale prior handoff says merged but live PR list says open.""" mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } mock_fetch.return_value = _final_page_fetch([{ "number": 99, "title": "Stale merge claim", "state": "open", "head": {"ref": "f99", "sha": "sha99"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other"}, "labels": [], "body": "Fixes #200", "updated_at": "2026-07-05T22:00:00Z" }]) prs = gitea_list_prs(state="open", remote="prgs") self.assertEqual(len(prs["prs"]), 1) self.assertEqual(prs["prs"][0]["state"], "open") self.assertIn("updated_at", prs["prs"][0]) # enables staleness check vs prior "merged" @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_r5_s2_prior_handoff_open_but_live_pr_closed(self, mock_get_profile, _auth, mock_api): """Scenario 2: prior handoff says open but live view says closed.""" mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } mock_api.return_value = { "number": 88, "title": "Closed but prior said open", "state": "closed", "head": {"ref": "f88"}, "base": {"ref": "master"}, "mergeable": None, "body": "", "user": {"login": "u"}, "updated_at": "2026-07-05T21:00:00Z", "merged_at": "2026-07-05T21:05:00Z", "merge_commit_sha": "m123", "closed_at": "2026-07-05T21:05:00Z" } pr = gitea_view_pr(pr_number=88, remote="prgs") self.assertEqual(pr["state"], "closed") self.assertIsNotNone(pr.get("closed_at")) # live closed would contradict prior "open" handoff @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_r5_s3_list_prs_and_view_pr_disagree(self, mock_get_profile, _auth, mock_api, mock_fetch): """Scenario 3: gitea_list_prs and gitea_view_pr disagree on state/details.""" mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } # list says open mock_fetch.return_value = _final_page_fetch([{ "number": 77, "title": "Disagree", "state": "open", "head": {"ref": "f77", "sha": "s77"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "x"}, "labels": [], "body": "", "updated_at": "2026-07-05T23:00:00Z" }]) # view says closed (simulating inconsistency) mock_api.return_value = { "number": 77, "title": "Disagree", "state": "closed", "head": {"ref": "f77"}, "base": {"ref": "master"}, "mergeable": None, "body": "", "user": {"login": "x"}, "updated_at": "2026-07-05T23:01:00Z", "merged_at": None, "merge_commit_sha": None, "closed_at": None } listed = gitea_list_prs(state="open", remote="prgs") viewed = gitea_view_pr(pr_number=77, remote="prgs") self.assertEqual(listed["prs"][0]["state"], "open") self.assertEqual(viewed["state"], "closed") # caller must reconcile; mismatch is hard blocker per rules @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_r5_s4_merge_commit_missing_after_claimed_merge(self, mock_get_profile, _auth, mock_api): """Scenario 4: merge commit missing after claimed merge.""" mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } mock_api.return_value = { "number": 55, "title": "Claimed merge no commit", "state": "closed", "head": {"ref": "f55"}, "base": {"ref": "master"}, "mergeable": False, "body": "Fixes #55", "user": {"login": "u"}, "updated_at": "2026-07-05T20:10:00Z", "merged_at": "2026-07-05T20:09:00Z", "merge_commit_sha": None, # missing! "closed_at": "2026-07-05T20:09:00Z" } pr = gitea_view_pr(pr_number=55, remote="prgs") self.assertEqual(pr["state"], "closed") self.assertIsNone(pr.get("merge_commit_sha")) # post-merge view must surface missing commit for detection @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_r5_s5_linked_issue_remains_open_after_claimed_merge(self, mock_get_profile, _auth, mock_api, mock_fetch): """Scenario 5: linked issue remains open after claimed merge.""" mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } # PR claims merge, links #300 mock_fetch.return_value = _final_page_fetch([{ "number": 300, "title": "PR for #300", "state": "closed", "head": {"ref": "f300", "sha": "s3"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "u"}, "labels": [], "body": "Fixes #300", "updated_at": "2026-07-05T20:20:00Z" }]) # But linked issue (via list_issues or view) still open - here we use view_pr body to confirm link # For this test we assert the PR data shows link, caller must check issue state live mock_api.return_value = { "number": 300, "title": "PR for #300", "state": "closed", "head": {"ref": "f300"}, "base": {"ref": "master"}, "mergeable": False, "body": "Fixes #300", "user": {"login": "u"}, "updated_at": "2026-07-05T20:20:00Z", "merged_at": "2026-07-05T20:21:00Z", "merge_commit_sha": "mc300", "closed_at": "2026-07-05T20:21:00Z" } prs = gitea_list_prs(state="closed", remote="prgs") pr = gitea_view_pr(pr_number=300, remote="prgs") self.assertIn("#300", pr.get("body", "")) self.assertIsNotNone(pr.get("merge_commit_sha")) # real impl must cross-check live issue #300 state != closed or label still in-progress