From 3aaba73127a3a6b322225a5bf6b3dc36459aa2e0 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 1 Jul 2026 22:44:31 -0400 Subject: [PATCH] feat: JSON multi-profile runtime config for Gitea MCP (roadmap #10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 7 ++ .gitignore | 3 + README.md | 37 +++++++ gitea-mcp.example.json | 24 ++++ gitea_auth.py | 61 +++++++++-- gitea_config.py | 143 ++++++++++++++++++++++++ tests/test_config.py | 242 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 507 insertions(+), 10 deletions(-) create mode 100644 gitea-mcp.example.json create mode 100644 gitea_config.py create mode 100644 tests/test_config.py diff --git a/.env.example b/.env.example index 0748a31..522f4e2 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,10 @@ GITEA_AUDIT_LOG=/path/to/gitea-mcp-audit.log # Optional NAME of the token's source (e.g. an env var name). This is a name # only — never the token value. Surfaced by gitea_get_profile. GITEA_TOKEN_SOURCE=GITEA_TOKEN + +# Optional JSON runtime-profile config (roadmap #10). Instead of the fields +# above, point at a JSON file with multiple named profiles and select one. +# See gitea-mcp.example.json. Explicit env vars above still override the +# selected profile's values. Leave unset for pure env-based configuration. +GITEA_MCP_CONFIG=/path/to/gitea-mcp.json +GITEA_MCP_PROFILE=dev diff --git a/.gitignore b/.gitignore index ed6386f..552c123 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,8 @@ __pycache__/ *.pyc .env* !.env.example +# Real JSON runtime-profile configs may reference private hosts; keep only the example. +gitea-mcp*.json +!gitea-mcp.example.json .vscode/ graphify-out/ diff --git a/README.md b/README.md index 8cf4916..8dcb451 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ Recognized environment fields (see [`.env.example`](.env.example) for placeholde | `GITEA_TOKEN_SOURCE` | Optional *name* of the token source (e.g. an env var name). A name only — never the token value. | | `GITEA_BASE_URL` | Optional informational base URL. | | `GITEA_AUDIT_LOG` | Optional path to an audit log file. When set, mutating actions append one redacted JSON record each (profile + authenticated user + outcome). Unset ⇒ auditing off (no records, no extra API calls). | +| `GITEA_MCP_CONFIG` | Optional path to a JSON file defining multiple named runtime profiles. Unset ⇒ pure env behaviour. | +| `GITEA_MCP_PROFILE` | Name of the profile (from `GITEA_MCP_CONFIG`) to activate for this runtime. | Notes: @@ -187,6 +189,41 @@ Notes: branch and head SHA where applicable — when `GITEA_AUDIT_LOG` is set. Auditing is off by default and never adds API calls or breaks the action when off. See [`gitea_audit.py`](gitea_audit.py). + +**JSON runtime profiles (roadmap #10).** Instead of juggling many `.env` files, +one server can pick a named profile from a JSON file (see +[`gitea-mcp.example.json`](gitea-mcp.example.json), loaded by +[`gitea_config.py`](gitea_config.py)): + +```bash +export GITEA_MCP_CONFIG=/path/to/gitea-mcp.json +export GITEA_MCP_PROFILE=dev +``` + +```json +{ + "profiles": { + "dev": { + "base_url": "https://gitea.dev.example", + "profile_name": "gitea-author", + "token_env": "GITEA_TOKEN_DEV", + "allowed_operations": ["read", "pr.create"], + "audit_label": "dev-author" + } + } +} +``` + +- **Token by reference only:** a profile names the env var holding its token + (`token_env`); an inline `token` is rejected. The value is read from that env + var and never stored in or returned as profile metadata. +- **Precedence:** explicit env vars (`GITEA_PROFILE_NAME`, `GITEA_BASE_URL`, + `GITEA_ALLOWED_OPERATIONS`, `GITEA_TOKEN`, …) **override** the JSON profile; + the JSON profile only fills what the environment leaves unset. +- **Backwards compatible / fail-safe:** with `GITEA_MCP_CONFIG` unset, behaviour + is exactly env-only. A missing file, invalid JSON, or unknown/unset selected + profile raises a clear startup error that never prints file contents or + tokens. Parsing makes no network calls.
diff --git a/gitea-mcp.example.json b/gitea-mcp.example.json new file mode 100644 index 0000000..dee02d8 --- /dev/null +++ b/gitea-mcp.example.json @@ -0,0 +1,24 @@ +{ + "profiles": { + "dev": { + "base_url": "https://gitea.dev.example", + "profile_name": "gitea-author", + "token_env": "GITEA_TOKEN_DEV", + "owner": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "allowed_operations": ["read", "pr.create"], + "forbidden_operations": ["merge"], + "audit_label": "dev-author" + }, + "prod-readonly": { + "base_url": "https://gitea.prgs.cc", + "profile_name": "gitea-readonly", + "token_env": "GITEA_TOKEN_PROD", + "owner": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "allowed_operations": ["read"], + "forbidden_operations": ["merge", "approve", "request_changes"], + "audit_label": "prod-ro" + } + } +} diff --git a/gitea_auth.py b/gitea_auth.py index 28663f8..4eb6ed6 100644 --- a/gitea_auth.py +++ b/gitea_auth.py @@ -17,6 +17,8 @@ import urllib.error from email.utils import parsedate_to_datetime from dotenv import dotenv_values, load_dotenv +import gitea_config + # Load standard .env if present load_dotenv() @@ -119,10 +121,20 @@ def get_auth_header(host): if not token: token = os.environ.get("GITEA_TOKEN") + # 3. Fall back to a JSON runtime-profile token reference (token_env). + # Explicit env tokens above take precedence. A broken config never breaks + # auth here — it fails closed to "no token"; the clear error surfaces via + # get_profile() / startup instead. + if not token: + try: + token = gitea_config.resolve_token(gitea_config.resolve_profile()) + except gitea_config.ConfigError: + token = None + if token: return f"token {token}" - # 3. Try User/Password Basic auth + # 4. Try User/Password Basic auth user, password = get_credentials(host) if user and password: token_b64 = base64.b64encode(f"{user}:{password}".encode()).decode() @@ -299,20 +311,49 @@ def get_profile(): token continues to be resolved separately by ``get_auth_header`` and is never part of this metadata. Callers may surface the result safely. + A JSON runtime-profile config (``GITEA_MCP_CONFIG`` + ``GITEA_MCP_PROFILE``, + see ``gitea_config``) may supply these same fields as a base layer. Explicit + environment variables always override the JSON profile; the JSON profile + only fills fields the environment leaves unset. With no config configured, + behaviour is exactly the environment-only behaviour above. + Returns: dict with 'profile_name', 'allowed_operations' (list), 'forbidden_operations' (list), 'audit_label', 'token_source_name', and 'base_url'. """ - name = (os.environ.get("GITEA_PROFILE_NAME") or "gitea-default").strip() - raw_ops = os.environ.get("GITEA_ALLOWED_OPERATIONS") or "" - ops = [o.strip() for o in raw_ops.split(",") if o.strip()] - raw_forbidden = os.environ.get("GITEA_FORBIDDEN_OPERATIONS") or "" - forbidden = [o.strip() for o in raw_forbidden.split(",") if o.strip()] - audit_label = (os.environ.get("GITEA_AUDIT_LABEL") or "").strip() or None - # A *name* of the token source (e.g. "GITEA_TOKEN"), never the token value. - token_source = (os.environ.get("GITEA_TOKEN_SOURCE") or "").strip() or None - base_url = os.environ.get("GITEA_BASE_URL") or None + # JSON layer (base). None when GITEA_MCP_CONFIG is unset; raises ConfigError + # on a misconfigured file/profile so the problem surfaces clearly at startup. + jp = gitea_config.resolve_profile() or {} + + def _env_csv(env_key): + raw = os.environ.get(env_key) + if raw is None: + return None + return [o.strip() for o in raw.split(",") if o.strip()] + + def _json_list(key): + val = jp.get(key) + return list(val) if isinstance(val, (list, tuple)) else [] + + # profile_name: env > JSON > default. + name = (os.environ.get("GITEA_PROFILE_NAME") + or jp.get("profile_name") or "gitea-default") + name = str(name).strip() or "gitea-default" + + ops = _env_csv("GITEA_ALLOWED_OPERATIONS") + if ops is None: + ops = _json_list("allowed_operations") + forbidden = _env_csv("GITEA_FORBIDDEN_OPERATIONS") + if forbidden is None: + forbidden = _json_list("forbidden_operations") + + audit_label = (os.environ.get("GITEA_AUDIT_LABEL") or "").strip() \ + or (jp.get("audit_label") or None) + # A *name* of the token source (env var name / JSON token_env), never a value. + token_source = (os.environ.get("GITEA_TOKEN_SOURCE") or "").strip() \ + or (jp.get("token_env") or None) + base_url = os.environ.get("GITEA_BASE_URL") or jp.get("base_url") or None return { "profile_name": name, "allowed_operations": ops, diff --git a/gitea_config.py b/gitea_config.py new file mode 100644 index 0000000..fcd06fc --- /dev/null +++ b/gitea_config.py @@ -0,0 +1,143 @@ +"""JSON runtime-profile configuration for Gitea MCP. + +Lets one MCP server select among multiple named runtime profiles defined in a +JSON file, instead of editing code or juggling many ``.env`` files: + + GITEA_MCP_CONFIG=/path/to/gitea-mcp.json # the file + GITEA_MCP_PROFILE=dev # which named profile to use + +File shape (see ``gitea-mcp.example.json``):: + + { + "profiles": { + "dev": { + "base_url": "https://gitea.dev.example", + "profile_name": "gitea-author", + "token_env": "GITEA_TOKEN_DEV", + "owner": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "allowed_operations": ["read", "pr.create"], + "forbidden_operations": ["merge"], + "audit_label": "dev-author" + } + } + } + +Design constraints: + +- **Backwards compatible.** With ``GITEA_MCP_CONFIG`` unset, every entry point + returns ``None`` and callers fall back to pure environment behaviour. +- **No inline secrets.** A profile references its token by *env var name* + (``token_env``); a raw ``token`` key is rejected. The token value is resolved + by reading that env var and is never stored in, or returned as, profile + metadata. +- **No network.** Parsing only reads and decodes a local file. +- **Fail safely.** A missing file, invalid JSON, or missing/unknown selected + profile raises :class:`ConfigError` with a clear message that never includes + file contents or credential values. +""" +import os +import json + +ENV_CONFIG_PATH = "GITEA_MCP_CONFIG" +ENV_PROFILE = "GITEA_MCP_PROFILE" + + +class ConfigError(Exception): + """Raised for a missing/invalid config file or a bad profile selection. + + Messages are safe to surface: they never include file contents or tokens. + """ + + +def config_path(): + """Return the configured JSON path, or None when the JSON layer is off.""" + return (os.environ.get(ENV_CONFIG_PATH) or "").strip() or None + + +def selected_profile_name(): + """Return the selected profile name from the environment, or None.""" + return (os.environ.get(ENV_PROFILE) or "").strip() or None + + +def load_config(path=None): + """Load and minimally validate the JSON config. + + Returns the parsed dict, or None when no config path is configured (so + env-only usage is never broken). Raises :class:`ConfigError` when a path is + configured but the file is missing, unreadable, not valid JSON, or lacks a + ``profiles`` object. + """ + path = path or config_path() + if not path: + return None + if not os.path.isfile(path): + raise ConfigError(f"{ENV_CONFIG_PATH} points to a missing file: {path}") + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + except json.JSONDecodeError as exc: + # Report position only (msg/line/col). `from None` suppresses the + # original exception, whose `.doc` holds the raw file text — which could + # contain a token — so it never reaches a traceback. + raise ConfigError( + f"invalid JSON in {path} (line {exc.lineno}, column {exc.colno}): {exc.msg}" + ) from None + except OSError as exc: + raise ConfigError(f"could not read {path}: {exc.strerror}") from None + if not isinstance(data, dict) or not isinstance(data.get("profiles"), dict): + raise ConfigError(f"{path} must be a JSON object with a 'profiles' object") + return data + + +def select_profile(config, name=None): + """Return the selected profile dict from a loaded *config*. + + Returns None when *config* is None. Raises :class:`ConfigError` when no + profile is selected, the selected profile is unknown, the profile is not an + object, or the profile embeds a raw ``token``. + """ + if config is None: + return None + profiles = config.get("profiles", {}) + name = name or selected_profile_name() + available = sorted(profiles) + if not name: + raise ConfigError( + f"{ENV_CONFIG_PATH} is set but {ENV_PROFILE} is not; " + f"available profiles: {available}" + ) + if name not in profiles: + raise ConfigError( + f"profile '{name}' not found in config; available profiles: {available}" + ) + profile = profiles[name] + if not isinstance(profile, dict): + raise ConfigError(f"profile '{name}' must be a JSON object") + if "token" in profile: + # Never accept (or echo) an inline secret; require an env var reference. + raise ConfigError( + f"profile '{name}' must not contain an inline 'token'; " + "use 'token_env' (the NAME of an environment variable) instead" + ) + return profile + + +def resolve_profile(path=None, name=None): + """Load the config and return the selected profile dict, or None if off.""" + return select_profile(load_config(path), name) + + +def resolve_token(profile): + """Resolve the token for *profile* via its ``token_env`` name. + + Reads the named environment variable; returns None if the profile is None, + has no ``token_env``, or the variable is unset. Never accepts an inline + token and never logs the value. + """ + if not profile: + return None + env_name = profile.get("token_env") + if not env_name: + return None + return os.environ.get(env_name) or None diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..8721c88 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,242 @@ +"""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()