Close the #714 remediation gap left at 943d402 where env-only mutation
profiles, REMOTES Timesheet defaults treated as explicit caller input, and
machine-dependent Git remotes produced false greens.
- Fail closed when mutations lack a config-backed profile with non-empty
allowed_repositories; env ops cannot invent mutation authority.
- Preserve omitted-vs-explicit org/repo provenance through the mutation
gate; omitted targets bind to the verified workspace repository.
- Prefer workspace-aligned Git remotes over historical Timesheet defaults
in MCP _resolve and CLI resolve_remote.
- Centralize deterministic config-backed mutation fixtures; migrate
mutation tests off env-only authority without weakening security
assertions.
Full suite: 2744 passed, 6 skipped.
484 lines
16 KiB
Python
484 lines
16 KiB
Python
"""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
|