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:
+7
-6
@@ -39,9 +39,10 @@ GITEA_AUDIT_LOG=/path/to/gitea-mcp-audit.log
|
|||||||
# 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
|
# Optional canonical runtime-profile config (#19). Instead of the fields above,
|
||||||
# above, point at a JSON file with multiple named profiles and select one.
|
# point every LLM launcher at ONE JSON file of named profiles and select one.
|
||||||
# See gitea-mcp.example.json. Explicit env vars above still override the
|
# Secrets are referenced (keychain id / env var name), never inlined. See
|
||||||
# selected profile's values. Leave unset for pure env-based configuration.
|
# gitea-mcp.example.json. Explicit env vars above still override the selected
|
||||||
GITEA_MCP_CONFIG=/path/to/gitea-mcp.json
|
# profile's values. Leave unset for pure env-based configuration.
|
||||||
GITEA_MCP_PROFILE=dev
|
GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
|
||||||
|
GITEA_MCP_PROFILE=prgs
|
||||||
|
|||||||
@@ -190,40 +190,68 @@ Notes:
|
|||||||
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,
|
**Canonical runtime profiles (#19).** Define every Gitea profile **once**, in a
|
||||||
one server can pick a named profile from a JSON file (see
|
canonical JSON file, and keep each LLM launcher (Claude / Gemini / Codex) a
|
||||||
[`gitea-mcp.example.json`](gitea-mcp.example.json), loaded by
|
*thin* pointer at it — no duplicated `GITEA_USER_*` / `GITEA_PASS_*` blocks and
|
||||||
[`gitea_config.py`](gitea_config.py)):
|
no raw tokens in client configs. See [`gitea-mcp.example.json`](gitea-mcp.example.json),
|
||||||
|
loaded by [`gitea_config.py`](gitea_config.py).
|
||||||
|
|
||||||
```bash
|
Canonical profile file (e.g. `~/.config/gitea-tools/profiles.json`):
|
||||||
export GITEA_MCP_CONFIG=/path/to/gitea-mcp.json
|
|
||||||
export GITEA_MCP_PROFILE=dev
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"version": 1,
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"dev": {
|
"prgs": {
|
||||||
"base_url": "https://gitea.dev.example",
|
"base_url": "https://gitea.prgs.cc",
|
||||||
"profile_name": "gitea-author",
|
"username": "jcwalker3",
|
||||||
"token_env": "GITEA_TOKEN_DEV",
|
"auth": { "type": "keychain", "id": "prgs-gitea-token" },
|
||||||
"allowed_operations": ["read", "pr.create"],
|
"default_owner": "Scaled-Tech-Consulting",
|
||||||
"audit_label": "dev-author"
|
"execution_profile": "personal-prgs"
|
||||||
|
},
|
||||||
|
"mdcps": {
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
"username": "913443",
|
||||||
|
"auth": { "type": "env", "name": "GITEA_TOKEN_MDCPS" },
|
||||||
|
"execution_profile": "mdcps"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Token by reference only:** a profile names the env var holding its token
|
Thin LLM launcher (Claude / Gemini / Codex) — only two env vars, no secrets:
|
||||||
(`token_env`); an inline `token` is rejected. The value is read from that env
|
|
||||||
var and never stored in or returned as profile metadata.
|
```json
|
||||||
- **Precedence:** explicit env vars (`GITEA_PROFILE_NAME`, `GITEA_BASE_URL`,
|
"gitea-tools": {
|
||||||
`GITEA_ALLOWED_OPERATIONS`, `GITEA_TOKEN`, …) **override** the JSON profile;
|
"command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
|
||||||
the JSON profile only fills what the environment leaves unset.
|
"args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
|
||||||
|
"env": {
|
||||||
|
"GITEA_MCP_CONFIG": "/Users/jasonwalker/.config/gitea-tools/profiles.json",
|
||||||
|
"GITEA_MCP_PROFILE": "prgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Secrets by reference only:** a profile's `auth` names *where* the token
|
||||||
|
lives — `{ "type": "keychain", "id": "..." }` (macOS keychain) or
|
||||||
|
`{ "type": "env", "name": "..." }` (env var). Inline `token`/`password` keys
|
||||||
|
are rejected. The value is resolved on demand and never stored in, returned
|
||||||
|
by, or logged as profile metadata.
|
||||||
|
- **Precedence:** explicit process env vars (`GITEA_PROFILE_NAME`,
|
||||||
|
`GITEA_BASE_URL`, `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
|
- **Backwards compatible / fail-safe:** with `GITEA_MCP_CONFIG` unset, behaviour
|
||||||
is exactly env-only. A missing file, invalid JSON, or unknown/unset selected
|
is exactly the legacy env-only mode. A missing file, invalid JSON, unsupported
|
||||||
profile raises a clear startup error that never prints file contents or
|
`version`, unknown/unset selected profile, or unresolvable secret reference
|
||||||
tokens. Parsing makes no network calls.
|
raises a clear startup error that never prints file contents, tokens, or
|
||||||
|
passwords. Parsing makes no network calls.
|
||||||
|
|
||||||
|
**Migrating from duplicated `GITEA_PASS_*` blocks.** Move each instance's
|
||||||
|
credentials into one canonical profile entry (referencing a keychain id or env
|
||||||
|
var for the secret), then delete the `GITEA_USER_*` / `GITEA_PASS_*` /
|
||||||
|
`GITEA_SITE_*` blocks from every LLM `mcp_config.json`, leaving only
|
||||||
|
`GITEA_MCP_CONFIG` + `GITEA_MCP_PROFILE`. Existing env-only setups keep working
|
||||||
|
unchanged until migrated.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
+29
-18
@@ -1,24 +1,35 @@
|
|||||||
{
|
{
|
||||||
|
"version": 1,
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"dev": {
|
"prgs": {
|
||||||
"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",
|
"base_url": "https://gitea.prgs.cc",
|
||||||
"profile_name": "gitea-readonly",
|
"username": "jcwalker3",
|
||||||
"token_env": "GITEA_TOKEN_PROD",
|
"auth": {
|
||||||
"owner": "Scaled-Tech-Consulting",
|
"type": "keychain",
|
||||||
"repo": "Gitea-Tools",
|
"id": "prgs-gitea-token"
|
||||||
"allowed_operations": ["read"],
|
},
|
||||||
"forbidden_operations": ["merge", "approve", "request_changes"],
|
"default_owner": "Scaled-Tech-Consulting",
|
||||||
"audit_label": "prod-ro"
|
"execution_profile": "personal-prgs"
|
||||||
|
},
|
||||||
|
"mdcps": {
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
"username": "913443",
|
||||||
|
"auth": {
|
||||||
|
"type": "keychain",
|
||||||
|
"id": "mdcps-gitea-token"
|
||||||
|
},
|
||||||
|
"default_owner": "Contractor",
|
||||||
|
"execution_profile": "mdcps"
|
||||||
|
},
|
||||||
|
"prgs-env": {
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"auth": {
|
||||||
|
"type": "env",
|
||||||
|
"name": "GITEA_TOKEN_PRGS"
|
||||||
|
},
|
||||||
|
"default_owner": "Scaled-Tech-Consulting",
|
||||||
|
"execution_profile": "personal-prgs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-5
@@ -320,7 +320,9 @@ def get_profile():
|
|||||||
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'.
|
'base_url', 'username', and 'default_owner'. ``profile_name`` maps to a
|
||||||
|
JSON profile's ``execution_profile``; ``token_source_name`` is the
|
||||||
|
non-secret auth reference name (env var name or ``keychain:<id>``).
|
||||||
"""
|
"""
|
||||||
# JSON layer (base). None when GITEA_MCP_CONFIG is unset; raises ConfigError
|
# JSON layer (base). None when GITEA_MCP_CONFIG is unset; raises ConfigError
|
||||||
# on a misconfigured file/profile so the problem surfaces clearly at startup.
|
# on a misconfigured file/profile so the problem surfaces clearly at startup.
|
||||||
@@ -336,9 +338,9 @@ def get_profile():
|
|||||||
val = jp.get(key)
|
val = jp.get(key)
|
||||||
return list(val) if isinstance(val, (list, tuple)) else []
|
return list(val) if isinstance(val, (list, tuple)) else []
|
||||||
|
|
||||||
# profile_name: env > JSON > default.
|
# profile_name: env > JSON execution_profile > default.
|
||||||
name = (os.environ.get("GITEA_PROFILE_NAME")
|
name = (os.environ.get("GITEA_PROFILE_NAME")
|
||||||
or jp.get("profile_name") or "gitea-default")
|
or jp.get("execution_profile") or "gitea-default")
|
||||||
name = str(name).strip() or "gitea-default"
|
name = str(name).strip() or "gitea-default"
|
||||||
|
|
||||||
ops = _env_csv("GITEA_ALLOWED_OPERATIONS")
|
ops = _env_csv("GITEA_ALLOWED_OPERATIONS")
|
||||||
@@ -350,9 +352,9 @@ def get_profile():
|
|||||||
|
|
||||||
audit_label = (os.environ.get("GITEA_AUDIT_LABEL") or "").strip() \
|
audit_label = (os.environ.get("GITEA_AUDIT_LABEL") or "").strip() \
|
||||||
or (jp.get("audit_label") or None)
|
or (jp.get("audit_label") or None)
|
||||||
# A *name* of the token source (env var name / JSON token_env), never a value.
|
# A *name* of the token source (env var name / keychain id), never a value.
|
||||||
token_source = (os.environ.get("GITEA_TOKEN_SOURCE") or "").strip() \
|
token_source = (os.environ.get("GITEA_TOKEN_SOURCE") or "").strip() \
|
||||||
or (jp.get("token_env") or None)
|
or gitea_config.auth_source_name(jp)
|
||||||
base_url = os.environ.get("GITEA_BASE_URL") or jp.get("base_url") 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,
|
||||||
@@ -361,4 +363,6 @@ def get_profile():
|
|||||||
"audit_label": audit_label,
|
"audit_label": audit_label,
|
||||||
"token_source_name": token_source,
|
"token_source_name": token_source,
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
|
"username": jp.get("username") or None,
|
||||||
|
"default_owner": jp.get("default_owner") or None,
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-43
@@ -1,52 +1,64 @@
|
|||||||
"""JSON runtime-profile configuration for Gitea MCP.
|
"""Canonical JSON runtime-profile configuration for Gitea MCP (issue #19).
|
||||||
|
|
||||||
Lets one MCP server select among multiple named runtime profiles defined in a
|
One canonical config file defines every Gitea runtime profile. Each LLM MCP
|
||||||
JSON file, instead of editing code or juggling many ``.env`` files:
|
launcher (Claude / Gemini / Codex) stays a *thin* launcher that only points at
|
||||||
|
that file and names a profile — no duplicated ``GITEA_USER_*`` / ``GITEA_PASS_*``
|
||||||
|
blocks, no raw tokens in client configs:
|
||||||
|
|
||||||
GITEA_MCP_CONFIG=/path/to/gitea-mcp.json # the file
|
GITEA_MCP_CONFIG=/path/to/profiles.json # the canonical file
|
||||||
GITEA_MCP_PROFILE=dev # which named profile to use
|
GITEA_MCP_PROFILE=prgs # which named profile to use
|
||||||
|
|
||||||
File shape (see ``gitea-mcp.example.json``)::
|
File shape (see ``gitea-mcp.example.json``)::
|
||||||
|
|
||||||
{
|
{
|
||||||
|
"version": 1,
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"dev": {
|
"prgs": {
|
||||||
"base_url": "https://gitea.dev.example",
|
"base_url": "https://gitea.prgs.cc",
|
||||||
"profile_name": "gitea-author",
|
"username": "jcwalker3",
|
||||||
"token_env": "GITEA_TOKEN_DEV",
|
"auth": {"type": "keychain", "id": "prgs-gitea-token"},
|
||||||
"owner": "Scaled-Tech-Consulting",
|
"default_owner": "Scaled-Tech-Consulting",
|
||||||
"repo": "Gitea-Tools",
|
"execution_profile": "personal-prgs"
|
||||||
"allowed_operations": ["read", "pr.create"],
|
|
||||||
"forbidden_operations": ["merge"],
|
|
||||||
"audit_label": "dev-author"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Auth is referenced *indirectly*, never inline:
|
||||||
|
|
||||||
|
- ``{"type": "keychain", "id": "prgs-gitea-token"}`` — resolved from the macOS
|
||||||
|
keychain at token-resolution time.
|
||||||
|
- ``{"type": "env", "name": "GITEA_TOKEN_PRGS"}`` — read from that env var.
|
||||||
|
|
||||||
Design constraints:
|
Design constraints:
|
||||||
|
|
||||||
- **Backwards compatible.** With ``GITEA_MCP_CONFIG`` unset, every entry point
|
- **Backwards compatible.** With ``GITEA_MCP_CONFIG`` unset, every entry point
|
||||||
returns ``None`` and callers fall back to pure environment behaviour.
|
returns ``None`` and callers fall back to pure environment behaviour.
|
||||||
- **No inline secrets.** A profile references its token by *env var name*
|
- **No inline secrets.** A raw ``token``/``password`` key is rejected. Token
|
||||||
(``token_env``); a raw ``token`` key is rejected. The token value is resolved
|
values are resolved only via an auth *reference* and are never stored in, or
|
||||||
by reading that env var and is never stored in, or returned as, profile
|
returned as, profile metadata.
|
||||||
metadata.
|
- **No network.** Parsing only reads and decodes a local file. Token resolution
|
||||||
- **No network.** Parsing only reads and decodes a local file.
|
(keychain/env) happens separately, on demand.
|
||||||
- **Fail safely.** A missing file, invalid JSON, or missing/unknown selected
|
- **Fail safely.** A missing file, invalid JSON, unsupported version, unknown/
|
||||||
profile raises :class:`ConfigError` with a clear message that never includes
|
unset selected profile, or an unresolvable secret reference raises
|
||||||
file contents or credential values.
|
:class:`ConfigError` with a message that never includes file contents,
|
||||||
|
tokens, or passwords.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
ENV_CONFIG_PATH = "GITEA_MCP_CONFIG"
|
ENV_CONFIG_PATH = "GITEA_MCP_CONFIG"
|
||||||
ENV_PROFILE = "GITEA_MCP_PROFILE"
|
ENV_PROFILE = "GITEA_MCP_PROFILE"
|
||||||
|
|
||||||
|
SUPPORTED_VERSION = 1
|
||||||
|
_AUTH_TYPES = ("keychain", "env")
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(Exception):
|
class ConfigError(Exception):
|
||||||
"""Raised for a missing/invalid config file or a bad profile selection.
|
"""Raised for a bad config file, profile selection, or secret reference.
|
||||||
|
|
||||||
Messages are safe to surface: they never include file contents or tokens.
|
Messages are safe to surface: they never include file contents, tokens, or
|
||||||
|
passwords — only non-secret names/ids/positions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -61,12 +73,12 @@ def selected_profile_name():
|
|||||||
|
|
||||||
|
|
||||||
def load_config(path=None):
|
def load_config(path=None):
|
||||||
"""Load and minimally validate the JSON config.
|
"""Load and minimally validate the canonical JSON config.
|
||||||
|
|
||||||
Returns the parsed dict, or None when no config path is configured (so
|
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
|
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
|
configured but the file is missing, unreadable, not valid JSON, declares an
|
||||||
``profiles`` object.
|
unsupported ``version``, or lacks a ``profiles`` object.
|
||||||
"""
|
"""
|
||||||
path = path or config_path()
|
path = path or config_path()
|
||||||
if not path:
|
if not path:
|
||||||
@@ -87,15 +99,39 @@ def load_config(path=None):
|
|||||||
raise ConfigError(f"could not read {path}: {exc.strerror}") from None
|
raise ConfigError(f"could not read {path}: {exc.strerror}") from None
|
||||||
if not isinstance(data, dict) or not isinstance(data.get("profiles"), dict):
|
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")
|
raise ConfigError(f"{path} must be a JSON object with a 'profiles' object")
|
||||||
|
version = data.get("version", SUPPORTED_VERSION)
|
||||||
|
if version != SUPPORTED_VERSION:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{path} has unsupported version {version!r}; expected {SUPPORTED_VERSION}"
|
||||||
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_auth(name, auth):
|
||||||
|
"""Validate a profile's optional ``auth`` reference. Never echoes secrets."""
|
||||||
|
if auth is None:
|
||||||
|
return
|
||||||
|
if not isinstance(auth, dict):
|
||||||
|
raise ConfigError(f"profile '{name}' has a non-object 'auth'")
|
||||||
|
atype = auth.get("type")
|
||||||
|
if atype not in _AUTH_TYPES:
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' has invalid auth type {atype!r}; "
|
||||||
|
f"expected one of {list(_AUTH_TYPES)}"
|
||||||
|
)
|
||||||
|
if atype == "keychain" and not auth.get("id"):
|
||||||
|
raise ConfigError(f"profile '{name}' keychain auth requires an 'id'")
|
||||||
|
if atype == "env" and not auth.get("name"):
|
||||||
|
raise ConfigError(f"profile '{name}' env auth requires a 'name'")
|
||||||
|
|
||||||
|
|
||||||
def select_profile(config, name=None):
|
def select_profile(config, name=None):
|
||||||
"""Return the selected profile dict from a loaded *config*.
|
"""Return the selected profile dict from a loaded *config*.
|
||||||
|
|
||||||
Returns None when *config* is None. Raises :class:`ConfigError` when no
|
Returns None when *config* is None. Raises :class:`ConfigError` when no
|
||||||
profile is selected, the selected profile is unknown, the profile is not an
|
profile is selected, the selected profile is unknown or not an object, the
|
||||||
object, or the profile embeds a raw ``token``.
|
profile embeds a raw ``token``/``password``, or its ``auth`` reference is
|
||||||
|
malformed.
|
||||||
"""
|
"""
|
||||||
if config is None:
|
if config is None:
|
||||||
return None
|
return None
|
||||||
@@ -114,12 +150,14 @@ def select_profile(config, name=None):
|
|||||||
profile = profiles[name]
|
profile = profiles[name]
|
||||||
if not isinstance(profile, dict):
|
if not isinstance(profile, dict):
|
||||||
raise ConfigError(f"profile '{name}' must be a JSON object")
|
raise ConfigError(f"profile '{name}' must be a JSON object")
|
||||||
if "token" in profile:
|
for secret_key in ("token", "password"):
|
||||||
# Never accept (or echo) an inline secret; require an env var reference.
|
if secret_key in profile:
|
||||||
raise ConfigError(
|
# Never accept (or echo) an inline secret; require an auth reference.
|
||||||
f"profile '{name}' must not contain an inline 'token'; "
|
raise ConfigError(
|
||||||
"use 'token_env' (the NAME of an environment variable) instead"
|
f"profile '{name}' must not contain an inline '{secret_key}'; "
|
||||||
)
|
"use an 'auth' reference ({\"type\": \"keychain\"|\"env\", ...}) instead"
|
||||||
|
)
|
||||||
|
_validate_auth(name, profile.get("auth"))
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
|
||||||
@@ -128,16 +166,71 @@ def resolve_profile(path=None, name=None):
|
|||||||
return select_profile(load_config(path), name)
|
return select_profile(load_config(path), name)
|
||||||
|
|
||||||
|
|
||||||
def resolve_token(profile):
|
def auth_source_name(profile):
|
||||||
"""Resolve the token for *profile* via its ``token_env`` name.
|
"""Return a *non-secret* name for a profile's token source, or None.
|
||||||
|
|
||||||
Reads the named environment variable; returns None if the profile is None,
|
For env auth this is the env var name; for keychain auth, ``keychain:<id>``.
|
||||||
has no ``token_env``, or the variable is unset. Never accepts an inline
|
Safe to surface in profile metadata (never the token value).
|
||||||
token and never logs the value.
|
|
||||||
"""
|
"""
|
||||||
if not profile:
|
if not profile:
|
||||||
return None
|
return None
|
||||||
env_name = profile.get("token_env")
|
auth = profile.get("auth")
|
||||||
if not env_name:
|
if not isinstance(auth, dict):
|
||||||
return None
|
return None
|
||||||
return os.environ.get(env_name) or None
|
if auth.get("type") == "env":
|
||||||
|
return auth.get("name")
|
||||||
|
if auth.get("type") == "keychain":
|
||||||
|
return f"keychain:{auth.get('id')}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _keychain_token(item_id):
|
||||||
|
"""Read a token from the macOS keychain by service *item_id*.
|
||||||
|
|
||||||
|
Returns the secret string, or None if it cannot be found. Never logs the
|
||||||
|
value; failures are swallowed so the caller can raise a safe error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["security", "find-generic-password", "-s", item_id, "-w"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return None
|
||||||
|
return proc.stdout.strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_token(profile, keychain_lookup=_keychain_token):
|
||||||
|
"""Resolve the token for *profile* via its ``auth`` reference.
|
||||||
|
|
||||||
|
Returns None when *profile* is None or has no ``auth``. Raises
|
||||||
|
:class:`ConfigError` when the reference cannot be resolved (env var unset or
|
||||||
|
keychain item missing). Never accepts an inline token and never logs, echoes,
|
||||||
|
or includes the secret *value* in any error. *keychain_lookup* is injectable
|
||||||
|
for testing.
|
||||||
|
"""
|
||||||
|
if not profile:
|
||||||
|
return None
|
||||||
|
auth = profile.get("auth")
|
||||||
|
if not isinstance(auth, dict):
|
||||||
|
return None
|
||||||
|
atype = auth.get("type")
|
||||||
|
if atype == "env":
|
||||||
|
env_name = auth.get("name")
|
||||||
|
value = os.environ.get(env_name) if env_name else None
|
||||||
|
if not value:
|
||||||
|
raise ConfigError(
|
||||||
|
f"auth env var '{env_name}' referenced by the profile is not set"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
if atype == "keychain":
|
||||||
|
item_id = auth.get("id")
|
||||||
|
value = keychain_lookup(item_id) if item_id else None
|
||||||
|
if not value:
|
||||||
|
raise ConfigError(
|
||||||
|
f"keychain item '{item_id}' referenced by the profile was not found"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
raise ConfigError(f"unsupported auth type {atype!r} in the selected profile")
|
||||||
|
|||||||
+158
-94
@@ -1,9 +1,10 @@
|
|||||||
"""Tests for JSON runtime-profile configuration (gitea_config) and its
|
"""Tests for canonical JSON runtime-profile configuration (gitea_config) and
|
||||||
integration into gitea_auth.get_profile / get_auth_header.
|
its integration into gitea_auth.get_profile / get_auth_header.
|
||||||
|
|
||||||
Covers: env-only still works, JSON selection, multiple profiles, env-override
|
Covers: legacy env-only, JSON profile config, profile selection, multiple
|
||||||
precedence, missing file, invalid JSON, missing/unset profile, inline-token
|
profiles, missing profile, invalid JSON, env-override precedence, keychain and
|
||||||
rejection + redaction, and that config parsing performs no network calls.
|
env auth-reference parsing/resolution, token/password redaction in errors, and
|
||||||
|
no network calls during config load.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -18,32 +19,30 @@ import gitea_config # noqa: E402
|
|||||||
import gitea_auth # noqa: E402
|
import gitea_auth # noqa: E402
|
||||||
|
|
||||||
CONFIG = {
|
CONFIG = {
|
||||||
|
"version": 1,
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"dev": {
|
"prgs": {
|
||||||
"base_url": "https://gitea.dev.example",
|
"base_url": "https://gitea.prgs.cc",
|
||||||
"profile_name": "gitea-author",
|
"username": "jcwalker3",
|
||||||
"token_env": "GITEA_TOKEN_DEV",
|
"auth": {"type": "keychain", "id": "prgs-gitea-token"},
|
||||||
"owner": "Org",
|
"default_owner": "Scaled-Tech-Consulting",
|
||||||
"repo": "Repo",
|
"execution_profile": "personal-prgs",
|
||||||
"allowed_operations": ["read", "pr.create"],
|
|
||||||
"forbidden_operations": ["merge"],
|
|
||||||
"audit_label": "dev-author",
|
|
||||||
},
|
},
|
||||||
"prod-readonly": {
|
"mdcps-env": {
|
||||||
"base_url": "https://gitea.prod.example",
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
"profile_name": "gitea-readonly",
|
"username": "913443",
|
||||||
"token_env": "GITEA_TOKEN_PROD",
|
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS"},
|
||||||
"allowed_operations": ["read"],
|
"default_owner": "Contractor",
|
||||||
"audit_label": "prod-ro",
|
"execution_profile": "mdcps",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _ConfigBase(unittest.TestCase):
|
class _ConfigBase(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._dir = tempfile.TemporaryDirectory()
|
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)
|
self._write(CONFIG)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
@@ -51,59 +50,55 @@ class _ConfigBase(unittest.TestCase):
|
|||||||
|
|
||||||
def _write(self, obj):
|
def _write(self, obj):
|
||||||
with open(self.path, "w", encoding="utf-8") as fh:
|
with open(self.path, "w", encoding="utf-8") as fh:
|
||||||
if isinstance(obj, str):
|
fh.write(obj if isinstance(obj, str) else json.dumps(obj))
|
||||||
fh.write(obj)
|
|
||||||
else:
|
|
||||||
json.dump(obj, fh)
|
|
||||||
|
|
||||||
def _env(self, profile="dev", **extra):
|
def _env(self, profile="prgs", **extra):
|
||||||
env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": profile}
|
env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": profile}
|
||||||
env.update(extra)
|
env.update(extra)
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# gitea_config: loading / selection
|
# Loading / selection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestLoadSelect(_ConfigBase):
|
class TestLoadSelect(_ConfigBase):
|
||||||
|
|
||||||
def test_env_only_returns_none(self):
|
def test_legacy_env_only_returns_none(self):
|
||||||
# No GITEA_MCP_CONFIG -> JSON layer off, no error.
|
|
||||||
with patch.dict(os.environ, {}, clear=True):
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
self.assertIsNone(gitea_config.load_config())
|
self.assertIsNone(gitea_config.load_config())
|
||||||
self.assertIsNone(gitea_config.resolve_profile())
|
self.assertIsNone(gitea_config.resolve_profile())
|
||||||
|
|
||||||
def test_selected_profile_loads(self):
|
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()
|
p = gitea_config.resolve_profile()
|
||||||
self.assertEqual(p["profile_name"], "gitea-author")
|
self.assertEqual(p["base_url"], "https://gitea.prgs.cc")
|
||||||
self.assertEqual(p["base_url"], "https://gitea.dev.example")
|
self.assertEqual(p["execution_profile"], "personal-prgs")
|
||||||
|
|
||||||
def test_multiple_profiles_select_correctly(self):
|
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()
|
p = gitea_config.resolve_profile()
|
||||||
self.assertEqual(p["profile_name"], "gitea-readonly")
|
self.assertEqual(p["username"], "913443")
|
||||||
self.assertEqual(p["allowed_operations"], ["read"])
|
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):
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
self.assertIsNone(gitea_config.resolve_profile())
|
self.assertIsNone(gitea_config.resolve_profile())
|
||||||
|
|
||||||
def test_missing_file_when_set_raises(self):
|
def test_missing_file_set_raises(self):
|
||||||
env = {"GITEA_MCP_CONFIG": self.path + ".nope", "GITEA_MCP_PROFILE": "dev"}
|
env = {"GITEA_MCP_CONFIG": self.path + ".nope", "GITEA_MCP_PROFILE": "prgs"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
gitea_config.resolve_profile()
|
gitea_config.resolve_profile()
|
||||||
self.assertIn("missing file", str(ctx.exception))
|
self.assertIn("missing file", str(ctx.exception))
|
||||||
|
|
||||||
def test_invalid_json_raises_without_leaking_content(self):
|
def test_invalid_json_raises_without_leaking_content(self):
|
||||||
self._write('{"profiles": {"dev": {"token": "super-secret-token" BROKEN}}}')
|
self._write('{"version":1,"profiles":{"prgs":{"auth":{"type":"env" BROKEN')
|
||||||
with patch.dict(os.environ, self._env("dev"), clear=True):
|
with patch.dict(os.environ, self._env("prgs"), clear=True):
|
||||||
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
gitea_config.resolve_profile()
|
gitea_config.resolve_profile()
|
||||||
msg = str(ctx.exception)
|
msg = str(ctx.exception)
|
||||||
self.assertIn("invalid JSON", msg)
|
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):
|
def test_missing_profile_raises_with_available(self):
|
||||||
with patch.dict(os.environ, self._env("ghost"), clear=True):
|
with patch.dict(os.environ, self._env("ghost"), clear=True):
|
||||||
@@ -111,7 +106,7 @@ class TestLoadSelect(_ConfigBase):
|
|||||||
gitea_config.resolve_profile()
|
gitea_config.resolve_profile()
|
||||||
msg = str(ctx.exception)
|
msg = str(ctx.exception)
|
||||||
self.assertIn("ghost", msg)
|
self.assertIn("ghost", msg)
|
||||||
self.assertIn("dev", msg) # lists available profiles
|
self.assertIn("prgs", msg)
|
||||||
|
|
||||||
def test_config_set_but_profile_unset_raises(self):
|
def test_config_set_but_profile_unset_raises(self):
|
||||||
with patch.dict(os.environ, {"GITEA_MCP_CONFIG": self.path}, clear=True):
|
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))
|
self.assertIn("GITEA_MCP_PROFILE", str(ctx.exception))
|
||||||
|
|
||||||
def test_bad_top_level_shape_raises(self):
|
def test_bad_top_level_shape_raises(self):
|
||||||
self._write({"nope": 1})
|
self._write({"version": 1, "nope": 1})
|
||||||
with patch.dict(os.environ, self._env("dev"), clear=True):
|
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):
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
gitea_config.resolve_profile()
|
gitea_config.resolve_profile()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Token handling / redaction
|
# Secret redaction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestTokenHandling(_ConfigBase):
|
class TestRedaction(_ConfigBase):
|
||||||
|
|
||||||
def test_inline_token_rejected_without_echo(self):
|
def test_inline_token_rejected_without_echo(self):
|
||||||
self._write({"profiles": {"dev": {"token": "super-secret-token"}}})
|
self._write({"version": 1, "profiles": {"prgs": {"token": "super-secret-token"}}})
|
||||||
with patch.dict(os.environ, self._env("dev"), clear=True):
|
with patch.dict(os.environ, self._env("prgs"), clear=True):
|
||||||
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
gitea_config.resolve_profile()
|
gitea_config.resolve_profile()
|
||||||
msg = str(ctx.exception)
|
msg = str(ctx.exception)
|
||||||
self.assertIn("token_env", msg)
|
self.assertIn("auth", msg)
|
||||||
self.assertNotIn("super-secret-token", msg) # value never echoed
|
self.assertNotIn("super-secret-token", msg)
|
||||||
|
|
||||||
def test_resolve_token_reads_env_by_name(self):
|
def test_inline_password_rejected_without_echo(self):
|
||||||
env = self._env("dev", GITEA_TOKEN_DEV="dev-token-value")
|
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):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
p = gitea_config.resolve_profile()
|
p = gitea_config.resolve_profile()
|
||||||
self.assertEqual(gitea_config.resolve_token(p), "dev-token-value")
|
self.assertNotIn("mdcps-token-value", json.dumps(p))
|
||||||
|
|
||||||
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
|
# gitea_auth integration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestAuthIntegration(_ConfigBase):
|
class TestAuthIntegration(_ConfigBase):
|
||||||
|
|
||||||
def test_env_only_profile_unchanged(self):
|
def test_legacy_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",
|
with patch.dict(os.environ, {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review"},
|
"GITEA_ALLOWED_OPERATIONS": "read,review"},
|
||||||
clear=True):
|
clear=True):
|
||||||
@@ -177,25 +245,21 @@ class TestAuthIntegration(_ConfigBase):
|
|||||||
self.assertIsNone(p["base_url"])
|
self.assertIsNone(p["base_url"])
|
||||||
|
|
||||||
def test_json_fills_profile_when_env_absent(self):
|
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()
|
p = gitea_auth.get_profile()
|
||||||
self.assertEqual(p["profile_name"], "gitea-author")
|
self.assertEqual(p["profile_name"], "personal-prgs") # from execution_profile
|
||||||
self.assertEqual(p["allowed_operations"], ["read", "pr.create"])
|
self.assertEqual(p["base_url"], "https://gitea.prgs.cc")
|
||||||
self.assertEqual(p["forbidden_operations"], ["merge"])
|
self.assertEqual(p["username"], "jcwalker3")
|
||||||
self.assertEqual(p["audit_label"], "dev-author")
|
self.assertEqual(p["default_owner"], "Scaled-Tech-Consulting")
|
||||||
self.assertEqual(p["base_url"], "https://gitea.dev.example")
|
self.assertEqual(p["token_source_name"], "keychain:prgs-gitea-token")
|
||||||
# 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):
|
def test_env_overrides_json(self):
|
||||||
env = self._env("dev",
|
env = self._env("prgs",
|
||||||
GITEA_PROFILE_NAME="env-name",
|
GITEA_PROFILE_NAME="env-name",
|
||||||
GITEA_ALLOWED_OPERATIONS="read",
|
|
||||||
GITEA_BASE_URL="https://env.example")
|
GITEA_BASE_URL="https://env.example")
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
p = gitea_auth.get_profile()
|
p = gitea_auth.get_profile()
|
||||||
self.assertEqual(p["profile_name"], "env-name")
|
self.assertEqual(p["profile_name"], "env-name")
|
||||||
self.assertEqual(p["allowed_operations"], ["read"])
|
|
||||||
self.assertEqual(p["base_url"], "https://env.example")
|
self.assertEqual(p["base_url"], "https://env.example")
|
||||||
|
|
||||||
def test_get_profile_propagates_config_error(self):
|
def test_get_profile_propagates_config_error(self):
|
||||||
@@ -203,22 +267,23 @@ class TestAuthIntegration(_ConfigBase):
|
|||||||
with self.assertRaises(gitea_config.ConfigError):
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
gitea_auth.get_profile()
|
gitea_auth.get_profile()
|
||||||
|
|
||||||
def test_auth_header_uses_json_token_env(self):
|
def test_auth_header_uses_json_env_token(self):
|
||||||
env = self._env("dev", GITEA_TOKEN_DEV="json-token")
|
env = self._env("mdcps-env", GITEA_TOKEN_MDCPS="json-env-token")
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
header = gitea_auth.get_auth_header("gitea.example.com")
|
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):
|
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):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
header = gitea_auth.get_auth_header("gitea.example.com")
|
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):
|
def test_auth_header_unresolvable_ref_fails_closed(self):
|
||||||
# A broken selection must not crash auth — it falls back to no token.
|
# env token ref points at an unset var -> ConfigError inside resolve is
|
||||||
env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": "ghost"}
|
# swallowed to "no token"; auth falls through to (mocked-empty) basic.
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, self._env("mdcps-env"), clear=True):
|
||||||
with patch("gitea_auth.get_credentials", return_value=("", "")):
|
with patch("gitea_auth.get_credentials", return_value=("", "")):
|
||||||
self.assertIsNone(gitea_auth.get_auth_header("gitea.example.com"))
|
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):
|
def test_config_load_makes_no_network_calls(self):
|
||||||
boom = AssertionError("config parsing must not touch the network")
|
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):
|
with patch("gitea_auth.urllib.request.urlopen", side_effect=boom):
|
||||||
self.assertIsNotNone(gitea_config.resolve_profile())
|
self.assertIsNotNone(gitea_config.resolve_profile())
|
||||||
# get_profile only reads config + env; no HTTP.
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
gitea_auth.get_profile()["profile_name"], "gitea-author")
|
gitea_auth.get_profile()["profile_name"], "personal-prgs")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user