"""Tests for profiles.json version 2 *contexts* shape (#120). The canonical machine config uses ``contexts`` / ``profiles`` / ``projects`` / ``rules`` with explicit ``enabled`` flags. Covers: loading + active-profile resolution via GITEA_MCP_PROFILE, fail-closed refusal of disabled profiles / contexts / services / projects, project-to-context mapping, base-URL fallback from the context's gitea block, keychain-only auth references, LLM-safe audit output (no endpoint URLs, no keychain ids, no tokens) with an explicit admin/debug opt-in, v1 compatibility, and the no-silent-fallback rule in gitea_auth.get_auth_header. No network, no real secrets. """ import json import os import sys 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 FAKE_TOKEN = "fake-token-for-tests" # not a real credential def contexts_config(): """A fresh, valid v2 contexts-shape config with enabled/disabled entries.""" return { "version": 2, "contexts": { "prgs": { "enabled": True, "label": "Local / PRGS", "default_owner": "Scaled-Tech-Consulting", "gitea": { "enabled": True, "kind": "gitea", "base_url": "https://gitea.prgs.cc", }, "services": { "jenkins": { "enabled": True, "kind": "jenkins", "label": "PRGS Jenkins", "base_url": "https://jenkins.prgs.cc", "auth": {"type": "keychain", "id": "prgs-jenkins-token"}, "capabilities": ["read"], }, "sentry": { "enabled": False, "kind": "sentry", "label": "PRGS Sentry", "base_url": "", "auth": {"type": "keychain", "id": "prgs-sentry-token"}, "capabilities": ["read"], }, }, }, "lab": { "enabled": False, "gitea": {"enabled": False, "kind": "gitea", "base_url": ""}, "services": { "jenkins": { "enabled": False, "kind": "jenkins", "label": "Lab Jenkins", "base_url": "http://localhost:8080", "auth": {"type": "keychain", "id": "lab-jenkins-token"}, "capabilities": ["read"], }, }, }, }, "profiles": { "prgs-author": { "enabled": True, "context": "prgs", "role": "author", "username": "jcwalker3", "execution_profile": "prgs-author", "audit_label": "prgs-author", "base_url": "https://gitea.prgs.cc", "auth": {"type": "keychain", "id": "prgs-gitea-author-token"}, "allowed_operations": [ "read", "branch", "commit", "push", "open_pr", "comment", ], "forbidden_operations": [ "approve", "request_changes", "merge", ], }, "prgs-reviewer": { "enabled": True, "context": "prgs", "role": "reviewer", "username": "sysadmin", "execution_profile": "prgs-reviewer", "audit_label": "prgs-reviewer", # no base_url on purpose: must fall back to context gitea "auth": {"type": "keychain", "id": "prgs-gitea-reviewer-token"}, "allowed_operations": [ "read", "review", "comment", "approve", "request_changes", "merge", ], "forbidden_operations": [ "branch", "commit", "push", "open_pr", ], }, "retired-author": { "enabled": False, "context": "prgs", "role": "author", "username": "jcwalker3", "base_url": "https://gitea.prgs.cc", "auth": {"type": "keychain", "id": "retired-token-ref"}, "allowed_operations": ["read"], "forbidden_operations": [], }, "lab-author": { "enabled": True, "context": "lab", "role": "author", "username": "jcwalker3", "base_url": "http://localhost:3000", "auth": {"type": "keychain", "id": "lab-gitea-author-token"}, "allowed_operations": ["read"], "forbidden_operations": [], }, }, "projects": { "/repo/one": { "enabled": True, "context": "prgs", "default_owner": "Scaled-Tech-Consulting", "default_repo": "One", "default_author_profile": "prgs-author", "default_reviewer_profile": "prgs-reviewer", }, "/repo/lab": { "enabled": False, "context": "lab", }, }, "rules": { "disabled_behavior": "report in audits, never act", "no_silent_fallback": True, "tokens_in_json": False, "token_storage": "keychain", "hide_service_urls_from_llm": True, "hide_keychain_ids_from_llm": True, "mcp_resolves_endpoints": True, }, } def write_config(data): """Write *data* to a temp JSON file and return its path.""" fd, path = tempfile.mkstemp(suffix=".json") with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(data, fh) return path def load(data): """Load *data* through gitea_config via a temp file, then clean up.""" path = write_config(data) try: return gitea_config.load_config(path) finally: os.unlink(path) class LoadContextsShapeTests(unittest.TestCase): def test_contexts_shape_loads(self): config = load(contexts_config()) self.assertEqual(config["version"], 2) self.assertIn("prgs-author", config["profiles"]) self.assertIn("prgs-reviewer", config["profiles"]) def test_active_profile_resolved_from_env(self): path = write_config(contexts_config()) try: with patch.dict(os.environ, { gitea_config.ENV_CONFIG_PATH: path, gitea_config.ENV_PROFILE: "prgs-author", }): profile = gitea_config.resolve_profile() finally: os.unlink(path) self.assertEqual(profile["username"], "jcwalker3") self.assertEqual(profile["base_url"], "https://gitea.prgs.cc") self.assertEqual(profile["context"], "prgs") def test_base_url_falls_back_to_context_gitea(self): profile = gitea_config.select_profile(load(contexts_config()), "prgs-reviewer") self.assertEqual(profile["base_url"], "https://gitea.prgs.cc") def test_profile_without_any_base_url_is_refused(self): data = contexts_config() del data["profiles"]["prgs-author"]["base_url"] data["contexts"]["prgs"]["gitea"]["enabled"] = False config = load(data) with self.assertRaises(gitea_config.ConfigError): gitea_config.select_profile(config, "prgs-author") def test_v1_config_still_loads(self): config = load({ "version": 1, "profiles": { "prgs": { "base_url": "https://gitea.prgs.cc", "auth": {"type": "keychain", "id": "prgs-gitea-token"}, }, }, }) profile = gitea_config.select_profile(config, "prgs") self.assertEqual(profile["base_url"], "https://gitea.prgs.cc") def test_mixed_contexts_and_environments_rejected(self): data = contexts_config() data["environments"] = {"x": {"services": {}}} with self.assertRaises(gitea_config.ConfigError): load(data) def test_missing_enabled_flag_is_refused(self): data = contexts_config() del data["profiles"]["prgs-author"]["enabled"] with self.assertRaises(gitea_config.ConfigError) as ctx: load(data) self.assertIn("enabled", str(ctx.exception)) class DisabledRefusalTests(unittest.TestCase): def setUp(self): self.config = load(contexts_config()) def test_disabled_profile_refused(self): with self.assertRaises(gitea_config.ConfigError) as ctx: gitea_config.select_profile(self.config, "retired-author") self.assertIn("disabled", str(ctx.exception)) def test_profile_in_disabled_context_refused(self): with self.assertRaises(gitea_config.ConfigError) as ctx: gitea_config.select_profile(self.config, "lab-author") self.assertIn("disabled", str(ctx.exception)) def test_enabled_profile_still_selectable(self): profile = gitea_config.select_profile(self.config, "prgs-author") self.assertEqual(profile["context"], "prgs") def test_disabled_service_refused(self): with self.assertRaises(gitea_config.ConfigError) as ctx: gitea_config.resolve_service(self.config, "prgs", "sentry") self.assertIn("disabled", str(ctx.exception)) def test_enabled_service_resolves_internally_with_auth_reference(self): # Internal resolution keeps the URL + auth reference for MCP's own use; # they must never appear in LLM-facing (audit/summary) output. service = gitea_config.resolve_service(self.config, "prgs", "jenkins") self.assertEqual(service["base_url"], "https://jenkins.prgs.cc") self.assertEqual(service["auth"], {"type": "keychain", "id": "prgs-jenkins-token"}) self.assertNotIn("token", service) def test_service_in_disabled_context_refused(self): with self.assertRaises(gitea_config.ConfigError) as ctx: gitea_config.resolve_service(self.config, "lab", "jenkins") self.assertIn("disabled", str(ctx.exception)) def test_unknown_service_fails_closed(self): with self.assertRaises(gitea_config.ConfigError): gitea_config.resolve_service(self.config, "prgs", "nope") class ProjectMappingTests(unittest.TestCase): def setUp(self): self.config = load(contexts_config()) def test_project_maps_to_context(self): project = gitea_config.project_for_path(self.config, "/repo/one") self.assertEqual(project["context"], "prgs") self.assertEqual(project["default_reviewer_profile"], "prgs-reviewer") def test_unknown_project_returns_none(self): self.assertIsNone( gitea_config.project_for_path(self.config, "/repo/unknown")) def test_disabled_project_refused(self): with self.assertRaises(gitea_config.ConfigError) as ctx: gitea_config.project_for_path(self.config, "/repo/lab") self.assertIn("disabled", str(ctx.exception)) class SecretHandlingTests(unittest.TestCase): def test_inline_profile_token_rejected(self): data = contexts_config() data["profiles"]["prgs-author"]["token"] = FAKE_TOKEN with self.assertRaises(gitea_config.ConfigError) as ctx: load(data) self.assertNotIn(FAKE_TOKEN, str(ctx.exception)) def test_inline_service_token_rejected(self): data = contexts_config() data["contexts"]["prgs"]["services"]["jenkins"]["token"] = FAKE_TOKEN with self.assertRaises(gitea_config.ConfigError) as ctx: load(data) self.assertNotIn(FAKE_TOKEN, str(ctx.exception)) def test_selected_profile_resolves_token_via_keychain(self): profile = gitea_config.select_profile(load(contexts_config()), "prgs-author") token = gitea_config.resolve_token( profile, keychain_lookup=lambda item_id: FAKE_TOKEN if item_id == "prgs-gitea-author-token" else None) self.assertEqual(token, FAKE_TOKEN) class AuditTests(unittest.TestCase): """LLM-facing audit output: enabled/disabled state only — no endpoint URLs, no keychain ids, no token values. Admin opt-in reveals endpoints and auth source names (never token values).""" def setUp(self): self.config = load(contexts_config()) def test_audit_reports_enabled_and_disabled(self): report = gitea_config.audit_config(self.config) profiles = {p["name"]: p for p in report["profiles"]} self.assertTrue(profiles["prgs-author"]["enabled"]) self.assertFalse(profiles["retired-author"]["enabled"]) services = {(s["context"], s["name"]): s for s in report["services"]} self.assertTrue(services[("prgs", "jenkins")]["enabled"]) self.assertFalse(services[("prgs", "sentry")]["enabled"]) self.assertFalse(services[("lab", "jenkins")]["enabled"]) def test_audit_hides_urls_keychain_ids_and_tokens_by_default(self): rendered = json.dumps(gitea_config.audit_config(self.config)) for leaked in ("https://", "http://", "prgs-gitea-author-token", "prgs-jenkins-token", "base_url", FAKE_TOKEN): self.assertNotIn(leaked, rendered) # Auth is reported as a status, not a reference. report = gitea_config.audit_config(self.config) profiles = {p["name"]: p for p in report["profiles"]} self.assertEqual(profiles["prgs-author"]["auth"], "keychain") def test_audit_admin_optin_reveals_endpoints_but_never_tokens(self): report = gitea_config.audit_config(self.config, reveal_endpoints=True) rendered = json.dumps(report) self.assertIn("https://jenkins.prgs.cc", rendered) self.assertIn("keychain:prgs-gitea-author-token", rendered) self.assertNotIn(FAKE_TOKEN, rendered) def test_audit_works_for_v1_config(self): report = gitea_config.audit_config({ "version": 1, "profiles": { "prgs": { "base_url": "https://gitea.prgs.cc", "auth": {"type": "keychain", "id": "prgs-gitea-token"}, }, }, }) profiles = {p["name"]: p for p in report["profiles"]} self.assertTrue(profiles["prgs"]["enabled"]) self.assertEqual(profiles["prgs"]["auth"], "keychain") self.assertNotIn("https://", json.dumps(report)) class ServiceSummaryTests(unittest.TestCase): """Safe one-line summaries for LLM sessions: label + state only.""" def setUp(self): self.config = load(contexts_config()) def test_summaries_show_state_without_urls_or_ids(self): lines = gitea_config.service_summaries( self.config, auth_check=lambda service: True) text = "\n".join(lines) self.assertIn("PRGS Jenkins: enabled, read-only, authenticated", text) self.assertIn("PRGS Sentry: disabled", text) self.assertIn("Lab Jenkins: disabled", text) for leaked in ("https://", "http://", "keychain", "prgs-jenkins-token"): self.assertNotIn(leaked, text) def test_summary_reports_missing_auth_without_secrets(self): lines = gitea_config.service_summaries( self.config, auth_check=lambda service: False) text = "\n".join(lines) self.assertIn("PRGS Jenkins: enabled, read-only, no credential", text) class NoSilentFallbackTests(unittest.TestCase): def test_broken_config_fails_auth_instead_of_falling_back(self): """With GITEA_MCP_CONFIG set but unloadable, auth must fail closed.""" path = write_config({"version": 2}) # invalid: no contexts/environments env = { gitea_config.ENV_CONFIG_PATH: path, gitea_config.ENV_PROFILE: "prgs-author", } try: with patch.dict(os.environ, env, clear=False), \ patch.object(gitea_auth, "get_credentials", return_value=(None, None)): for var in ("GITEA_TOKEN", "GITEA_TOKEN_PRGS", "GITEA_TOKEN_DADESCHOOLS"): os.environ.pop(var, None) with self.assertRaises(gitea_config.ConfigError): gitea_auth.get_auth_header("https://gitea.prgs.cc") finally: os.unlink(path) def test_env_only_users_unaffected(self): """Without GITEA_MCP_CONFIG, a missing token still degrades quietly.""" env = dict(os.environ) env.pop(gitea_config.ENV_CONFIG_PATH, None) with patch.dict(os.environ, env, clear=True), \ patch.object(gitea_auth, "get_credentials", return_value=(None, None)): for var in ("GITEA_TOKEN", "GITEA_TOKEN_PRGS", "GITEA_TOKEN_DADESCHOOLS"): os.environ.pop(var, None) self.assertIsNone( gitea_auth.get_auth_header("https://gitea.prgs.cc")) class ValidateConfigTests(unittest.TestCase): def test_valid_contexts_config_has_no_problems(self): self.assertEqual(gitea_config.validate_config(contexts_config()), []) def test_repo_example_file_validates(self): example = __import__("pathlib").Path(__file__).resolve().parent.parent \ / "gitea-mcp.v2-contexts.example.json" with open(example, encoding="utf-8") as fh: self.assertEqual(gitea_config.validate_config(json.load(fh)), []) def test_broken_contexts_config_reports_problems(self): data = contexts_config() data["profiles"]["prgs-author"]["context"] = "nope" problems = gitea_config.validate_config(data) self.assertTrue(problems) if __name__ == "__main__": unittest.main()