3aaba73127
Let one MCP server select among named Gitea runtime profiles from a JSON file
instead of editing code or juggling many .env files:
GITEA_MCP_CONFIG=/path/to/gitea-mcp.json
GITEA_MCP_PROFILE=dev
- New gitea_config.py: load/validate the JSON, select the named profile, and
resolve its token by env-var reference. Profiles supply base_url,
profile_name, token_env, owner/repo, allowed/forbidden operations, and audit
label.
- gitea_auth.get_profile() now overlays env over the selected JSON profile:
explicit env vars win, the JSON profile fills only what env leaves unset.
- gitea_auth.get_auth_header() gains a JSON token_env fallback after explicit
env tokens (env still wins).
Security / safety:
- Tokens are referenced by env-var NAME (token_env); an inline "token" is
rejected and never echoed. The value is never stored in or returned as
profile metadata.
- Fail-safe errors: missing file / invalid JSON / unknown or unset selected
profile raise a clear ConfigError that never prints file contents or tokens
(JSONDecodeError context is suppressed so the raw file text can't surface).
- No network calls during config parsing.
- Real config files are gitignored (gitea-mcp*.json), example kept.
Backwards compatible: with GITEA_MCP_CONFIG unset, behaviour is exactly the
prior env-only behaviour (all existing get_profile/get_auth_header tests pass
unchanged).
Docs: README JSON-profiles section + env table rows, .env.example placeholders,
gitea-mcp.example.json.
Tests: tests/test_config.py (22 cases) — env-only, selection, multiple
profiles, env-override precedence, missing file, invalid JSON, missing/unset
profile, inline-token rejection + redaction, and no-network-during-parse.
Refs #10. Note: issue #19 (env-based profiles) was already implemented and
closed; this JSON-file capability is adjacent new scope tracked under the
roadmap rather than reopening #19.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
243 lines
10 KiB
Python
243 lines
10 KiB
Python
"""Tests for JSON runtime-profile configuration (gitea_config) and its
|
|
integration into gitea_auth.get_profile / get_auth_header.
|
|
|
|
Covers: env-only still works, JSON selection, multiple profiles, env-override
|
|
precedence, missing file, invalid JSON, missing/unset profile, inline-token
|
|
rejection + redaction, and that config parsing performs no network calls.
|
|
"""
|
|
import os
|
|
import sys
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
|
|
import gitea_config # noqa: E402
|
|
import gitea_auth # noqa: E402
|
|
|
|
CONFIG = {
|
|
"profiles": {
|
|
"dev": {
|
|
"base_url": "https://gitea.dev.example",
|
|
"profile_name": "gitea-author",
|
|
"token_env": "GITEA_TOKEN_DEV",
|
|
"owner": "Org",
|
|
"repo": "Repo",
|
|
"allowed_operations": ["read", "pr.create"],
|
|
"forbidden_operations": ["merge"],
|
|
"audit_label": "dev-author",
|
|
},
|
|
"prod-readonly": {
|
|
"base_url": "https://gitea.prod.example",
|
|
"profile_name": "gitea-readonly",
|
|
"token_env": "GITEA_TOKEN_PROD",
|
|
"allowed_operations": ["read"],
|
|
"audit_label": "prod-ro",
|
|
},
|
|
}
|
|
}
|
|
|
|
|
|
class _ConfigBase(unittest.TestCase):
|
|
def setUp(self):
|
|
self._dir = tempfile.TemporaryDirectory()
|
|
self.path = os.path.join(self._dir.name, "gitea-mcp.json")
|
|
self._write(CONFIG)
|
|
|
|
def tearDown(self):
|
|
self._dir.cleanup()
|
|
|
|
def _write(self, obj):
|
|
with open(self.path, "w", encoding="utf-8") as fh:
|
|
if isinstance(obj, str):
|
|
fh.write(obj)
|
|
else:
|
|
json.dump(obj, fh)
|
|
|
|
def _env(self, profile="dev", **extra):
|
|
env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": profile}
|
|
env.update(extra)
|
|
return env
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# gitea_config: loading / selection
|
|
# ---------------------------------------------------------------------------
|
|
class TestLoadSelect(_ConfigBase):
|
|
|
|
def test_env_only_returns_none(self):
|
|
# No GITEA_MCP_CONFIG -> JSON layer off, no error.
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
self.assertIsNone(gitea_config.load_config())
|
|
self.assertIsNone(gitea_config.resolve_profile())
|
|
|
|
def test_selected_profile_loads(self):
|
|
with patch.dict(os.environ, self._env("dev"), clear=True):
|
|
p = gitea_config.resolve_profile()
|
|
self.assertEqual(p["profile_name"], "gitea-author")
|
|
self.assertEqual(p["base_url"], "https://gitea.dev.example")
|
|
|
|
def test_multiple_profiles_select_correctly(self):
|
|
with patch.dict(os.environ, self._env("prod-readonly"), clear=True):
|
|
p = gitea_config.resolve_profile()
|
|
self.assertEqual(p["profile_name"], "gitea-readonly")
|
|
self.assertEqual(p["allowed_operations"], ["read"])
|
|
|
|
def test_missing_file_when_unset_is_noop(self):
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
self.assertIsNone(gitea_config.resolve_profile())
|
|
|
|
def test_missing_file_when_set_raises(self):
|
|
env = {"GITEA_MCP_CONFIG": self.path + ".nope", "GITEA_MCP_PROFILE": "dev"}
|
|
with patch.dict(os.environ, env, clear=True):
|
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
|
gitea_config.resolve_profile()
|
|
self.assertIn("missing file", str(ctx.exception))
|
|
|
|
def test_invalid_json_raises_without_leaking_content(self):
|
|
self._write('{"profiles": {"dev": {"token": "super-secret-token" BROKEN}}}')
|
|
with patch.dict(os.environ, self._env("dev"), clear=True):
|
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
|
gitea_config.resolve_profile()
|
|
msg = str(ctx.exception)
|
|
self.assertIn("invalid JSON", msg)
|
|
self.assertNotIn("super-secret-token", msg) # no file content leaked
|
|
|
|
def test_missing_profile_raises_with_available(self):
|
|
with patch.dict(os.environ, self._env("ghost"), clear=True):
|
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
|
gitea_config.resolve_profile()
|
|
msg = str(ctx.exception)
|
|
self.assertIn("ghost", msg)
|
|
self.assertIn("dev", msg) # lists available profiles
|
|
|
|
def test_config_set_but_profile_unset_raises(self):
|
|
with patch.dict(os.environ, {"GITEA_MCP_CONFIG": self.path}, clear=True):
|
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
|
gitea_config.resolve_profile()
|
|
self.assertIn("GITEA_MCP_PROFILE", str(ctx.exception))
|
|
|
|
def test_bad_top_level_shape_raises(self):
|
|
self._write({"nope": 1})
|
|
with patch.dict(os.environ, self._env("dev"), clear=True):
|
|
with self.assertRaises(gitea_config.ConfigError):
|
|
gitea_config.resolve_profile()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Token handling / redaction
|
|
# ---------------------------------------------------------------------------
|
|
class TestTokenHandling(_ConfigBase):
|
|
|
|
def test_inline_token_rejected_without_echo(self):
|
|
self._write({"profiles": {"dev": {"token": "super-secret-token"}}})
|
|
with patch.dict(os.environ, self._env("dev"), clear=True):
|
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
|
gitea_config.resolve_profile()
|
|
msg = str(ctx.exception)
|
|
self.assertIn("token_env", msg)
|
|
self.assertNotIn("super-secret-token", msg) # value never echoed
|
|
|
|
def test_resolve_token_reads_env_by_name(self):
|
|
env = self._env("dev", GITEA_TOKEN_DEV="dev-token-value")
|
|
with patch.dict(os.environ, env, clear=True):
|
|
p = gitea_config.resolve_profile()
|
|
self.assertEqual(gitea_config.resolve_token(p), "dev-token-value")
|
|
|
|
def test_resolve_token_none_when_env_unset(self):
|
|
with patch.dict(os.environ, self._env("dev"), clear=True):
|
|
p = gitea_config.resolve_profile()
|
|
self.assertIsNone(gitea_config.resolve_token(p))
|
|
|
|
def test_resolve_token_none_for_none_profile(self):
|
|
self.assertIsNone(gitea_config.resolve_token(None))
|
|
|
|
def test_profile_metadata_never_contains_token_value(self):
|
|
env = self._env("dev", GITEA_TOKEN_DEV="dev-token-value")
|
|
with patch.dict(os.environ, env, clear=True):
|
|
p = gitea_config.resolve_profile()
|
|
self.assertNotIn("dev-token-value", json.dumps(p))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# gitea_auth integration: get_profile precedence + get_auth_header fallback
|
|
# ---------------------------------------------------------------------------
|
|
class TestAuthIntegration(_ConfigBase):
|
|
|
|
def test_env_only_profile_unchanged(self):
|
|
# No JSON config: get_profile is exactly the env-only behaviour.
|
|
with patch.dict(os.environ, {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
|
"GITEA_ALLOWED_OPERATIONS": "read,review"},
|
|
clear=True):
|
|
p = gitea_auth.get_profile()
|
|
self.assertEqual(p["profile_name"], "gitea-reviewer")
|
|
self.assertEqual(p["allowed_operations"], ["read", "review"])
|
|
self.assertIsNone(p["base_url"])
|
|
|
|
def test_json_fills_profile_when_env_absent(self):
|
|
with patch.dict(os.environ, self._env("dev"), clear=True):
|
|
p = gitea_auth.get_profile()
|
|
self.assertEqual(p["profile_name"], "gitea-author")
|
|
self.assertEqual(p["allowed_operations"], ["read", "pr.create"])
|
|
self.assertEqual(p["forbidden_operations"], ["merge"])
|
|
self.assertEqual(p["audit_label"], "dev-author")
|
|
self.assertEqual(p["base_url"], "https://gitea.dev.example")
|
|
# token_env surfaces as the (non-secret) token *source name*.
|
|
self.assertEqual(p["token_source_name"], "GITEA_TOKEN_DEV")
|
|
|
|
def test_env_overrides_json(self):
|
|
env = self._env("dev",
|
|
GITEA_PROFILE_NAME="env-name",
|
|
GITEA_ALLOWED_OPERATIONS="read",
|
|
GITEA_BASE_URL="https://env.example")
|
|
with patch.dict(os.environ, env, clear=True):
|
|
p = gitea_auth.get_profile()
|
|
self.assertEqual(p["profile_name"], "env-name")
|
|
self.assertEqual(p["allowed_operations"], ["read"])
|
|
self.assertEqual(p["base_url"], "https://env.example")
|
|
|
|
def test_get_profile_propagates_config_error(self):
|
|
with patch.dict(os.environ, self._env("ghost"), clear=True):
|
|
with self.assertRaises(gitea_config.ConfigError):
|
|
gitea_auth.get_profile()
|
|
|
|
def test_auth_header_uses_json_token_env(self):
|
|
env = self._env("dev", GITEA_TOKEN_DEV="json-token")
|
|
with patch.dict(os.environ, env, clear=True):
|
|
header = gitea_auth.get_auth_header("gitea.example.com")
|
|
self.assertEqual(header, "token json-token")
|
|
|
|
def test_explicit_env_token_overrides_json(self):
|
|
env = self._env("dev", GITEA_TOKEN_DEV="json-token", GITEA_TOKEN="env-token")
|
|
with patch.dict(os.environ, env, clear=True):
|
|
header = gitea_auth.get_auth_header("gitea.example.com")
|
|
self.assertEqual(header, "token env-token")
|
|
|
|
def test_auth_header_config_error_fails_closed(self):
|
|
# A broken selection must not crash auth — it falls back to no token.
|
|
env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": "ghost"}
|
|
with patch.dict(os.environ, env, clear=True):
|
|
with patch("gitea_auth.get_credentials", return_value=("", "")):
|
|
self.assertIsNone(gitea_auth.get_auth_header("gitea.example.com"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# No network during config parsing
|
|
# ---------------------------------------------------------------------------
|
|
class TestNoNetwork(_ConfigBase):
|
|
|
|
def test_config_load_makes_no_network_calls(self):
|
|
boom = AssertionError("config parsing must not touch the network")
|
|
with patch.dict(os.environ, self._env("dev"), clear=True):
|
|
with patch("gitea_auth.urllib.request.urlopen", side_effect=boom):
|
|
self.assertIsNotNone(gitea_config.resolve_profile())
|
|
# get_profile only reads config + env; no HTTP.
|
|
self.assertEqual(
|
|
gitea_auth.get_profile()["profile_name"], "gitea-author")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|