import unittest from pathlib import Path import sys from unittest.mock import AsyncMock, MagicMock, patch import os import httpx REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT / "opax-mcp")) from gitea_handler import handle_list_repo_files class TestGiteaHandler(unittest.TestCase): def setUp(self): self.original_gitea_url = os.environ.get("GITEA_URL") self.original_gitea_token = os.environ.get("GITEA_TOKEN") os.environ["GITEA_URL"] = "https://gitea.example.com" os.environ["GITEA_TOKEN"] = "test-token" def tearDown(self): if self.original_gitea_url is not None: os.environ["GITEA_URL"] = self.original_gitea_url else: del os.environ["GITEA_URL"] if self.original_gitea_token is not None: os.environ["GITEA_TOKEN"] = self.original_gitea_token else: del os.environ["GITEA_TOKEN"] def _prepare_mock_client(self, payload, is_file=False): response = MagicMock() response.raise_for_status = MagicMock() if is_file: response.json.return_value = {"name": "file.txt", "path": "file.txt", "type": "file"} else: response.json.return_value = payload client = MagicMock() client.get = AsyncMock(return_value=response) async_client = MagicMock() async_client.__aenter__ = AsyncMock(return_value=client) async_client.__aexit__ = AsyncMock(return_value=None) return async_client, client @patch("httpx.AsyncClient") def test_list_repo_files_root(self, mock_async_client_constructor): payload = [ {"name": "README.md", "path": "README.md", "type": "file", "sha": "sha123", "size": 1024}, {"name": "docs", "path": "docs", "type": "dir"} ] mock_async_client, _ = self._prepare_mock_client(payload) mock_async_client_constructor.return_value = mock_async_client async def run_test(): result = await handle_list_repo_files({"path": None}, "test/repo") self.assertEqual(result["path"], "") self.assertEqual(len(result["files"]), 2) self.assertEqual(result["files"][0]["name"], "README.md") self.assertIn("sha", result["files"][0]) self.assertIn("size", result["files"][0]) self.assertEqual(result["files"][1]["name"], "docs") self.assertNotIn("sha", result["files"][1]) self.assertNotIn("size", result["files"][1]) import asyncio asyncio.run(run_test()) @patch("httpx.AsyncClient") def test_list_repo_files_empty_path(self, mock_async_client_constructor): payload = [] mock_async_client, _ = self._prepare_mock_client(payload) mock_async_client_constructor.return_value = mock_async_client async def run_test(): result = await handle_list_repo_files({"path": ""}, "test/repo") self.assertEqual(result["path"], "") import asyncio asyncio.run(run_test()) @patch("httpx.AsyncClient") def test_nested_path(self, mock_async_client_constructor): payload = [] mock_async_client, mock_client = self._prepare_mock_client(payload) mock_async_client_constructor.return_value = mock_async_client async def run_test(): await handle_list_repo_files({"path": "docs/security"}, "test/repo") mock_client.get.assert_called_once() called_url = mock_client.get.call_args[0][0] self.assertIn("docs/security", called_url) import asyncio asyncio.run(run_test()) @patch("httpx.AsyncClient") def test_path_is_file(self, mock_async_client_constructor): mock_async_client, _ = self._prepare_mock_client(None, is_file=True) mock_async_client_constructor.return_value = mock_async_client async def run_test(): with self.assertRaisesRegex(ValueError, "Path is a file, not a directory. Use get_file_content instead."): await handle_list_repo_files({"path": "file.txt"}, "test/repo") import asyncio asyncio.run(run_test()) @patch("httpx.AsyncClient") def test_hardened_root_none(self, mock_async_client_constructor): """Verify path=None retains root-listing behavior and params.""" mock_async_client, mock_client = self._prepare_mock_client([]) mock_async_client_constructor.return_value = mock_async_client async def run_test(): await handle_list_repo_files({"path": None}, "test/repo") mock_client.get.assert_called_once() called_url = mock_client.get.call_args[0][0] self.assertTrue(called_url.endswith("/contents/")) self.assertEqual(mock_client.get.call_args[1].get("params"), {"ref": "main"}) import asyncio asyncio.run(run_test()) @patch("httpx.AsyncClient") def test_hardened_root_empty(self, mock_async_client_constructor): """Verify path="" retains root-listing behavior and params.""" mock_async_client, mock_client = self._prepare_mock_client([]) mock_async_client_constructor.return_value = mock_async_client async def run_test(): await handle_list_repo_files({"path": ""}, "test/repo") mock_client.get.assert_called_once() called_url = mock_client.get.call_args[0][0] self.assertTrue(called_url.endswith("/contents/")) self.assertEqual(mock_client.get.call_args[1].get("params"), {"ref": "main"}) import asyncio asyncio.run(run_test()) @patch("httpx.AsyncClient") def test_hardened_dot_path_denied(self, mock_async_client_constructor): """Verify path='.' is denied before making an HTTP request.""" mock_async_client, mock_client = self._prepare_mock_client([]) mock_async_client_constructor.return_value = mock_async_client async def run_test(): with self.assertRaises(ValueError) as cm: await handle_list_repo_files({"path": "."}, "test/repo") self.assertEqual(str(cm.exception), "PATH_NOT_ALLOWED") mock_client.get.assert_not_called() import asyncio asyncio.run(run_test()) @patch("httpx.AsyncClient") def test_hardened_safe_nested_path(self, mock_async_client_constructor): """Verify a safe nested path reaches the client and preserves params.""" mock_async_client, mock_client = self._prepare_mock_client([]) mock_async_client_constructor.return_value = mock_async_client async def run_test(): await handle_list_repo_files({"path": "docs/sub"}, "test/repo") mock_client.get.assert_called_once() called_url = mock_client.get.call_args[0][0] self.assertTrue(called_url.endswith("/contents/docs/sub")) self.assertEqual(mock_client.get.call_args[1].get("params"), {"ref": "main"}) import asyncio asyncio.run(run_test()) @patch("httpx.AsyncClient") def test_hardened_traversal_path_denied(self, mock_async_client_constructor): """Verify a traversal path is denied before any HTTP request.""" mock_async_client, mock_client = self._prepare_mock_client([]) mock_async_client_constructor.return_value = mock_async_client async def run_test(): with self.assertRaises(ValueError) as cm: await handle_list_repo_files({"path": "a/../b"}, "test/repo") self.assertEqual(str(cm.exception), "PATH_NOT_ALLOWED") mock_client.get.assert_not_called() import asyncio asyncio.run(run_test()) @patch("httpx.AsyncClient") def test_hardened_secret_request_path_denied(self, mock_async_client_constructor): """Verify a secret requested path is denied before any HTTP request.""" mock_async_client, mock_client = self._prepare_mock_client([]) mock_async_client_constructor.return_value = mock_async_client async def run_test(): with self.assertRaises(ValueError) as cm: await handle_list_repo_files({"path": ".env"}, "test/repo") self.assertEqual(str(cm.exception), "SECRET_PATH_DENIED") mock_client.get.assert_not_called() import asyncio asyncio.run(run_test()) @patch("httpx.AsyncClient") def test_hardened_output_filtering(self, mock_async_client_constructor): """Verify secret paths are omitted from the returned list.""" gitea_response_payload = [ {"name": "README.md", "path": "README.md", "type": "file", "sha": "safe_sha", "size": 100}, {"name": ".env", "path": ".env", "type": "file"}, {"name": "config", "path": ".git/config", "type": "file"}, {"name": "app.txt", "path": "secrets/app.txt", "type": "file"}, {"name": "secret.pem", "path": "keys/secret.pem", "type": "file"}, ] mock_async_client, _ = self._prepare_mock_client(gitea_response_payload) mock_async_client_constructor.return_value = mock_async_client async def run_test(): result = await handle_list_repo_files({"path": ""}, "test/repo") self.assertEqual(result["files"], [{"name": "README.md", "path": "README.md", "type": "file", "sha": "safe_sha", "size": 100}]) returned_paths = {f["path"] for f in result["files"]} self.assertNotIn(".env", returned_paths) self.assertNotIn(".git/config", returned_paths) self.assertNotIn("secrets/app.txt", returned_paths) self.assertNotIn("keys/secret.pem", returned_paths) import asyncio asyncio.run(run_test()) def test_hardened_unexpected_error_reraised(self): """Verify other ValueErrors are not suppressed during filtering.""" import gitea_handler real_validator = gitea_handler._validate_safe_path def validator_side_effect(path, *args, **kwargs): if path == "trigger.txt": raise ValueError("UNEXPECTED_ERROR") return real_validator(path, *args, **kwargs) async def run_test(): with patch("gitea_handler._validate_safe_path") as mock_validator: mock_validator.side_effect = validator_side_effect with patch("httpx.AsyncClient") as mock_async_client_constructor: mock_async_client, mock_client = self._prepare_mock_client( [ { "name": "safe.txt", "path": "safe.txt", "type": "file", "sha": "safe_sha", "size": 1, }, { "name": "trigger.txt", "path": "trigger.txt", "type": "file", "sha": "trigger_sha", "size": 1, }, ] ) mock_async_client_constructor.return_value = mock_async_client with self.assertRaises(ValueError) as cm: await handle_list_repo_files({"path": ""}, "test/repo") self.assertEqual(str(cm.exception), "UNEXPECTED_ERROR") mock_client.get.assert_called_once() import asyncio asyncio.run(run_test()) def test_invalid_paths(self): invalid_paths = [ "../secrets", "docs/../secrets", "docs\\secrets", "/etc/passwd", "https://example.com", "docs//security", "docs/./security", ] async def run_test(): for path in invalid_paths: with self.subTest(path=path): # Use patch here to ensure no real network call is made for paths # that might otherwise be valid URLs. with patch("httpx.AsyncClient") as mock_client: mock_async_client, mocked_http_client = self._prepare_mock_client([]) mock_client.return_value = mock_async_client with self.assertRaises(ValueError) as cm: await handle_list_repo_files({"path": path}, "test/repo") self.assertEqual(str(cm.exception), "PATH_NOT_ALLOWED") # Ensure no network call was attempted for invalid paths mocked_http_client.get.assert_not_called() import asyncio asyncio.run(run_test()) def test_invalid_input_types(self): async def run_test(): with self.assertRaises(ValueError) as cm: await handle_list_repo_files({"path": 123}, "test/repo") self.assertEqual(str(cm.exception), "PATH_NOT_ALLOWED") import asyncio asyncio.run(run_test()) def test_unsupported_fields(self): unsupported = [ {"repo": "x"}, {"ref": "y"}, {"branch": "z"}, {"extra": "key"}, ] async def run_test(): for p in unsupported: with self.subTest(p=p): with self.assertRaisesRegex(ValueError, "Unsupported list_repo_files input field."): await handle_list_repo_files(p, "test/repo") import asyncio asyncio.run(run_test()) @patch("httpx.AsyncClient") def test_uses_trusted_repo(self, mock_async_client_constructor): payload = [] mock_async_client, mock_client = self._prepare_mock_client(payload) mock_async_client_constructor.return_value = mock_async_client async def run_test(): await handle_list_repo_files({"path": "docs", "repo": "user/bad-repo"}, "test/repo") # The handler should raise an error due to the unsupported 'repo' field. import asyncio with self.assertRaisesRegex(ValueError, "Unsupported list_repo_files input field."): asyncio.run(run_test()) @patch("httpx.AsyncClient") def test_http_error_propagation(self, mock_async_client_constructor): response = MagicMock() response.raise_for_status.side_effect = httpx.HTTPStatusError("Error", request=MagicMock(), response=MagicMock()) client = MagicMock() client.get = AsyncMock(return_value=response) async_client = MagicMock() async_client.__aenter__ = AsyncMock(return_value=client) async_client.__aexit__ = AsyncMock(return_value=None) mock_async_client_constructor.return_value = async_client async def run_test(): with self.assertRaises(httpx.HTTPStatusError): await handle_list_repo_files({"path": ""}, "test/repo") import asyncio asyncio.run(run_test()) if __name__ == '__main__': unittest.main()