65ea7514d2
Add version-2 support to gitea_config: environment -> service -> identity
hierarchy flattened at load into v1-shaped profiles keyed by the canonical
dotted address {env}.{service}.{identity}, with aliases for legacy names
(mdcps, prgs-author, prgs-reviewer) and service-level defaults inherited by
identities.
Fail-closed validation: missing required version (v1 files must now declare
version: 1), unknown versions, malformed environment/service/identity
structure, dotted segment names, missing base_url, missing auth reference,
inline secrets in identities or auth entries, alias/address selector
conflicts, aliases to unknown targets, and unqualified operations that
cannot be normalized safely. TBD-* usernames fail closed at selection
without blocking other identities in the file.
Reviewer-identity deadlock rule enforced at load: any identity allowed
gitea.pr.approve or gitea.pr.merge must forbid gitea.pr.create and
gitea.branch.push (prevents the PR #102-style self-authored-PR deadlock).
Selector resolution is strict: exact alias -> exact dotted address -> fail
closed; no fuzzy matching. Minimal operation normalization only (the known
v1 unqualified Gitea ops and single-word non-Gitea ops); the full table and
enforcement matrix remain issue #106.
Tests: new tests/test_config_v2.py (29 cases) covering the acceptance
criteria; test_config.py missing-version case flipped to fail-closed per
the issue. resolve_token/auth_source_name proven against flattened v2
profiles.
Refs #100. Closes #103.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
368 lines
15 KiB
Python
368 lines
15 KiB
Python
"""Tests for profiles.json version 2 (#103): environment → service → identity.
|
|
|
|
Covers: v2 loading + flattening, dotted-path and alias resolution with strict
|
|
order (exact alias → exact address → fail closed), legacy v1 names via aliases,
|
|
fail-closed validation (missing/unknown version, malformed hierarchy, ambiguous
|
|
selectors, TBD-* usernames, reviewer-identity deadlock rule, inline secrets,
|
|
missing auth, unnormalizable operations), service-default inheritance, and that
|
|
flattened v2 profiles still work with resolve_token. No network, no secrets.
|
|
"""
|
|
import os
|
|
import sys
|
|
import copy
|
|
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
|
|
|
|
FAKE_TOKEN = "fake-token-for-tests" # not a real credential
|
|
|
|
|
|
def v2_config():
|
|
"""A fresh, valid v2 config exercising both environments."""
|
|
return {
|
|
"version": 2,
|
|
"environments": {
|
|
"prgs": {
|
|
"services": {
|
|
"gitea": {
|
|
"base_url": "https://gitea.prgs.cc",
|
|
"default_owner": "Scaled-Tech-Consulting",
|
|
"identities": {
|
|
"author": {
|
|
"role": "author",
|
|
"username": "jcwalker3",
|
|
"auth": {"type": "keychain",
|
|
"id": "prgs.gitea.author.token"},
|
|
"execution_profile": "prgs-author",
|
|
"audit_label": "prgs-author",
|
|
"allowed_operations": [
|
|
"gitea.read", "gitea.issue.create",
|
|
"gitea.branch.push", "gitea.pr.create",
|
|
],
|
|
"forbidden_operations": [
|
|
"gitea.pr.approve", "gitea.pr.merge",
|
|
],
|
|
},
|
|
"reviewer": {
|
|
"role": "reviewer",
|
|
"username": "sysadmin",
|
|
"auth": {"type": "env",
|
|
"name": "PRGS_REVIEWER_TOKEN"},
|
|
"execution_profile": "prgs-reviewer",
|
|
"audit_label": "prgs-reviewer",
|
|
"default_repo": "Gitea-Tools",
|
|
"allowed_operations": [
|
|
"read", "review", "comment", "approve",
|
|
"request_changes", "merge",
|
|
],
|
|
"forbidden_operations": [
|
|
"gitea.pr.create", "gitea.branch.push",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"mdcps": {
|
|
"services": {
|
|
"gitea": {
|
|
"base_url": "https://gitea.dadeschools.net",
|
|
"identities": {
|
|
"author": {
|
|
"role": "author",
|
|
"username": "913443",
|
|
"auth": {"type": "keychain",
|
|
"id": "mdcps.gitea.author.token"},
|
|
"allowed_operations": ["gitea.read"],
|
|
"forbidden_operations": [
|
|
"gitea.pr.approve", "gitea.pr.merge",
|
|
],
|
|
},
|
|
"reviewer": {
|
|
"role": "reviewer",
|
|
"username": "TBD-second-mdcps-user",
|
|
"auth": {"type": "keychain",
|
|
"id": "mdcps.gitea.reviewer.token"},
|
|
"allowed_operations": [
|
|
"gitea.read", "gitea.pr.approve",
|
|
"gitea.pr.merge",
|
|
],
|
|
"forbidden_operations": [
|
|
"gitea.pr.create", "gitea.branch.push",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
"jenkins": {
|
|
"base_url": "https://jenkins.dadeschools.net",
|
|
"identities": {
|
|
"reader": {
|
|
"role": "reader",
|
|
"username": "svc-jenkins-read",
|
|
"auth": {"type": "keychain",
|
|
"id": "mdcps.jenkins.reader.token"},
|
|
"allowed_operations": ["read", "jenkins.build.read"],
|
|
"forbidden_operations": ["jenkins.build.trigger"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"aliases": {
|
|
"mdcps": "mdcps.gitea.author",
|
|
"prgs-author": "prgs.gitea.author",
|
|
"prgs-reviewer": "prgs.gitea.reviewer",
|
|
},
|
|
}
|
|
|
|
|
|
class _V2Base(unittest.TestCase):
|
|
def setUp(self):
|
|
self._dir = tempfile.TemporaryDirectory()
|
|
self.path = os.path.join(self._dir.name, "profiles.json")
|
|
self._write(v2_config())
|
|
|
|
def tearDown(self):
|
|
self._dir.cleanup()
|
|
|
|
def _write(self, obj):
|
|
with open(self.path, "w", encoding="utf-8") as fh:
|
|
fh.write(obj if isinstance(obj, str) else json.dumps(obj))
|
|
|
|
def _env(self, profile, **extra):
|
|
env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": profile}
|
|
env.update(extra)
|
|
return env
|
|
|
|
def _resolve(self, profile):
|
|
with patch.dict(os.environ, self._env(profile), clear=True):
|
|
return gitea_config.resolve_profile()
|
|
|
|
def _load_raises(self, mutate, needle):
|
|
cfg = v2_config()
|
|
mutate(cfg)
|
|
self._write(cfg)
|
|
with patch.dict(os.environ, self._env("prgs.gitea.author"), clear=True):
|
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
|
gitea_config.resolve_profile()
|
|
self.assertIn(needle, str(ctx.exception))
|
|
return str(ctx.exception)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Happy path: loading, dotted paths, aliases, inheritance
|
|
# ---------------------------------------------------------------------------
|
|
class TestV2Loads(_V2Base):
|
|
|
|
def test_dotted_path_resolution(self):
|
|
p = self._resolve("prgs.gitea.author")
|
|
self.assertEqual(p["base_url"], "https://gitea.prgs.cc")
|
|
self.assertEqual(p["username"], "jcwalker3")
|
|
self.assertEqual(p["profile_path"], "prgs.gitea.author")
|
|
self.assertEqual(p["environment"], "prgs")
|
|
self.assertEqual(p["service"], "gitea")
|
|
self.assertEqual(p["identity"], "author")
|
|
self.assertEqual(p["role"], "author")
|
|
|
|
def test_alias_resolution_legacy_names(self):
|
|
for legacy, addr in (
|
|
("mdcps", "mdcps.gitea.author"),
|
|
("prgs-author", "prgs.gitea.author"),
|
|
("prgs-reviewer", "prgs.gitea.reviewer"),
|
|
):
|
|
p = self._resolve(legacy)
|
|
self.assertEqual(p["profile_path"], addr, legacy)
|
|
|
|
def test_service_defaults_inherit_and_identity_overrides(self):
|
|
author = self._resolve("prgs.gitea.author")
|
|
self.assertEqual(author["default_owner"], "Scaled-Tech-Consulting")
|
|
self.assertNotIn("default_repo", author)
|
|
reviewer = self._resolve("prgs.gitea.reviewer")
|
|
self.assertEqual(reviewer["default_owner"], "Scaled-Tech-Consulting")
|
|
self.assertEqual(reviewer["default_repo"], "Gitea-Tools")
|
|
|
|
def test_unqualified_ops_normalized_minimally(self):
|
|
reviewer = self._resolve("prgs.gitea.reviewer")
|
|
self.assertIn("gitea.pr.merge", reviewer["allowed_operations"])
|
|
self.assertIn("gitea.read", reviewer["allowed_operations"])
|
|
self.assertNotIn("merge", reviewer["allowed_operations"])
|
|
jenkins = self._resolve("mdcps.jenkins.reader")
|
|
self.assertIn("jenkins.read", jenkins["allowed_operations"])
|
|
self.assertIn("jenkins.build.read", jenkins["allowed_operations"])
|
|
|
|
def test_resolve_token_works_on_flattened_profile(self):
|
|
with patch.dict(
|
|
os.environ,
|
|
self._env("prgs.gitea.reviewer", PRGS_REVIEWER_TOKEN=FAKE_TOKEN),
|
|
clear=True,
|
|
):
|
|
profile = gitea_config.resolve_profile()
|
|
self.assertEqual(gitea_config.resolve_token(profile), FAKE_TOKEN)
|
|
|
|
def test_auth_source_name_on_flattened_profile(self):
|
|
p = self._resolve("mdcps.gitea.author")
|
|
self.assertEqual(
|
|
gitea_config.auth_source_name(p), "keychain:mdcps.gitea.author.token"
|
|
)
|
|
|
|
def test_v1_config_still_loads(self):
|
|
self._write({
|
|
"version": 1,
|
|
"profiles": {"prgs": {
|
|
"base_url": "https://gitea.prgs.cc",
|
|
"auth": {"type": "keychain", "id": "prgs-gitea-token"},
|
|
}},
|
|
})
|
|
p = self._resolve("prgs")
|
|
self.assertEqual(p["base_url"], "https://gitea.prgs.cc")
|
|
|
|
def test_validate_config_accepts_valid_v2(self):
|
|
self.assertEqual(gitea_config.validate_config(v2_config()), [])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fail-closed: selectors
|
|
# ---------------------------------------------------------------------------
|
|
class TestV2Selectors(_V2Base):
|
|
|
|
def test_unknown_selector_fails_closed(self):
|
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
|
self._resolve("prgs.gitea") # partial address — no fuzzy matching
|
|
self.assertIn("not found", str(ctx.exception))
|
|
|
|
def test_no_fuzzy_matching_on_near_miss(self):
|
|
with self.assertRaises(gitea_config.ConfigError):
|
|
self._resolve("prgs-reviewers")
|
|
|
|
def test_conflicting_alias_and_address_fails_closed(self):
|
|
def mutate(cfg):
|
|
cfg["aliases"]["prgs.gitea.author"] = "prgs.gitea.reviewer"
|
|
self._load_raises(mutate, "conflicting selector")
|
|
|
|
def test_alias_to_unknown_target_fails_closed(self):
|
|
def mutate(cfg):
|
|
cfg["aliases"]["ghost"] = "prgs.gitea.nope"
|
|
self._load_raises(mutate, "unknown profile")
|
|
|
|
def test_tbd_username_fails_closed_on_selection(self):
|
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
|
self._resolve("mdcps.gitea.reviewer")
|
|
msg = str(ctx.exception)
|
|
self.assertIn("TBD", msg)
|
|
self.assertIn("provision", msg)
|
|
|
|
def test_tbd_identity_does_not_block_other_identities(self):
|
|
# Same file contains the TBD reviewer; author still resolves.
|
|
p = self._resolve("mdcps.gitea.author")
|
|
self.assertEqual(p["username"], "913443")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fail-closed: structure and versions
|
|
# ---------------------------------------------------------------------------
|
|
class TestV2Structure(_V2Base):
|
|
|
|
def test_missing_version_fails_closed(self):
|
|
def mutate(cfg):
|
|
del cfg["version"]
|
|
self._load_raises(mutate, "version")
|
|
|
|
def test_unknown_version_fails_closed(self):
|
|
def mutate(cfg):
|
|
cfg["version"] = 3
|
|
self._load_raises(mutate, "unsupported version")
|
|
|
|
def test_missing_environments_fails_closed(self):
|
|
def mutate(cfg):
|
|
del cfg["environments"]
|
|
self._load_raises(mutate, "environments")
|
|
|
|
def test_malformed_environment_fails_closed(self):
|
|
def mutate(cfg):
|
|
cfg["environments"]["prgs"] = "not-an-object"
|
|
self._load_raises(mutate, "must be a JSON object")
|
|
|
|
def test_missing_services_fails_closed(self):
|
|
def mutate(cfg):
|
|
cfg["environments"]["prgs"]["services"] = {}
|
|
self._load_raises(mutate, "services")
|
|
|
|
def test_missing_identities_fails_closed(self):
|
|
def mutate(cfg):
|
|
cfg["environments"]["prgs"]["services"]["gitea"]["identities"] = {}
|
|
self._load_raises(mutate, "identities")
|
|
|
|
def test_dotted_segment_name_fails_closed(self):
|
|
def mutate(cfg):
|
|
envs = cfg["environments"]
|
|
envs["bad.env"] = copy.deepcopy(envs["prgs"])
|
|
self._load_raises(mutate, "invalid environment name")
|
|
|
|
def test_missing_base_url_fails_closed(self):
|
|
def mutate(cfg):
|
|
svc = cfg["environments"]["prgs"]["services"]["gitea"]
|
|
del svc["base_url"]
|
|
self._load_raises(mutate, "base_url")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fail-closed: identity invariants
|
|
# ---------------------------------------------------------------------------
|
|
class TestV2IdentityInvariants(_V2Base):
|
|
|
|
def _ident(self, cfg, addr="prgs.gitea.author"):
|
|
env, svc, ident = addr.split(".")
|
|
return cfg["environments"][env]["services"][svc]["identities"][ident]
|
|
|
|
def test_missing_auth_fails_closed(self):
|
|
def mutate(cfg):
|
|
del self._ident(cfg)["auth"]
|
|
self._load_raises(mutate, "missing an 'auth' reference")
|
|
|
|
def test_inline_secret_in_identity_rejected(self):
|
|
def mutate(cfg):
|
|
self._ident(cfg)["token"] = "oops-not-a-real-secret"
|
|
msg = self._load_raises(mutate, "inline 'token'")
|
|
self.assertNotIn("oops-not-a-real-secret", msg)
|
|
|
|
def test_inline_secret_in_auth_rejected(self):
|
|
def mutate(cfg):
|
|
self._ident(cfg)["auth"]["password"] = "oops-not-a-real-secret"
|
|
msg = self._load_raises(mutate, "inline 'password'")
|
|
self.assertNotIn("oops-not-a-real-secret", msg)
|
|
|
|
def test_reviewer_deadlock_invariant_enforced(self):
|
|
def mutate(cfg):
|
|
reviewer = self._ident(cfg, "prgs.gitea.reviewer")
|
|
reviewer["forbidden_operations"] = [] # can approve/merge AND create
|
|
msg = self._load_raises(mutate, "deadlock")
|
|
self.assertIn("gitea.pr.create", msg)
|
|
|
|
def test_reviewer_deadlock_applies_to_unqualified_merge(self):
|
|
def mutate(cfg):
|
|
author = self._ident(cfg)
|
|
author["allowed_operations"] = ["merge"] # normalized to gitea.pr.merge
|
|
author["forbidden_operations"] = []
|
|
self._load_raises(mutate, "deadlock")
|
|
|
|
def test_unnormalizable_operation_fails_closed(self):
|
|
def mutate(cfg):
|
|
self._ident(cfg)["allowed_operations"] = ["frobnicate"]
|
|
self._load_raises(mutate, "cannot be normalized")
|
|
|
|
def test_foreign_namespace_operation_fails_closed(self):
|
|
def mutate(cfg):
|
|
reader = self._ident(cfg, "mdcps.jenkins.reader")
|
|
reader["allowed_operations"] = ["gitea.pr.merge"]
|
|
self._load_raises(mutate, "cannot be normalized")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|