feat: JSON multi-profile runtime config for Gitea MCP (roadmap #10)

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>
This commit is contained in:
2026-07-01 22:44:31 -04:00
parent d4251c5c47
commit 3aaba73127
7 changed files with 507 additions and 10 deletions
+7
View File
@@ -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 # 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. # only — never the token value. Surfaced by gitea_get_profile.
GITEA_TOKEN_SOURCE=GITEA_TOKEN 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
+3
View File
@@ -3,5 +3,8 @@ __pycache__/
*.pyc *.pyc
.env* .env*
!.env.example !.env.example
# Real JSON runtime-profile configs may reference private hosts; keep only the example.
gitea-mcp*.json
!gitea-mcp.example.json
.vscode/ .vscode/
graphify-out/ graphify-out/
+37
View File
@@ -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_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_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_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: Notes:
@@ -187,6 +189,41 @@ Notes:
branch and head SHA where applicable — when `GITEA_AUDIT_LOG` is set. Auditing 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. is off by default and never adds API calls or breaks the action when off.
See [`gitea_audit.py`](gitea_audit.py). 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.
</details> </details>
<details> <details>
+24
View File
@@ -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"
}
}
}
+51 -10
View File
@@ -17,6 +17,8 @@ import urllib.error
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
from dotenv import dotenv_values, load_dotenv from dotenv import dotenv_values, load_dotenv
import gitea_config
# Load standard .env if present # Load standard .env if present
load_dotenv() load_dotenv()
@@ -119,10 +121,20 @@ def get_auth_header(host):
if not token: if not token:
token = os.environ.get("GITEA_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: if token:
return f"token {token}" return f"token {token}"
# 3. Try User/Password Basic auth # 4. Try User/Password Basic auth
user, password = get_credentials(host) user, password = get_credentials(host)
if user and password: if user and password:
token_b64 = base64.b64encode(f"{user}:{password}".encode()).decode() 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 token continues to be resolved separately by ``get_auth_header`` and is
never part of this metadata. Callers may surface the result safely. 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: Returns:
dict with 'profile_name', 'allowed_operations' (list), dict with 'profile_name', 'allowed_operations' (list),
'forbidden_operations' (list), 'audit_label', 'token_source_name', 'forbidden_operations' (list), 'audit_label', 'token_source_name',
and 'base_url'. and 'base_url'.
""" """
name = (os.environ.get("GITEA_PROFILE_NAME") or "gitea-default").strip() # JSON layer (base). None when GITEA_MCP_CONFIG is unset; raises ConfigError
raw_ops = os.environ.get("GITEA_ALLOWED_OPERATIONS") or "" # on a misconfigured file/profile so the problem surfaces clearly at startup.
ops = [o.strip() for o in raw_ops.split(",") if o.strip()] jp = gitea_config.resolve_profile() or {}
raw_forbidden = os.environ.get("GITEA_FORBIDDEN_OPERATIONS") or ""
forbidden = [o.strip() for o in raw_forbidden.split(",") if o.strip()] def _env_csv(env_key):
audit_label = (os.environ.get("GITEA_AUDIT_LABEL") or "").strip() or None raw = os.environ.get(env_key)
# A *name* of the token source (e.g. "GITEA_TOKEN"), never the token value. if raw is None:
token_source = (os.environ.get("GITEA_TOKEN_SOURCE") or "").strip() or None return None
base_url = os.environ.get("GITEA_BASE_URL") or 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 { return {
"profile_name": name, "profile_name": name,
"allowed_operations": ops, "allowed_operations": ops,
+143
View File
@@ -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
+242
View File
@@ -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()