import os import sys import unittest from pathlib import Path from unittest.mock import MagicMock, patch sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import gitea_mcp_server as srv FAKE_AUTH = {"Authorization": "token test-token"} current_file_path = Path(__file__).resolve() if "branches" in current_file_path.parts: CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3]) else: CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1]) class TestIssueCommentWorkspaceGuard(unittest.TestCase): AUTHOR_ENV = { "GITEA_PROFILE_NAME": "gitea-author", "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment", } def setUp(self): srv._preflight_whoami_called = True srv._preflight_capability_called = True srv._preflight_resolved_role = "author" srv._preflight_resolved_task = "comment_issue" srv._preflight_whoami_violation = False srv._preflight_capability_violation = False srv._preflight_reviewer_violation_files = [] self._orig_in_test = srv._preflight_in_test_mode srv._preflight_in_test_mode = lambda: False self.addCleanup(self._restore_preflight_mode) def _restore_preflight_mode(self): srv._preflight_in_test_mode = self._orig_in_test def _git_state(self, valid_worktree: str): def side_effect(path): real = os.path.realpath(path) if real == os.path.realpath(CONTROL_CHECKOUT_ROOT): return { "current_branch": "master", "head_sha": "a" * 40, "porcelain_status": "", } if real == os.path.realpath(valid_worktree): return { "current_branch": "fix/issue-560-issue-comment-worktree-path", "head_sha": "b" * 40, "porcelain_status": "", } return { "current_branch": "other", "head_sha": "c" * 40, "porcelain_status": "", } return side_effect def _subprocess(self, valid_worktree: str, outside_worktree: str | None = None): def side_effect(cmd, *args, **kwargs): result = MagicMock(returncode=0, stdout="") cwd = "" if isinstance(cmd, list) and "-C" in cmd: cwd = os.path.realpath(cmd[cmd.index("-C") + 1]) if isinstance(cmd, list) and "--show-toplevel" in cmd: if cwd == os.path.realpath(valid_worktree): result.stdout = f"{valid_worktree}\n" elif outside_worktree and cwd == os.path.realpath(outside_worktree): result.stdout = f"{outside_worktree}\n" else: result.stdout = f"{CONTROL_CHECKOUT_ROOT}\n" return result if isinstance(cmd, list) and "--git-common-dir" in cmd: if cwd == os.path.realpath(valid_worktree): result.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n" elif outside_worktree and cwd == os.path.realpath(outside_worktree): result.stdout = "/tmp/other-repo/.git\n" else: result.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n" return result return result return side_effect @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @patch("gitea_mcp_server.api_request") @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) def test_comment_rejects_control_checkout_without_worktree_path( self, _remote_sha, mock_api, _auth ): valid_worktree = os.path.join( CONTROL_CHECKOUT_ROOT, "branches", "issue-560-issue-comment-worktree-path", ) mock_api.return_value = {"id": 42} with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): with patch( "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", side_effect=self._git_state(valid_worktree), ): with self.assertRaises(RuntimeError) as ctx: srv.gitea_create_issue_comment( issue_number=557, body="evidence comment", remote="prgs", ) self.assertIn("stable control checkout", str(ctx.exception)) mock_api.assert_not_called() @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @patch("gitea_mcp_server.api_request") @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) @patch("os.path.isdir", return_value=True) @patch("os.path.exists", return_value=True) def test_comment_accepts_valid_branches_worktree_path_with_explicit_repo( self, _exists, _isdir, _remote_sha, mock_api, _auth ): valid_worktree = os.path.join( CONTROL_CHECKOUT_ROOT, "branches", "issue-560-issue-comment-worktree-path", ) mock_api.return_value = {"id": 43} with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""): with patch( "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", side_effect=self._git_state(valid_worktree), ): with patch( "gitea_mcp_server.subprocess.run", side_effect=self._subprocess(valid_worktree), ): with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): result = srv.gitea_create_issue_comment( issue_number=557, body="evidence comment", remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", worktree_path=valid_worktree, ) self.assertTrue(result["success"]) self.assertTrue(result["performed"]) self.assertEqual(result["comment_id"], 43) method, url, _auth_arg, payload = mock_api.call_args[0] self.assertEqual(method, "POST") self.assertIn("/repos/Scaled-Tech-Consulting/Gitea-Tools/issues/557/comments", url) self.assertEqual(payload, {"body": "evidence comment"}) @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @patch("gitea_mcp_server.api_request") @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) @patch("os.path.isdir", return_value=True) @patch("os.path.exists", return_value=True) def test_comment_rejects_outside_repo_worktree_path( self, _exists, _isdir, _remote_sha, mock_api, _auth ): valid_worktree = os.path.join( CONTROL_CHECKOUT_ROOT, "branches", "issue-560-issue-comment-worktree-path", ) outside_worktree = "/tmp/not-gitea-tools-worktree" with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""): with patch( "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", side_effect=self._git_state(valid_worktree), ): with patch( "gitea_mcp_server.subprocess.run", side_effect=self._subprocess(valid_worktree, outside_worktree), ): with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): with self.assertRaises(RuntimeError) as ctx: srv.gitea_create_issue_comment( issue_number=557, body="evidence comment", remote="prgs", worktree_path=outside_worktree, ) self.assertIn("does not belong to the target repository", str(ctx.exception)) mock_api.assert_not_called() @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @patch("gitea_mcp_server.api_request") def test_comment_still_requires_capability_preflight(self, mock_api, _auth): srv._preflight_capability_called = False with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): with self.assertRaises(RuntimeError) as ctx: srv.gitea_create_issue_comment( issue_number=557, body="evidence comment", remote="prgs", worktree_path=os.path.join( CONTROL_CHECKOUT_ROOT, "branches", "issue-560-issue-comment-worktree-path", ), ) self.assertIn("Task capability", str(ctx.exception)) mock_api.assert_not_called() @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @patch("gitea_mcp_server.api_request") @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) @patch("os.path.isdir", return_value=True) @patch("os.path.exists", return_value=True) def test_comment_still_requires_issue_comment_permission( self, _exists, _isdir, _remote_sha, mock_api, _auth ): valid_worktree = os.path.join( CONTROL_CHECKOUT_ROOT, "branches", "issue-560-issue-comment-worktree-path", ) denied_env = { "GITEA_PROFILE_NAME": "gitea-author", "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment", } with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""): with patch( "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", side_effect=self._git_state(valid_worktree), ): with patch( "gitea_mcp_server.subprocess.run", side_effect=self._subprocess(valid_worktree), ): with patch.dict(os.environ, denied_env, clear=True): result = srv.gitea_create_issue_comment( issue_number=557, body="evidence comment", remote="prgs", worktree_path=valid_worktree, ) self.assertFalse(result["success"]) self.assertFalse(result["performed"]) self.assertIn("permission_report", result) self.assertEqual( result["permission_report"]["missing_permission"], "gitea.issue.comment", ) mock_api.assert_not_called() if __name__ == "__main__": unittest.main()