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:
@@ -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,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/
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user