Files
Gitea-Tools/tests/test_issue_714_production_first_bind.py
T
sysadmin dc899d23c8 fix(session): require config-backed mutation authority and workspace-bound targets (#714)
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.
2026-07-15 21:13:21 -04:00

510 lines
21 KiB
Python

"""#714: production first-bind pins the workspace-verified repository scope.
These tests drive the real entry points (``gitea_whoami``,
``gitea_get_runtime_context``, ``gitea_resolve_task_capability``,
``gitea_activate_profile``) in production order. They deliberately do not
construct a ``_SessionContext`` directly: the defect they cover is that the
production first-bind path left ``repository``/``org`` unbound, so
``assess_session_context`` skipped its repository/org drift checks and a
same-host mutation against another repository was not blocked.
The trusted repository identity comes from the workspace-aligned git remote
(``Scaled-Tech-Consulting/Gitea-Tools`` for this checkout), never from
``REMOTES`` (whose ``prgs`` default repo is ``Timesheet``) and never from a
caller-supplied ``org``/``repo`` argument.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_config # noqa: E402
import gitea_mcp_server as srv # noqa: E402
import mcp_server # noqa: E402
import session_context_binding as session_ctx # noqa: E402
WORKSPACE_ORG = "Scaled-Tech-Consulting"
WORKSPACE_REPO = "Gitea-Tools"
WORKSPACE_SLUG = f"{WORKSPACE_ORG}/{WORKSPACE_REPO}"
WORKSPACE_URL = f"https://gitea.prgs.cc/{WORKSPACE_SLUG}.git"
_BASE_AUTHOR_OPS = [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.branch.push",
"gitea.repo.commit",
]
def _config(allowed_repositories=None):
profile = {
"enabled": True,
"context": "prgs",
"role": "author",
"username": "jcwalker3",
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
"allowed_operations": list(_BASE_AUTHOR_OPS),
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
],
"execution_profile": "prgs-author",
}
if allowed_repositories is not None:
profile["allowed_repositories"] = list(allowed_repositories)
return {
"version": 2,
# v2-contexts normalizes the config and keeps only rules.* — a
# top-level allow_runtime_switching would be dropped.
"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": {"prgs-author": profile},
}
class _ProductionOrderBase(unittest.TestCase):
"""Real entry points, pinned workspace remote, mocked network only."""
allowed_repositories = [WORKSPACE_SLUG]
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(_config(self.allowed_repositories)))
self._env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": "prgs-author",
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
}
gitea_config._active_profile_override = None
mcp_server._IDENTITY_CACHE.clear()
srv._MUTATION_AUTHORITY = None
# Pin the workspace git remote so the trusted source is deterministic
# and never depends on the developer's checkout layout.
self._remote_url = patch.object(
srv, "_local_git_remote_url", side_effect=self._local_remote_url
)
self._remote_url.start()
def tearDown(self):
self._remote_url.stop()
gitea_config._active_profile_override = None
mcp_server._IDENTITY_CACHE.clear()
srv._MUTATION_AUTHORITY = None
self._dir.cleanup()
def _local_remote_url(self, remote_name):
return WORKSPACE_URL if remote_name == "prgs" else None
def _api(self, method, url, header):
return {
"login": "jcwalker3",
"full_name": "Test",
"id": 1,
"email": "[email protected]",
}
def _live(self):
return patch("gitea_mcp_server.api_request", side_effect=self._api)
class TestProductionFirstBindPinsRepository(_ProductionOrderBase):
def test_whoami_first_bind_is_complete(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
ctx = session_ctx.get_session_context()
self.assertIsNotNone(ctx)
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
self.assertEqual(ctx["remote"], "prgs")
self.assertEqual(ctx["host"], "gitea.prgs.cc")
def test_runtime_context_first_bind_is_complete(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_get_runtime_context(remote="prgs")
ctx = session_ctx.get_session_context()
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
def test_capability_preflight_first_bind_is_complete(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
ctx = session_ctx.get_session_context()
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
def test_activate_profile_binds_complete_context(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
ctx = session_ctx.get_session_context()
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
class TestProductionOrderRepositoryDriftBlocks(_ProductionOrderBase):
def test_whoami_then_same_host_other_repository_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
)
self.assertIsNotNone(blocked)
self.assertTrue(
any("Other-Tools" in r for r in blocked["reasons"]), blocked["reasons"]
)
def test_whoami_then_timesheet_blocks(self):
"""REMOTES['prgs'].repo is Timesheet — a default target, not a scope."""
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs",
org=WORKSPACE_ORG,
repo="Timesheet",
org_explicit=True,
repo_explicit=True,
)
self.assertIsNotNone(blocked)
self.assertTrue(
any("Timesheet" in r for r in blocked["reasons"]), blocked["reasons"]
)
def test_whoami_then_same_host_other_org_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org="Other-Org", repo=WORKSPACE_REPO
)
self.assertIsNotNone(blocked)
self.assertTrue(
any("Other-Org" in r for r in blocked["reasons"]), blocked["reasons"]
)
def test_runtime_context_then_other_repository_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_get_runtime_context(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
)
self.assertIsNotNone(blocked)
def test_capability_preflight_then_other_repository_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
)
self.assertIsNotNone(blocked)
def test_activate_profile_then_other_repository_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs", org="Other-Org", repo="Other-Tools"
)
self.assertIsNotNone(blocked)
def test_cross_host_still_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(remote="dadeschools")
self.assertIsNotNone(blocked)
self.assertTrue(
any("cross-host" in r for r in blocked["reasons"]), blocked["reasons"]
)
def test_authorized_repository_mutation_remains_allowed(self):
"""Normal PR #715 author comment/push path must stay functional."""
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
self.assertIsNone(
srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
)
)
# A bare call with no override resolves to the same bound scope.
self.assertIsNone(srv._session_context_mutation_block(remote="prgs"))
class TestMutationRequestCannotEstablishBinding(_ProductionOrderBase):
def test_caller_values_cannot_establish_binding_on_first_mutation(self):
"""A mutation-first session must not be pinned by request values."""
with patch.dict(os.environ, self._env, clear=False), self._live():
self.assertIsNone(session_ctx.get_session_context())
srv._session_context_mutation_block(
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
)
ctx = session_ctx.get_session_context()
self.assertIsNotNone(ctx)
# Bound to the verified workspace, never to the request values.
self.assertEqual(ctx["org"], WORKSPACE_ORG)
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
def test_mutation_first_with_attacker_values_is_blocked(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
blocked = srv._session_context_mutation_block(
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
)
self.assertIsNotNone(blocked)
def test_later_call_cannot_overwrite_bound_repository(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
before = session_ctx.get_session_context()
for _ in range(3):
srv._session_context_mutation_block(
remote="prgs", org="Other-Org", repo="Other-Tools"
)
self.assertEqual(session_ctx.get_session_context(), before)
class TestUnverifiedWorkspaceFailsClosed(_ProductionOrderBase):
def _local_remote_url(self, remote_name):
return None # no verifiable workspace repository
def test_mutation_blocks_when_workspace_repository_unverified(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
ctx = session_ctx.get_session_context()
self.assertIsNone(ctx["repository"])
blocked = srv._session_context_mutation_block(
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
)
self.assertIsNotNone(blocked)
self.assertTrue(
any(
"no verified workspace repository" in r or "unverified" in r
for r in blocked["reasons"]
),
blocked["reasons"],
)
def test_activate_profile_rejects_unverifiable_workspace(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
self.assertFalse(res.get("success", True))
self.assertEqual(res.get("blocker_kind"), "repository_scope")
class TestUnauthorizedWorkspaceFailsClosed(_ProductionOrderBase):
allowed_repositories = ["Scaled-Tech-Consulting/Some-Other-Project"]
def test_workspace_absent_from_allowlist_blocks_mutation(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
blocked = srv._session_context_mutation_block(remote="prgs")
self.assertIsNotNone(blocked)
self.assertTrue(
any("not authorized" in r for r in blocked["reasons"]),
blocked["reasons"],
)
def test_workspace_absent_from_allowlist_blocks_activation(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
self.assertFalse(res.get("success", True))
self.assertEqual(res.get("blocker_kind"), "repository_scope")
class TestTimesheetNotAuthorizedInThisPhase(_ProductionOrderBase):
"""Cross-project use is paused: only Gitea-Tools is authorized."""
def test_timesheet_workspace_is_rejected(self):
def timesheet_remote(remote_name):
return "https://gitea.prgs.cc/Scaled-Tech-Consulting/Timesheet.git"
with patch.dict(os.environ, self._env, clear=False), self._live():
with patch.object(
srv, "_local_git_remote_url", side_effect=timesheet_remote
):
blocked = srv._session_context_mutation_block(remote="prgs")
self.assertIsNotNone(blocked)
self.assertTrue(
any("not authorized" in r for r in blocked["reasons"]),
blocked["reasons"],
)
class TestConcurrentFirstBindSelectsOneRepository(_ProductionOrderBase):
def test_concurrent_initialization_cannot_change_selected_repository(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
self.assertIsNone(session_ctx.get_session_context())
thread_count = 8
barrier = threading.Barrier(thread_count)
results = []
lock = threading.Lock()
def racer(index: int) -> None:
barrier.wait()
srv._session_context_mutation_block(
remote="prgs", org=f"Racer-Org-{index}", repo=f"Racer-Repo-{index}"
)
with lock:
results.append(session_ctx.get_session_context())
threads = [
threading.Thread(target=racer, args=(i,)) for i in range(thread_count)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
self.assertFalse(any(t.is_alive() for t in threads))
winner = session_ctx.get_session_context()
self.assertEqual(winner["org"], WORKSPACE_ORG)
self.assertEqual(winner["repository"], WORKSPACE_REPO)
self.assertEqual(results, [winner] * thread_count)
class TestRepositoryScopeUnits(unittest.TestCase):
def test_absent_scope_is_not_enforced_for_read_diagnostics(self):
scope = session_ctx.assess_repository_scope(
workspace_slug=WORKSPACE_SLUG, allowed=[], profile_name="legacy"
)
self.assertTrue(scope["proven"])
self.assertFalse(scope["scope_enforced"])
def test_require_scope_blocks_empty_or_missing_allowlist(self):
scope = session_ctx.assess_repository_scope(
workspace_slug=WORKSPACE_SLUG,
allowed=[],
profile_name="legacy",
require_scope=True,
)
self.assertTrue(scope["block"])
self.assertTrue(any("allowed_repositories" in r for r in scope["reasons"]))
def test_declared_scope_authorizes_only_listed_repository(self):
allowed = session_ctx.declared_allowed_repositories(
{"allowed_repositories": [WORKSPACE_SLUG]}
)
self.assertEqual(allowed, [WORKSPACE_SLUG])
self.assertTrue(
session_ctx.assess_repository_scope(
workspace_slug=WORKSPACE_SLUG, allowed=allowed
)["proven"]
)
self.assertTrue(
session_ctx.assess_repository_scope(
workspace_slug="Scaled-Tech-Consulting/Timesheet", allowed=allowed
)["block"]
)
def test_missing_workspace_slug_blocks_when_scope_declared(self):
scope = session_ctx.assess_repository_scope(
workspace_slug=None, allowed=[WORKSPACE_SLUG]
)
self.assertTrue(scope["block"])
def test_override_must_match_binding(self):
self.assertTrue(
session_ctx.assess_repository_override(
requested_org=WORKSPACE_ORG,
requested_repo="Other",
bound_org=WORKSPACE_ORG,
bound_repo=WORKSPACE_REPO,
)["block"]
)
self.assertTrue(
session_ctx.assess_repository_override(
requested_org="Other-Org",
requested_repo=WORKSPACE_REPO,
bound_org=WORKSPACE_ORG,
bound_repo=WORKSPACE_REPO,
)["block"]
)
self.assertTrue(
session_ctx.assess_repository_override(
requested_org=WORKSPACE_ORG,
requested_repo=WORKSPACE_REPO,
bound_org=WORKSPACE_ORG,
bound_repo=WORKSPACE_REPO,
)["proven"]
)
def test_malformed_allowlist_entries_are_ignored_in_non_strict_mode(self):
self.assertEqual(
session_ctx.declared_allowed_repositories(
{"allowed_repositories": ["not-a-slug", 17, None, WORKSPACE_SLUG]}
),
[WORKSPACE_SLUG],
)
def test_strict_mode_rejects_malformed_allowlist_entries(self):
with self.assertRaises(ValueError):
session_ctx.declared_allowed_repositories(
{"allowed_repositories": ["not-a-slug", WORKSPACE_SLUG]},
strict=True,
)
class TestRemotesDefaultNotCallerOverride(_ProductionOrderBase):
def test_resolved_timesheet_default_does_not_block_when_bound_to_workspace(self):
"""Tools historically pass REMOTES-filled Timesheet after _resolve; that
must not be treated as an explicit override against Gitea-Tools."""
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
# Simulate create_issue after resolve: org/repo are REMOTES defaults
blocked = srv._session_context_mutation_block(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Timesheet",
)
self.assertIsNone(blocked, getattr(blocked, "get", lambda *_: blocked)("reasons") if blocked else None)
def test_git_unavailable_blocks_mutation(self):
def no_remote(_name):
return None
with patch.dict(os.environ, self._env, clear=False), self._live():
with patch.object(srv, "_local_git_remote_url", side_effect=no_remote):
blocked = srv._session_context_mutation_block(remote="prgs")
self.assertIsNotNone(blocked)
self.assertTrue(
any(
"workspace" in r.lower() or "allowed_repositories" in r or "unverified" in r
for r in blocked["reasons"]
),
blocked["reasons"],
)
def test_explicit_mismatch_still_blocks(self):
with patch.dict(os.environ, self._env, clear=False), self._live():
srv.gitea_whoami(remote="prgs")
blocked = srv._session_context_mutation_block(
remote="prgs",
org=WORKSPACE_ORG,
repo="Other-Tools",
)
self.assertIsNotNone(blocked)
if __name__ == "__main__":
unittest.main()