835fbbf324
Add an interactive utility so users create/edit/validate canonical runtime profiles and generate safe LLM launcher snippets without hand-editing JSON or pasting tokens into Claude/Gemini/Codex configs. Run: `python gitea_config.py menu` (or `python gitea_config_menu.py`). gitea_config.py — pure, testable authoring helpers: - is_valid_profile_name, build_profile, keychain_auth/env_auth, empty_config - validate_config (reports missing base_url/auth, inline token/password — never echoing the secret value) - add_profile (preserves existing, rejects dup/invalid name/missing base_url), upsert_profile, remove_profile - save_config: mkdir parents + atomic temp-then-os.replace, pretty JSON - launcher_entry: thin MCP entry (command/args + GITEA_MCP_CONFIG/PROFILE only) - keychain_set: store a token via `security add-generic-password` (token passed as an arg, never returned/printed/logged; injectable runner) - `menu` __main__ dispatch gitea_config_menu.py — interactive loop with fully injectable IO/secret/HTTP/ keychain so it is testable without a real terminal, keychain, or network: - list / add / edit / remove / validate profiles - test authentication + show authenticated user (calls /user only on request) - reviewer-eligibility helper (authenticated user vs PR author, open state) — read-only, never approves/merges - launcher snippets for Claude / Gemini / Codex (no secrets) Security: tokens are never written to profiles.json, launcher snippets, logs, or errors — only keychain ids / env var names are stored. Backwards compatible: menu is optional; env-only mode and MCP server startup are unchanged. Tests: tests/test_config_menu.py (21 cases) — name validation, preserve-on-add, dup/invalid/missing-field rejection, atomic write (+ replace-failure leaves the original intact, no temp debris), keychain_set stores-without-printing, launcher snippets secret-free, eligibility eligible/self-author/closed, and a full menu add→list→quit flow proving the token value never reaches disk or stdout. Stacked on #30 (canonical profiles); base branch feat/json-runtime-profiles. Refs #10, #19. Closes #31. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
296 lines
13 KiB
Python
296 lines
13 KiB
Python
"""Tests for the interactive profile-setup menu (#31): gitea_config authoring
|
|
helpers + gitea_config_menu, all exercised without a real keychain, terminal,
|
|
or network via injected IO/auth/HTTP.
|
|
"""
|
|
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_config_menu as menu # noqa: E402
|
|
|
|
SECRET = "super-secret-token-value"
|
|
|
|
|
|
class _InputQueue:
|
|
"""Callable that returns queued answers for successive input() prompts."""
|
|
|
|
def __init__(self, answers):
|
|
self._answers = list(answers)
|
|
|
|
def __call__(self, _prompt=""):
|
|
return self._answers.pop(0)
|
|
|
|
|
|
class _Out:
|
|
"""Capture out() lines."""
|
|
|
|
def __init__(self):
|
|
self.lines = []
|
|
|
|
def __call__(self, *args):
|
|
self.lines.append(" ".join(str(a) for a in args))
|
|
|
|
@property
|
|
def text(self):
|
|
return "\n".join(self.lines)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config authoring helpers
|
|
# ---------------------------------------------------------------------------
|
|
class TestAuthoringHelpers(unittest.TestCase):
|
|
|
|
def test_profile_name_validation(self):
|
|
self.assertTrue(gitea_config.is_valid_profile_name("prgs-reviewer"))
|
|
self.assertTrue(gitea_config.is_valid_profile_name("prgs.author_1"))
|
|
for bad in ("", "-leading", "has space", "a/b", "b@d"):
|
|
self.assertFalse(gitea_config.is_valid_profile_name(bad), bad)
|
|
|
|
def test_add_profile_preserves_existing(self):
|
|
cfg = gitea_config.empty_config()
|
|
cfg = gitea_config.add_profile(cfg, "prgs-author", gitea_config.build_profile(
|
|
base_url="https://gitea.prgs.cc", auth=gitea_config.env_auth("GITEA_TOKEN_A")))
|
|
cfg = gitea_config.add_profile(cfg, "prgs-reviewer", gitea_config.build_profile(
|
|
base_url="https://gitea.prgs.cc",
|
|
auth=gitea_config.keychain_auth("prgs-reviewer-token")))
|
|
self.assertEqual(sorted(cfg["profiles"]), ["prgs-author", "prgs-reviewer"])
|
|
|
|
def test_add_duplicate_rejected(self):
|
|
cfg = gitea_config.add_profile(gitea_config.empty_config(), "p",
|
|
gitea_config.build_profile(base_url="https://x", auth=gitea_config.env_auth("V")))
|
|
with self.assertRaises(gitea_config.ConfigError):
|
|
gitea_config.add_profile(cfg, "p", gitea_config.build_profile(
|
|
base_url="https://y", auth=gitea_config.env_auth("W")))
|
|
|
|
def test_add_invalid_name_rejected(self):
|
|
with self.assertRaises(gitea_config.ConfigError):
|
|
gitea_config.add_profile(gitea_config.empty_config(), "bad name",
|
|
gitea_config.build_profile(base_url="https://x", auth=gitea_config.env_auth("V")))
|
|
|
|
def test_add_missing_base_url_rejected(self):
|
|
with self.assertRaises(gitea_config.ConfigError):
|
|
gitea_config.add_profile(gitea_config.empty_config(), "p",
|
|
{"auth": gitea_config.env_auth("V")})
|
|
|
|
def test_validate_reports_missing_fields(self):
|
|
cfg = {"version": 1, "profiles": {
|
|
"noauth": {"base_url": "https://x"},
|
|
"nobase": {"auth": gitea_config.env_auth("V")},
|
|
"inline": {"base_url": "https://x", "auth": gitea_config.env_auth("V"),
|
|
"token": "leak"},
|
|
}}
|
|
problems = gitea_config.validate_config(cfg)
|
|
joined = "\n".join(problems)
|
|
self.assertIn("noauth", joined)
|
|
self.assertIn("nobase", joined)
|
|
self.assertIn("inline", joined)
|
|
self.assertNotIn("leak", joined) # inline secret value never echoed
|
|
|
|
def test_build_profile_omits_empty(self):
|
|
p = gitea_config.build_profile(base_url="https://x",
|
|
auth=gitea_config.env_auth("V"), username="")
|
|
self.assertNotIn("username", p)
|
|
self.assertNotIn("default_owner", p)
|
|
|
|
|
|
class TestAtomicSave(unittest.TestCase):
|
|
|
|
def test_save_creates_parents_and_pretty_json(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = os.path.join(d, "nested", "dir", "profiles.json")
|
|
cfg = gitea_config.add_profile(gitea_config.empty_config(), "p",
|
|
gitea_config.build_profile(base_url="https://x",
|
|
auth=gitea_config.env_auth("V")))
|
|
gitea_config.save_config(cfg, path)
|
|
self.assertTrue(os.path.isfile(path))
|
|
with open(path, encoding="utf-8") as fh:
|
|
body = fh.read()
|
|
self.assertEqual(json.loads(body), cfg)
|
|
self.assertIn("\n ", body) # pretty-printed
|
|
# No leftover temp files in the directory.
|
|
leftovers = [n for n in os.listdir(os.path.dirname(path))
|
|
if n.startswith(".profiles-")]
|
|
self.assertEqual(leftovers, [])
|
|
|
|
def test_save_is_atomic_via_replace(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = os.path.join(d, "profiles.json")
|
|
gitea_config.save_config(gitea_config.empty_config(), path)
|
|
with patch("gitea_config.os.replace", side_effect=OSError("boom")):
|
|
with self.assertRaises(OSError):
|
|
gitea_config.save_config(
|
|
gitea_config.add_profile(gitea_config.empty_config(), "p",
|
|
gitea_config.build_profile(base_url="https://x",
|
|
auth=gitea_config.env_auth("V"))), path)
|
|
# Original file intact; no temp debris.
|
|
with open(path, encoding="utf-8") as fh:
|
|
self.assertEqual(json.load(fh), gitea_config.empty_config())
|
|
leftovers = [n for n in os.listdir(d) if n.startswith(".profiles-")]
|
|
self.assertEqual(leftovers, [])
|
|
|
|
|
|
class TestKeychainSet(unittest.TestCase):
|
|
|
|
def test_stores_token_via_security_without_printing(self):
|
|
calls = {}
|
|
|
|
class _Proc:
|
|
returncode = 0
|
|
|
|
def runner(cmd, **kw):
|
|
calls["cmd"] = cmd
|
|
return _Proc()
|
|
|
|
out = _Out()
|
|
rv = gitea_config.keychain_set("prgs-reviewer-token", SECRET, runner=runner)
|
|
self.assertEqual(rv, "prgs-reviewer-token")
|
|
self.assertEqual(calls["cmd"][0], "security")
|
|
self.assertIn(SECRET, calls["cmd"]) # token handed to security...
|
|
self.assertNotIn(SECRET, out.text) # ...never printed by us
|
|
|
|
def test_failure_raises_without_token(self):
|
|
class _Proc:
|
|
returncode = 1
|
|
|
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
|
gitea_config.keychain_set("id", SECRET, runner=lambda *a, **k: _Proc())
|
|
self.assertNotIn(SECRET, str(ctx.exception))
|
|
|
|
def test_empty_token_rejected(self):
|
|
with self.assertRaises(gitea_config.ConfigError):
|
|
gitea_config.keychain_set("id", "", runner=lambda *a, **k: None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Launcher snippets
|
|
# ---------------------------------------------------------------------------
|
|
class TestLauncherSnippets(unittest.TestCase):
|
|
|
|
def test_only_safe_keys_no_secrets(self):
|
|
entry = gitea_config.launcher_entry("prgs", "/cfg/profiles.json")["gitea-tools"]
|
|
self.assertEqual(set(entry), {"command", "args", "env"})
|
|
self.assertEqual(set(entry["env"]), {"GITEA_MCP_CONFIG", "GITEA_MCP_PROFILE"})
|
|
self.assertEqual(entry["env"]["GITEA_MCP_PROFILE"], "prgs")
|
|
blob = json.dumps(entry).lower()
|
|
for word in ("token", "password", "secret"):
|
|
self.assertNotIn(word, blob)
|
|
|
|
def test_snippets_for_all_clients(self):
|
|
snips = menu.launcher_snippets("prgs", "/cfg/profiles.json")
|
|
self.assertEqual(set(snips), {"claude", "gemini", "codex"})
|
|
for text in snips.values():
|
|
self.assertIn("GITEA_MCP_PROFILE", text)
|
|
self.assertNotIn("token", text.lower())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth test + reviewer eligibility (mocked API + token resolution)
|
|
# ---------------------------------------------------------------------------
|
|
class TestIdentityAndEligibility(unittest.TestCase):
|
|
|
|
def _profile(self):
|
|
return {"base_url": "https://gitea.prgs.cc",
|
|
"default_owner": "Org", "default_repo": "Repo",
|
|
"auth": gitea_config.env_auth("GITEA_TOKEN_X")}
|
|
|
|
def test_resolve_identity(self):
|
|
def api(m, url, auth):
|
|
return {"login": "reviewer-bot"} if url.endswith("/user") else {}
|
|
who = menu.resolve_identity(self._profile(),
|
|
resolve_token=lambda p: "tok", api_request=api)
|
|
self.assertEqual(who, "reviewer-bot")
|
|
|
|
def test_eligible_when_open_and_not_author(self):
|
|
def api(m, url, auth):
|
|
if url.endswith("/user"):
|
|
return {"login": "reviewer-bot"}
|
|
return {"user": {"login": "author-bot"}, "state": "open"}
|
|
r = menu.check_eligibility(self._profile(), 8,
|
|
resolve_token=lambda p: "tok", api_request=api)
|
|
self.assertTrue(r["eligible"])
|
|
self.assertEqual(r["authenticated_user"], "reviewer-bot")
|
|
self.assertEqual(r["pr_author"], "author-bot")
|
|
|
|
def test_ineligible_when_self_author(self):
|
|
def api(m, url, auth):
|
|
if url.endswith("/user"):
|
|
return {"login": "same-bot"}
|
|
return {"user": {"login": "same-bot"}, "state": "open"}
|
|
r = menu.check_eligibility(self._profile(), 8,
|
|
resolve_token=lambda p: "tok", api_request=api)
|
|
self.assertFalse(r["eligible"])
|
|
self.assertTrue(any("author" in x for x in r["reasons"]))
|
|
|
|
def test_ineligible_when_closed(self):
|
|
def api(m, url, auth):
|
|
if url.endswith("/user"):
|
|
return {"login": "reviewer-bot"}
|
|
return {"user": {"login": "author-bot"}, "state": "closed"}
|
|
r = menu.check_eligibility(self._profile(), 8,
|
|
resolve_token=lambda p: "tok", api_request=api)
|
|
self.assertFalse(r["eligible"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prompt flow + full menu loop (no real IO/keychain/network)
|
|
# ---------------------------------------------------------------------------
|
|
class TestMenuFlow(unittest.TestCase):
|
|
|
|
def test_prompt_keychain_stores_but_profile_has_no_token(self):
|
|
stored = {}
|
|
answers = ["prgs-reviewer", "https://gitea.prgs.cc", "jcwalker3",
|
|
"Org", "Repo", "personal-prgs", "keychain",
|
|
"prgs-reviewer-token", "y"]
|
|
out = _Out()
|
|
name, profile = menu._prompt_profile(
|
|
_InputQueue(answers), secret_fn=lambda _p: SECRET, out=out,
|
|
keychain_set=lambda i, t: stored.update({i: t}))
|
|
self.assertEqual(name, "prgs-reviewer")
|
|
self.assertEqual(profile["auth"], {"type": "keychain", "id": "prgs-reviewer-token"})
|
|
self.assertNotIn(SECRET, json.dumps(profile)) # token not in profile
|
|
self.assertNotIn(SECRET, out.text) # token not printed
|
|
self.assertEqual(stored, {"prgs-reviewer-token": SECRET}) # stored via keychain
|
|
|
|
def test_prompt_env_reference(self):
|
|
answers = ["prgs-author", "https://gitea.prgs.cc", "jcwalker3",
|
|
"", "", "personal-prgs", "env", ""] # blank -> default var name
|
|
name, profile = menu._prompt_profile(
|
|
_InputQueue(answers), secret_fn=lambda _p: SECRET, out=_Out(),
|
|
keychain_set=lambda *a: None)
|
|
self.assertEqual(profile["auth"], {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"})
|
|
|
|
def test_menu_add_then_quit_writes_config_without_secrets(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = os.path.join(d, "profiles.json")
|
|
answers = [
|
|
"2", # add profile
|
|
"prgs-reviewer", "https://gitea.prgs.cc", "jcwalker3",
|
|
"Org", "Repo", "personal-prgs", "keychain",
|
|
"prgs-reviewer-token", "y",
|
|
"1", # list
|
|
"0", # quit
|
|
]
|
|
out = _Out()
|
|
rc = menu.main(
|
|
input_fn=_InputQueue(answers), secret_fn=lambda _p: SECRET,
|
|
out=out, config_path=path, keychain_set=lambda *a: None)
|
|
self.assertEqual(rc, 0)
|
|
self.assertTrue(os.path.isfile(path))
|
|
with open(path, encoding="utf-8") as fh:
|
|
body = fh.read()
|
|
self.assertIn("prgs-reviewer", body)
|
|
self.assertIn("prgs-reviewer-token", body) # keychain id (non-secret)
|
|
self.assertNotIn(SECRET, body) # never the token value
|
|
self.assertNotIn(SECRET, out.text)
|
|
self.assertIn("prgs-reviewer", out.text) # listed
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|