feat: canonical shared runtime-profiles config with typed auth refs (#19)
Rework the JSON runtime-profile config from the earlier ad-hoc schema (profiles + token_env) to the canonical single-file model in #19, so every LLM launcher can reference one shared Gitea profiles file instead of duplicating GITEA_USER_*/GITEA_PASS_* blocks or embedding tokens. Canonical schema (gitea_config.py): - top-level "version" (1) + "profiles" map. - each profile: base_url, username, default_owner, execution_profile, and a typed auth reference: { "type": "keychain", "id": "..." } -> macOS keychain (security(1)) { "type": "env", "name": "..." } -> named environment variable - inline "token"/"password" keys are rejected (never accepted or echoed). - select via GITEA_MCP_CONFIG (path) + GITEA_MCP_PROFILE (name). gitea_auth integration: - get_profile() overlays env over the selected profile (env wins; JSON fills the rest); profile_name <- execution_profile; token_source_name <- the non-secret auth reference name (env var name or "keychain:<id>"); now also surfaces username + default_owner. - get_auth_header() resolves the profile's auth reference (env/keychain) as a token fallback after explicit env tokens; a ConfigError there fails closed. Security / safety: - Secrets referenced only (keychain id / env name); token values never stored in or returned as metadata. Errors never print file contents, tokens, or passwords (JSONDecodeError context suppressed). - Missing file / invalid JSON / unsupported version / unknown-or-unset profile / unresolvable secret reference all raise a clear, safe ConfigError. - No network calls during config parsing; keychain lookup is on-demand and injectable for tests. - Backwards compatible: GITEA_MCP_CONFIG unset => legacy env-only mode (existing get_profile/get_auth_header tests unchanged). Docs: README canonical-profile + thin-launcher (Claude/Gemini/Codex) sections and a migration note away from duplicated GITEA_PASS_* blocks; .env.example and gitea-mcp.example.json updated to the canonical shape (safe placeholders only). Tests: tests/test_config.py (31 cases) — legacy env-only, JSON selection, multiple profiles, missing/unset profile, invalid JSON, unsupported version, env-override precedence, keychain + env auth-reference parsing and resolution, missing-secret errors, inline token/password redaction, and no-network parse. Refs #10. Completes the closed #19 (env-based profiles) by adding the canonical shared-file model. Supersedes this PR's earlier simpler JSON schema. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+158
-94
@@ -1,9 +1,10 @@
|
||||
"""Tests for JSON runtime-profile configuration (gitea_config) and its
|
||||
integration into gitea_auth.get_profile / get_auth_header.
|
||||
"""Tests for canonical 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.
|
||||
Covers: legacy env-only, JSON profile config, profile selection, multiple
|
||||
profiles, missing profile, invalid JSON, env-override precedence, keychain and
|
||||
env auth-reference parsing/resolution, token/password redaction in errors, and
|
||||
no network calls during config load.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -18,32 +19,30 @@ import gitea_config # noqa: E402
|
||||
import gitea_auth # noqa: E402
|
||||
|
||||
CONFIG = {
|
||||
"version": 1,
|
||||
"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",
|
||||
"prgs": {
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"username": "jcwalker3",
|
||||
"auth": {"type": "keychain", "id": "prgs-gitea-token"},
|
||||
"default_owner": "Scaled-Tech-Consulting",
|
||||
"execution_profile": "personal-prgs",
|
||||
},
|
||||
"prod-readonly": {
|
||||
"base_url": "https://gitea.prod.example",
|
||||
"profile_name": "gitea-readonly",
|
||||
"token_env": "GITEA_TOKEN_PROD",
|
||||
"allowed_operations": ["read"],
|
||||
"audit_label": "prod-ro",
|
||||
"mdcps-env": {
|
||||
"base_url": "https://gitea.dadeschools.net",
|
||||
"username": "913443",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS"},
|
||||
"default_owner": "Contractor",
|
||||
"execution_profile": "mdcps",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _ConfigBase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.path = os.path.join(self._dir.name, "gitea-mcp.json")
|
||||
self.path = os.path.join(self._dir.name, "profiles.json")
|
||||
self._write(CONFIG)
|
||||
|
||||
def tearDown(self):
|
||||
@@ -51,59 +50,55 @@ class _ConfigBase(unittest.TestCase):
|
||||
|
||||
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)
|
||||
fh.write(obj if isinstance(obj, str) else json.dumps(obj))
|
||||
|
||||
def _env(self, profile="dev", **extra):
|
||||
def _env(self, profile="prgs", **extra):
|
||||
env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": profile}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gitea_config: loading / selection
|
||||
# Loading / selection
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestLoadSelect(_ConfigBase):
|
||||
|
||||
def test_env_only_returns_none(self):
|
||||
# No GITEA_MCP_CONFIG -> JSON layer off, no error.
|
||||
def test_legacy_env_only_returns_none(self):
|
||||
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):
|
||||
with patch.dict(os.environ, self._env("prgs"), clear=True):
|
||||
p = gitea_config.resolve_profile()
|
||||
self.assertEqual(p["profile_name"], "gitea-author")
|
||||
self.assertEqual(p["base_url"], "https://gitea.dev.example")
|
||||
self.assertEqual(p["base_url"], "https://gitea.prgs.cc")
|
||||
self.assertEqual(p["execution_profile"], "personal-prgs")
|
||||
|
||||
def test_multiple_profiles_select_correctly(self):
|
||||
with patch.dict(os.environ, self._env("prod-readonly"), clear=True):
|
||||
with patch.dict(os.environ, self._env("mdcps-env"), clear=True):
|
||||
p = gitea_config.resolve_profile()
|
||||
self.assertEqual(p["profile_name"], "gitea-readonly")
|
||||
self.assertEqual(p["allowed_operations"], ["read"])
|
||||
self.assertEqual(p["username"], "913443")
|
||||
self.assertEqual(p["auth"]["type"], "env")
|
||||
|
||||
def test_missing_file_when_unset_is_noop(self):
|
||||
def test_missing_file_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"}
|
||||
def test_missing_file_set_raises(self):
|
||||
env = {"GITEA_MCP_CONFIG": self.path + ".nope", "GITEA_MCP_PROFILE": "prgs"}
|
||||
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):
|
||||
self._write('{"version":1,"profiles":{"prgs":{"auth":{"type":"env" BROKEN')
|
||||
with patch.dict(os.environ, self._env("prgs"), 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
|
||||
self.assertNotIn("BROKEN", msg) # no file content leaked
|
||||
|
||||
def test_missing_profile_raises_with_available(self):
|
||||
with patch.dict(os.environ, self._env("ghost"), clear=True):
|
||||
@@ -111,7 +106,7 @@ class TestLoadSelect(_ConfigBase):
|
||||
gitea_config.resolve_profile()
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("ghost", msg)
|
||||
self.assertIn("dev", msg) # lists available profiles
|
||||
self.assertIn("prgs", msg)
|
||||
|
||||
def test_config_set_but_profile_unset_raises(self):
|
||||
with patch.dict(os.environ, {"GITEA_MCP_CONFIG": self.path}, clear=True):
|
||||
@@ -120,54 +115,127 @@ class TestLoadSelect(_ConfigBase):
|
||||
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):
|
||||
self._write({"version": 1, "nope": 1})
|
||||
with patch.dict(os.environ, self._env("prgs"), clear=True):
|
||||
with self.assertRaises(gitea_config.ConfigError):
|
||||
gitea_config.resolve_profile()
|
||||
|
||||
def test_unsupported_version_raises(self):
|
||||
self._write({"version": 2, "profiles": {"prgs": {}}})
|
||||
with patch.dict(os.environ, self._env("prgs"), clear=True):
|
||||
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||
gitea_config.resolve_profile()
|
||||
self.assertIn("version", str(ctx.exception))
|
||||
|
||||
def test_missing_version_defaults_ok(self):
|
||||
self._write({"profiles": {"prgs": {"base_url": "https://x"}}})
|
||||
with patch.dict(os.environ, self._env("prgs"), clear=True):
|
||||
self.assertEqual(
|
||||
gitea_config.resolve_profile()["base_url"], "https://x")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth reference parsing + resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAuthReferences(_ConfigBase):
|
||||
|
||||
def test_keychain_reference_parses(self):
|
||||
with patch.dict(os.environ, self._env("prgs"), clear=True):
|
||||
p = gitea_config.resolve_profile()
|
||||
self.assertEqual(p["auth"], {"type": "keychain", "id": "prgs-gitea-token"})
|
||||
self.assertEqual(gitea_config.auth_source_name(p), "keychain:prgs-gitea-token")
|
||||
|
||||
def test_env_reference_parses(self):
|
||||
with patch.dict(os.environ, self._env("mdcps-env"), clear=True):
|
||||
p = gitea_config.resolve_profile()
|
||||
self.assertEqual(gitea_config.auth_source_name(p), "GITEA_TOKEN_MDCPS")
|
||||
|
||||
def test_keychain_token_resolved_via_injected_lookup(self):
|
||||
with patch.dict(os.environ, self._env("prgs"), clear=True):
|
||||
p = gitea_config.resolve_profile()
|
||||
token = gitea_config.resolve_token(
|
||||
p, keychain_lookup=lambda i: "kc-token" if i == "prgs-gitea-token" else None)
|
||||
self.assertEqual(token, "kc-token")
|
||||
|
||||
def test_env_token_resolved_from_env(self):
|
||||
env = self._env("mdcps-env", GITEA_TOKEN_MDCPS="mdcps-token-value")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
p = gitea_config.resolve_profile()
|
||||
self.assertEqual(gitea_config.resolve_token(p), "mdcps-token-value")
|
||||
|
||||
def test_keychain_lookup_uses_security_binary(self):
|
||||
# _keychain_token shells out to `security find-generic-password`.
|
||||
class _Proc:
|
||||
returncode = 0
|
||||
stdout = "secret-from-keychain\n"
|
||||
with patch("gitea_config.subprocess.run", return_value=_Proc()) as run:
|
||||
self.assertEqual(gitea_config._keychain_token("some-id"), "secret-from-keychain")
|
||||
args = run.call_args[0][0]
|
||||
self.assertEqual(args[0], "security")
|
||||
self.assertIn("some-id", args)
|
||||
|
||||
def test_missing_env_secret_raises_clearly(self):
|
||||
with patch.dict(os.environ, self._env("mdcps-env"), clear=True):
|
||||
p = gitea_config.resolve_profile()
|
||||
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||
gitea_config.resolve_token(p)
|
||||
self.assertIn("GITEA_TOKEN_MDCPS", str(ctx.exception))
|
||||
|
||||
def test_missing_keychain_secret_raises_clearly(self):
|
||||
with patch.dict(os.environ, self._env("prgs"), clear=True):
|
||||
p = gitea_config.resolve_profile()
|
||||
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||
gitea_config.resolve_token(p, keychain_lookup=lambda i: None)
|
||||
self.assertIn("prgs-gitea-token", str(ctx.exception))
|
||||
|
||||
def test_invalid_auth_type_rejected(self):
|
||||
self._write({"version": 1, "profiles": {"p": {"auth": {"type": "vault"}}}})
|
||||
with patch.dict(os.environ, self._env("p"), clear=True):
|
||||
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||
gitea_config.resolve_profile()
|
||||
self.assertIn("invalid auth type", str(ctx.exception))
|
||||
|
||||
def test_keychain_without_id_rejected(self):
|
||||
self._write({"version": 1, "profiles": {"p": {"auth": {"type": "keychain"}}}})
|
||||
with patch.dict(os.environ, self._env("p"), clear=True):
|
||||
with self.assertRaises(gitea_config.ConfigError):
|
||||
gitea_config.resolve_profile()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token handling / redaction
|
||||
# Secret redaction
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestTokenHandling(_ConfigBase):
|
||||
class TestRedaction(_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):
|
||||
self._write({"version": 1, "profiles": {"prgs": {"token": "super-secret-token"}}})
|
||||
with patch.dict(os.environ, self._env("prgs"), 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
|
||||
self.assertIn("auth", msg)
|
||||
self.assertNotIn("super-secret-token", msg)
|
||||
|
||||
def test_resolve_token_reads_env_by_name(self):
|
||||
env = self._env("dev", GITEA_TOKEN_DEV="dev-token-value")
|
||||
def test_inline_password_rejected_without_echo(self):
|
||||
self._write({"version": 1, "profiles": {"prgs": {"password": "hunter2secret"}}})
|
||||
with patch.dict(os.environ, self._env("prgs"), clear=True):
|
||||
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||
gitea_config.resolve_profile()
|
||||
self.assertNotIn("hunter2secret", str(ctx.exception))
|
||||
|
||||
def test_profile_metadata_has_no_token_value(self):
|
||||
env = self._env("mdcps-env", GITEA_TOKEN_MDCPS="mdcps-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))
|
||||
self.assertNotIn("mdcps-token-value", json.dumps(p))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gitea_auth integration: get_profile precedence + get_auth_header fallback
|
||||
# gitea_auth integration
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAuthIntegration(_ConfigBase):
|
||||
|
||||
def test_env_only_profile_unchanged(self):
|
||||
# No JSON config: get_profile is exactly the env-only behaviour.
|
||||
def test_legacy_env_only_profile_unchanged(self):
|
||||
with patch.dict(os.environ, {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review"},
|
||||
clear=True):
|
||||
@@ -177,25 +245,21 @@ class TestAuthIntegration(_ConfigBase):
|
||||
self.assertIsNone(p["base_url"])
|
||||
|
||||
def test_json_fills_profile_when_env_absent(self):
|
||||
with patch.dict(os.environ, self._env("dev"), clear=True):
|
||||
with patch.dict(os.environ, self._env("prgs"), 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")
|
||||
self.assertEqual(p["profile_name"], "personal-prgs") # from execution_profile
|
||||
self.assertEqual(p["base_url"], "https://gitea.prgs.cc")
|
||||
self.assertEqual(p["username"], "jcwalker3")
|
||||
self.assertEqual(p["default_owner"], "Scaled-Tech-Consulting")
|
||||
self.assertEqual(p["token_source_name"], "keychain:prgs-gitea-token")
|
||||
|
||||
def test_env_overrides_json(self):
|
||||
env = self._env("dev",
|
||||
env = self._env("prgs",
|
||||
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):
|
||||
@@ -203,22 +267,23 @@ class TestAuthIntegration(_ConfigBase):
|
||||
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")
|
||||
def test_auth_header_uses_json_env_token(self):
|
||||
env = self._env("mdcps-env", GITEA_TOKEN_MDCPS="json-env-token")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
header = gitea_auth.get_auth_header("gitea.example.com")
|
||||
self.assertEqual(header, "token json-token")
|
||||
self.assertEqual(header, "token json-env-token")
|
||||
|
||||
def test_explicit_env_token_overrides_json(self):
|
||||
env = self._env("dev", GITEA_TOKEN_DEV="json-token", GITEA_TOKEN="env-token")
|
||||
env = self._env("mdcps-env", GITEA_TOKEN_MDCPS="json-env-token",
|
||||
GITEA_TOKEN="process-token")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
header = gitea_auth.get_auth_header("gitea.example.com")
|
||||
self.assertEqual(header, "token env-token")
|
||||
self.assertEqual(header, "token process-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):
|
||||
def test_auth_header_unresolvable_ref_fails_closed(self):
|
||||
# env token ref points at an unset var -> ConfigError inside resolve is
|
||||
# swallowed to "no token"; auth falls through to (mocked-empty) basic.
|
||||
with patch.dict(os.environ, self._env("mdcps-env"), clear=True):
|
||||
with patch("gitea_auth.get_credentials", return_value=("", "")):
|
||||
self.assertIsNone(gitea_auth.get_auth_header("gitea.example.com"))
|
||||
|
||||
@@ -230,12 +295,11 @@ 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.dict(os.environ, self._env("prgs"), 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")
|
||||
gitea_auth.get_profile()["profile_name"], "personal-prgs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user