Bare remote=prgs resolved to the hardcoded REMOTES default Scaled-Tech-Consulting/Timesheet instead of the Gitea-Tools repository the local git remote points at, causing false 404s and risking mutation of a different repository. Add remote_repo_guard.assess_remote_repo_match (pure) + formatter, wired into gitea_mcp_server._resolve so every read/lookup/mutation tool fails closed when the MCP-resolved org/repo disagrees with the local git remote URL and the caller did not pass explicit org/repo. The guard is best-effort: it skips when both org and repo are explicit, when the local remote URL is unavailable, and is bypassed under pytest unless GITEA_FORCE_REMOTE_REPO_CHECK is set. Add tests/test_remote_repo_guard.py (pure-function cases + server wiring proving a lookup tool cannot silently query a different repository), and update author/reviewer/merger handoff templates to require explicit remote/org/repo. Closes #530 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
179 lines
7.0 KiB
Python
179 lines
7.0 KiB
Python
"""Regression coverage for the remote/repo mismatch guard (#530).
|
|
|
|
Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet``
|
|
(the hardcoded ``REMOTES`` default) instead of ``Scaled-Tech-Consulting/Gitea-Tools``,
|
|
which is what the local git remote actually points at. That silent mismatch caused
|
|
false 404s and risked mutating the wrong repository. The guard fails closed when the
|
|
MCP-resolved org/repo disagrees with the local git remote URL and the caller did not
|
|
pass explicit ``org``/``repo``.
|
|
"""
|
|
|
|
import os
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
import remote_repo_guard
|
|
|
|
|
|
LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
|
|
|
|
|
class TestAssessRemoteRepoMatch(unittest.TestCase):
|
|
def test_explicit_org_and_repo_skips_guard(self):
|
|
# Caller supplied both org and repo explicitly: never block, even on mismatch.
|
|
assessment = remote_repo_guard.assess_remote_repo_match(
|
|
remote="prgs",
|
|
resolved_org="Scaled-Tech-Consulting",
|
|
resolved_repo="Timesheet",
|
|
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
|
org_explicit=True,
|
|
repo_explicit=True,
|
|
)
|
|
self.assertTrue(assessment["proven"])
|
|
self.assertFalse(assessment["block"])
|
|
self.assertEqual(assessment["reasons"], [])
|
|
|
|
def test_missing_local_remote_url_skips_guard(self):
|
|
# Best-effort lookup: no local remote URL (non-checkout usage) => do not block.
|
|
assessment = remote_repo_guard.assess_remote_repo_match(
|
|
remote="prgs",
|
|
resolved_org="Scaled-Tech-Consulting",
|
|
resolved_repo="Timesheet",
|
|
local_remote_url=None,
|
|
org_explicit=False,
|
|
repo_explicit=False,
|
|
)
|
|
self.assertTrue(assessment["proven"])
|
|
self.assertFalse(assessment["block"])
|
|
|
|
def test_matching_repo_is_proven(self):
|
|
assessment = remote_repo_guard.assess_remote_repo_match(
|
|
remote="prgs",
|
|
resolved_org="Scaled-Tech-Consulting",
|
|
resolved_repo="Gitea-Tools",
|
|
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
|
org_explicit=False,
|
|
repo_explicit=False,
|
|
)
|
|
self.assertTrue(assessment["proven"])
|
|
self.assertFalse(assessment["block"])
|
|
|
|
def test_regression_prgs_default_timesheet_vs_local_gitea_tools(self):
|
|
# The exact #530 scenario: default repo Timesheet, local remote Gitea-Tools.
|
|
assessment = remote_repo_guard.assess_remote_repo_match(
|
|
remote="prgs",
|
|
resolved_org="Scaled-Tech-Consulting",
|
|
resolved_repo="Timesheet",
|
|
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
|
org_explicit=False,
|
|
repo_explicit=False,
|
|
)
|
|
self.assertFalse(assessment["proven"])
|
|
self.assertTrue(assessment["block"])
|
|
self.assertTrue(assessment["reasons"])
|
|
self.assertEqual(assessment["resolved_repo"], "Timesheet")
|
|
self.assertEqual(assessment["resolved_org"], "Scaled-Tech-Consulting")
|
|
|
|
def test_only_org_explicit_still_checks_repo(self):
|
|
# Repo left as default => still guarded even if org was explicit.
|
|
assessment = remote_repo_guard.assess_remote_repo_match(
|
|
remote="prgs",
|
|
resolved_org="Scaled-Tech-Consulting",
|
|
resolved_repo="Timesheet",
|
|
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
|
org_explicit=True,
|
|
repo_explicit=False,
|
|
)
|
|
self.assertTrue(assessment["block"])
|
|
|
|
def test_case_insensitive_match(self):
|
|
assessment = remote_repo_guard.assess_remote_repo_match(
|
|
remote="prgs",
|
|
resolved_org="scaled-tech-consulting",
|
|
resolved_repo="gitea-tools",
|
|
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
|
org_explicit=False,
|
|
repo_explicit=False,
|
|
)
|
|
self.assertTrue(assessment["proven"])
|
|
|
|
def test_format_error_mentions_resolved_and_local(self):
|
|
assessment = remote_repo_guard.assess_remote_repo_match(
|
|
remote="prgs",
|
|
resolved_org="Scaled-Tech-Consulting",
|
|
resolved_repo="Timesheet",
|
|
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
|
org_explicit=False,
|
|
repo_explicit=False,
|
|
)
|
|
message = remote_repo_guard.format_remote_repo_guard_error(assessment)
|
|
self.assertIn("Timesheet", message)
|
|
self.assertIn("Gitea-Tools", message)
|
|
self.assertIn("org=", message)
|
|
self.assertIn("repo=", message)
|
|
|
|
|
|
class TestResolveServerWiring(unittest.TestCase):
|
|
"""The guard is wired into gitea_mcp_server._resolve, so every read/lookup/
|
|
mutation tool that resolves a target fails closed on a repo mismatch.
|
|
|
|
Under pytest the guard is bypassed unless GITEA_FORCE_REMOTE_REPO_CHECK is set,
|
|
so these tests set the force flag and patch the local remote lookup + REMOTES.
|
|
"""
|
|
|
|
def setUp(self):
|
|
import gitea_mcp_server
|
|
|
|
self.server = gitea_mcp_server
|
|
|
|
force = mock.patch.dict(
|
|
os.environ, {"GITEA_FORCE_REMOTE_REPO_CHECK": "1"}, clear=False
|
|
)
|
|
force.start()
|
|
self.addCleanup(force.stop)
|
|
|
|
url_patch = mock.patch.object(
|
|
gitea_mcp_server,
|
|
"_local_git_remote_url",
|
|
return_value=LOCAL_GITEA_TOOLS_URL,
|
|
)
|
|
url_patch.start()
|
|
self.addCleanup(url_patch.stop)
|
|
|
|
def _set_prgs_default_repo(self, repo):
|
|
original = dict(self.server.REMOTES["prgs"])
|
|
self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original)
|
|
self.server.REMOTES["prgs"] = {**original, "repo": repo}
|
|
|
|
def test_resolve_blocks_on_default_repo_mismatch(self):
|
|
self._set_prgs_default_repo("Timesheet")
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
self.server._resolve("prgs", None, None, None)
|
|
self.assertIn("Timesheet", str(ctx.exception))
|
|
self.assertIn("Gitea-Tools", str(ctx.exception))
|
|
|
|
def test_resolve_allows_explicit_org_and_repo(self):
|
|
self._set_prgs_default_repo("Timesheet")
|
|
# Explicit org/repo is authoritative even if it does not match local remote.
|
|
host, org, repo = self.server._resolve(
|
|
"prgs", None, "Scaled-Tech-Consulting", "Timesheet"
|
|
)
|
|
self.assertEqual((org, repo), ("Scaled-Tech-Consulting", "Timesheet"))
|
|
|
|
def test_resolve_allows_matching_default(self):
|
|
self._set_prgs_default_repo("Gitea-Tools")
|
|
host, org, repo = self.server._resolve("prgs", None, None, None)
|
|
self.assertEqual((org, repo), ("Scaled-Tech-Consulting", "Gitea-Tools"))
|
|
|
|
def test_lookup_tool_cannot_silently_query_different_repo(self):
|
|
# gitea_view_issue resolves the target via _resolve first, so a bare
|
|
# remote=prgs pointing at the wrong default repo fails closed before any
|
|
# API call is made.
|
|
self._set_prgs_default_repo("Timesheet")
|
|
with self.assertRaises(RuntimeError):
|
|
self.server.gitea_view_issue(issue_number=1, remote="prgs")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|