"""Centralized config-backed mutation fixture for #714. Mutation tests must opt into this helper explicitly. It never grants mutation authority via environment variables alone. Design rules: - temporary v2 configuration with non-empty ``allowed_repositories`` - profile-scoped operations (not every mutation op for every test) - deterministic workspace remotes (no host Git dependency) - one canonical repository per profile by default - isolated state + cleanup in ``finally`` """ from __future__ import annotations import json import os import tempfile from contextlib import contextmanager from copy import deepcopy from typing import Iterable, Sequence from unittest.mock import patch PRGS_SLUG = "Scaled-Tech-Consulting/Gitea-Tools" PRGS_URL = f"https://gitea.prgs.cc/{PRGS_SLUG}.git" MDCPS_SLUG = "913443/eAgenda" MDCPS_URL = f"https://gitea.dadeschools.net/{MDCPS_SLUG}.git" EXAMPLE_SLUG = "Example-Org/Example-Repo" EXAMPLE_URL = f"https://gitea.example.com/{EXAMPLE_SLUG}.git" TIMESHEET_SLUG = "Scaled-Tech-Consulting/Timesheet" def _author_ops() -> list[str]: return [ "gitea.read", "gitea.issue.create", "gitea.issue.close", "gitea.issue.comment", "gitea.pr.create", "gitea.pr.comment", "gitea.branch.create", "gitea.branch.push", "gitea.repo.commit", ] def _author_forbidden() -> list[str]: return [ "gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes", "gitea.pr.review", ] def _reviewer_ops() -> list[str]: return [ "gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.comment", "gitea.issue.comment", ] def _merger_ops() -> list[str]: return [ "gitea.read", "gitea.pr.merge", "gitea.pr.comment", ] def _profile( *, name: str, context: str, role: str, base_url: str, auth_env: str, allowed_operations: Sequence[str], forbidden_operations: Sequence[str], allowed_repositories: Sequence[str], username: str | None = None, ) -> dict: body = { "enabled": True, "context": context, "role": role, "base_url": base_url, "auth": {"type": "env", "name": auth_env}, "allowed_operations": list(allowed_operations), "forbidden_operations": list(forbidden_operations), "allowed_repositories": list(allowed_repositories), "execution_profile": name, } if username: body["username"] = username return body def build_dual_remote_config( *, allowed_operations: Iterable[str] | None = None, allowed_repositories_prgs: Sequence[str] | None = None, allowed_repositories_mdcps: Sequence[str] | None = None, extra_profiles: dict | None = None, include_example_repo: bool = False, ) -> dict: """Build a minimal dual-host v2 config. Each profile authorizes only its host-canonical repository by default. ``include_example_repo`` adds Example-Org/Example-Repo when a test patches REMOTES to that synthetic unit-test identity. """ ops = list(allowed_operations or _author_ops()) forb = _author_forbidden() prgs_repos = list(allowed_repositories_prgs or [PRGS_SLUG]) mdcps_repos = list(allowed_repositories_mdcps or [MDCPS_SLUG]) if include_example_repo: for repos in (prgs_repos, mdcps_repos): if EXAMPLE_SLUG not in repos: repos.append(EXAMPLE_SLUG) profiles = { "test-author-prgs": _profile( name="test-author-prgs", context="prgs", role="author", base_url="https://gitea.prgs.cc", auth_env="GITEA_TOKEN_TEST", allowed_operations=ops, forbidden_operations=forb, allowed_repositories=prgs_repos, ), "test-author-dadeschools": _profile( name="test-author-dadeschools", context="mdcps", role="author", base_url="https://gitea.dadeschools.net", auth_env="GITEA_TOKEN_TEST", allowed_operations=ops, forbidden_operations=forb, allowed_repositories=mdcps_repos, ), "test-reviewer-prgs": _profile( name="test-reviewer-prgs", context="prgs", role="reviewer", base_url="https://gitea.prgs.cc", auth_env="GITEA_TOKEN_TEST", allowed_operations=_reviewer_ops(), forbidden_operations=[ "gitea.pr.create", "gitea.branch.push", "gitea.pr.merge", "gitea.issue.create", ], allowed_repositories=prgs_repos, ), "test-merger-prgs": _profile( name="test-merger-prgs", context="prgs", role="merger", base_url="https://gitea.prgs.cc", auth_env="GITEA_TOKEN_TEST", allowed_operations=_merger_ops(), forbidden_operations=[ "gitea.pr.create", "gitea.branch.push", "gitea.pr.approve", "gitea.issue.create", ], allowed_repositories=prgs_repos, ), } if extra_profiles: profiles.update(deepcopy(extra_profiles)) # Legacy aliases used by older tests — same host scope as the base profile. for alias, base in ( ("gitea-author", "test-author-prgs"), ("author-test", "test-author-dadeschools"), ("author", "test-author-dadeschools"), ("full-author", "test-author-dadeschools"), ("test-author", "test-author-dadeschools"), ("prgs-author", "test-author-prgs"), ("gitea-reviewer", "test-reviewer-prgs"), ("prgs-reviewer", "test-reviewer-prgs"), ("gitea-merger", "test-merger-prgs"), ("prgs-merger", "test-merger-prgs"), ): if alias not in profiles and base in profiles: clone = deepcopy(profiles[base]) clone["execution_profile"] = alias profiles[alias] = clone return { "version": 2, "rules": {"allow_runtime_switching": True}, "contexts": { "prgs": { "enabled": True, "gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"}, }, "mdcps": { "enabled": True, "gitea": { "enabled": True, "base_url": "https://gitea.dadeschools.net", }, }, }, "profiles": profiles, } def build_v2_config(**kwargs): """Back-compat alias.""" return build_dual_remote_config( allowed_operations=kwargs.get("allowed_operations"), extra_profiles=kwargs.get("extra_profiles"), include_example_repo=bool(kwargs.get("include_example_repo")), ) def inject_allowed_repositories( config: dict, repos: Sequence[str], *, profiles: Sequence[str] | None = None, ) -> dict: """Return a deep copy of *config* with allowlists set on selected profiles.""" out = deepcopy(config) targets = set(profiles) if profiles is not None else set(out.get("profiles") or {}) for name, prof in (out.get("profiles") or {}).items(): if name in targets: prof["allowed_repositories"] = list(repos) return out def ensure_all_profiles_have_scope( config: dict, default_repos: Sequence[str], ) -> dict: """Add non-empty allowed_repositories to every profile that lacks one.""" out = deepcopy(config) for _name, prof in (out.get("profiles") or {}).items(): raw = prof.get("allowed_repositories") if not isinstance(raw, (list, tuple)) or not raw: prof["allowed_repositories"] = list(default_repos) return out @contextmanager def mutation_profile_env( *, profile_name: str = "test-author-dadeschools", allowed_operations: Iterable[str] | None = None, allowed_repositories: Sequence[str] | None = None, workspace_urls: dict | None = None, clear_env: bool = False, extra_env: dict | None = None, extra_profiles: dict | None = None, include_example_repo: bool = False, config: dict | None = None, ): """Context manager: config-backed mutation authority + deterministic remotes.""" import gitea_config import gitea_mcp_server as srv import session_context_binding as session_ctx if config is None: prgs_repos = None mdcps_repos = None if allowed_repositories is not None: # Caller-specified single allowlist applied to both host profiles. prgs_repos = list(allowed_repositories) mdcps_repos = list(allowed_repositories) cfg = build_dual_remote_config( allowed_operations=allowed_operations, allowed_repositories_prgs=prgs_repos, allowed_repositories_mdcps=mdcps_repos, extra_profiles=extra_profiles, include_example_repo=include_example_repo, ) else: cfg = deepcopy(config) if allowed_repositories is not None: cfg = ensure_all_profiles_have_scope(cfg, list(allowed_repositories)) urls = ( {"prgs": PRGS_URL, "dadeschools": MDCPS_URL} if workspace_urls is None else dict(workspace_urls) ) tmp = tempfile.TemporaryDirectory(prefix="gitea-mut-cfg-") try: cfg_path = os.path.join(tmp.name, "profiles.json") with open(cfg_path, "w", encoding="utf-8") as fh: json.dump(cfg, fh) env = { "GITEA_MCP_CONFIG": cfg_path, "GITEA_MCP_PROFILE": profile_name, "GITEA_TOKEN_TEST": "test-token", "PYTEST_CURRENT_TEST": os.environ.get( "PYTEST_CURRENT_TEST", "mutation_profile_env" ), } if extra_env: env.update(extra_env) def _remote_url(name: str): if name in urls: return urls[name] # Prefer live REMOTES (tests often patch Example-Org). try: entry = srv.REMOTES.get(name) or {} host = entry.get("host") org = entry.get("org") repo = entry.get("repo") if host and org and repo: if ( name == "prgs" and org == "Scaled-Tech-Consulting" and repo == "Timesheet" ): return PRGS_URL if name == "dadeschools" and repo == "Timesheet": return MDCPS_URL return f"https://{host}/{org}/{repo}.git" except Exception: pass return None gitea_config._active_profile_override = None try: session_ctx._reset_session_context_for_testing() except Exception: pass payload = { "env": env, "config_path": cfg_path, "profile_name": profile_name, "urls": urls, "config": cfg, } with patch.dict(os.environ, env, clear=clear_env): os.environ.setdefault( "PYTEST_CURRENT_TEST", env.get("PYTEST_CURRENT_TEST", "mutation_profile_env"), ) with patch.object(srv, "_local_git_remote_url", side_effect=_remote_url): mcp_srv = None try: import mcp_server as mcp_srv # type: ignore except Exception: mcp_srv = None if mcp_srv is not None: with patch.object( mcp_srv, "_local_git_remote_url", side_effect=_remote_url ): yield payload else: yield payload finally: try: session_ctx._reset_session_context_for_testing() except Exception: pass gitea_config._active_profile_override = None tmp.cleanup() # Process-lifetime shared config for mass-migrating env-only mutation tests. _SHARED_CFG_PATH = None _SHARED_CFG_DIR = None _SHARED_CFG_WITH_EXAMPLE_PATH = None def shared_mutation_config_path(*, include_example_repo: bool = False) -> str: """Write (once) a dual-remote mutation config and return its path.""" global _SHARED_CFG_PATH, _SHARED_CFG_DIR, _SHARED_CFG_WITH_EXAMPLE_PATH import atexit import shutil if include_example_repo: if _SHARED_CFG_WITH_EXAMPLE_PATH and os.path.isfile( _SHARED_CFG_WITH_EXAMPLE_PATH ): return _SHARED_CFG_WITH_EXAMPLE_PATH if _SHARED_CFG_DIR is None: _SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-") atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True)) path = os.path.join(_SHARED_CFG_DIR, "profiles-with-example.json") with open(path, "w", encoding="utf-8") as fh: json.dump(build_dual_remote_config(include_example_repo=True), fh) _SHARED_CFG_WITH_EXAMPLE_PATH = path return path if _SHARED_CFG_PATH and os.path.isfile(_SHARED_CFG_PATH): return _SHARED_CFG_PATH if _SHARED_CFG_DIR is None: _SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-") atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True)) _SHARED_CFG_PATH = os.path.join(_SHARED_CFG_DIR, "profiles.json") with open(_SHARED_CFG_PATH, "w", encoding="utf-8") as fh: json.dump(build_dual_remote_config(), fh) return _SHARED_CFG_PATH def shared_mutation_env( profile_name: str = "test-author-dadeschools", *, include_example_repo: bool = False, **extra, ) -> dict: """Env dict for mutation tests: config-backed + optional extras. Always carries ``PYTEST_CURRENT_TEST`` so ``patch.dict(..., clear=True)`` does not strip the pytest marker and trip the #695 native-transport wall. """ env = { "GITEA_MCP_CONFIG": shared_mutation_config_path( include_example_repo=include_example_repo ), "GITEA_MCP_PROFILE": profile_name, "GITEA_TOKEN_TEST": "test-token", "PYTEST_CURRENT_TEST": os.environ.get( "PYTEST_CURRENT_TEST", "mutation_profile_env" ), } env.update(extra) # Callers must not be able to drop the pytest marker by accident. env.setdefault( "PYTEST_CURRENT_TEST", os.environ.get("PYTEST_CURRENT_TEST", "mutation_profile_env"), ) return env def install_deterministic_remote_urls() -> None: """Patch server modules so workspace remotes are deterministic. Prefer live ``REMOTES`` entries (so tests that patch Example-Org keep workspace alignment). Map the historical prgs→Timesheet default to Gitea-Tools so session binding matches the control repository under test. """ import gitea_mcp_server as srv def _url(name: str): try: entry = srv.REMOTES.get(name) or {} host = entry.get("host") org = entry.get("org") repo = entry.get("repo") if host and org and repo: if ( name == "prgs" and org == "Scaled-Tech-Consulting" and repo == "Timesheet" ): return PRGS_URL if name == "dadeschools" and repo == "Timesheet": # Prefer mdcps eAgenda canonical for dual-config tests. return MDCPS_URL return f"https://{host}/{org}/{repo}.git" except Exception: pass if name == "prgs": return PRGS_URL if name == "dadeschools": return MDCPS_URL return None srv._local_git_remote_url = _url # type: ignore[method-assign] try: import mcp_server as mcp_srv mcp_srv._local_git_remote_url = _url # type: ignore[method-assign] except Exception: pass