fix: isolate immutable session context tests (#714)
This commit is contained in:
+117
-56
@@ -11,24 +11,70 @@ substitution is forbidden.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SessionContext:
|
||||
"""One atomic, immutable process-session binding."""
|
||||
|
||||
profile_name: str | None
|
||||
remote: str | None
|
||||
host: str | None
|
||||
identity: str | None
|
||||
repository: str | None
|
||||
org: str | None
|
||||
role_kind: str | None
|
||||
expected_username: str | None
|
||||
source: str
|
||||
pid: int
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"profile_name": self.profile_name,
|
||||
"remote": self.remote,
|
||||
"host": self.host,
|
||||
"identity": self.identity,
|
||||
"repository": self.repository,
|
||||
"org": self.org,
|
||||
"role_kind": self.role_kind,
|
||||
"expected_username": self.expected_username,
|
||||
"source": self.source,
|
||||
"pid": self.pid,
|
||||
}
|
||||
|
||||
|
||||
# Process-local only — never a shared file (same rationale as mutation authority).
|
||||
_SESSION_CONTEXT: dict[str, Any] | None = None
|
||||
# The frozen value prevents partial mutation, while the lock makes first-bind and
|
||||
# sanctioned rebind atomic across concurrent MCP calls.
|
||||
_SESSION_CONTEXT: _SessionContext | None = None
|
||||
_SESSION_CONTEXT_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def clear_session_context() -> None:
|
||||
"""Reset session context (tests / process rebind)."""
|
||||
def _reset_session_context_for_testing() -> None:
|
||||
"""Reset at a pytest test boundary; unavailable to production callers.
|
||||
|
||||
Production sessions transition only through process startup/PID change or
|
||||
the explicit profile-activation rebind. Keeping this helper private and
|
||||
requiring pytest's per-test marker prevents it from becoming an MCP/runtime
|
||||
bypass.
|
||||
"""
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ:
|
||||
raise RuntimeError("session context reset is restricted to pytest boundaries")
|
||||
global _SESSION_CONTEXT
|
||||
_SESSION_CONTEXT = None
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
_SESSION_CONTEXT = None
|
||||
|
||||
|
||||
def get_session_context() -> dict[str, Any] | None:
|
||||
"""Return a shallow copy of the bound context, or None if unbound."""
|
||||
if _SESSION_CONTEXT is None:
|
||||
return None
|
||||
return dict(_SESSION_CONTEXT)
|
||||
"""Return a detached snapshot of the bound context, or None if unbound."""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
if _SESSION_CONTEXT is None:
|
||||
return None
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
def profile_host(profile: dict | None) -> str | None:
|
||||
@@ -97,21 +143,48 @@ def bind_session_context(
|
||||
expected_username: str | None = None,
|
||||
source: str = "bind",
|
||||
) -> dict[str, Any]:
|
||||
"""Bind or re-bind the immutable session context (explicit activate path)."""
|
||||
"""Atomically bind/re-bind context (the explicit activation path)."""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
return _bind_session_context_unlocked(
|
||||
profile_name=profile_name,
|
||||
remote=remote,
|
||||
host=host,
|
||||
identity=identity,
|
||||
repository=repository,
|
||||
org=org,
|
||||
role_kind=role_kind,
|
||||
expected_username=expected_username,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _bind_session_context_unlocked(
|
||||
*,
|
||||
profile_name: str,
|
||||
remote: str | None,
|
||||
host: str | None,
|
||||
identity: str | None,
|
||||
repository: str | None,
|
||||
org: str | None,
|
||||
role_kind: str | None,
|
||||
expected_username: str | None,
|
||||
source: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Store a complete immutable context while the caller holds the lock."""
|
||||
global _SESSION_CONTEXT
|
||||
_SESSION_CONTEXT = {
|
||||
"profile_name": (profile_name or "").strip() or None,
|
||||
"remote": (remote or "").strip() or None,
|
||||
"host": (host or "").strip().lower() or None,
|
||||
"identity": (identity or "").strip() or None,
|
||||
"repository": (repository or "").strip() or None,
|
||||
"org": (org or "").strip() or None,
|
||||
"role_kind": (role_kind or "").strip() or None,
|
||||
"expected_username": (expected_username or "").strip() or None,
|
||||
"source": source,
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
return get_session_context() # type: ignore[return-value]
|
||||
_SESSION_CONTEXT = _SessionContext(
|
||||
profile_name=(profile_name or "").strip() or None,
|
||||
remote=(remote or "").strip() or None,
|
||||
host=(host or "").strip().lower() or None,
|
||||
identity=(identity or "").strip() or None,
|
||||
repository=(repository or "").strip() or None,
|
||||
org=(org or "").strip() or None,
|
||||
role_kind=(role_kind or "").strip() or None,
|
||||
expected_username=(expected_username or "").strip() or None,
|
||||
source=source,
|
||||
pid=os.getpid(),
|
||||
)
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
def seed_session_context_if_unbound(
|
||||
@@ -125,37 +198,17 @@ def seed_session_context_if_unbound(
|
||||
role_kind: str | None = None,
|
||||
expected_username: str | None = None,
|
||||
source: str = "seed",
|
||||
active_profile_override: str | None = None,
|
||||
env_profile: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Bind when unbound, or rebind when the static env profile changes.
|
||||
"""Atomically bind only when this process has no current context.
|
||||
|
||||
Mid-session ``_active_profile_override`` changes that did **not** go
|
||||
through ``gitea_activate_profile`` keep the prior bound context so
|
||||
assess/mutation gates can detect drift and fail closed.
|
||||
A changed environment or an interleaved call is not a session boundary and
|
||||
therefore cannot replace an established binding. A newly started/forked
|
||||
process is recognized by PID; explicit ``gitea_activate_profile`` uses
|
||||
:func:`bind_session_context` as its sanctioned logical-session transition.
|
||||
"""
|
||||
global _SESSION_CONTEXT
|
||||
live = (profile_name or "").strip() or None
|
||||
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.get("pid") != os.getpid():
|
||||
return bind_session_context(
|
||||
profile_name=profile_name,
|
||||
remote=remote,
|
||||
host=host,
|
||||
identity=identity,
|
||||
repository=repository,
|
||||
org=org,
|
||||
role_kind=role_kind,
|
||||
expected_username=expected_username,
|
||||
source=source,
|
||||
)
|
||||
bound_profile = _SESSION_CONTEXT.get("profile_name")
|
||||
if live and bound_profile and live != bound_profile:
|
||||
# Static env profile switch (tests / launcher) without override: rebind.
|
||||
# Override present but bound not updated: leave bound for drift detection.
|
||||
if active_profile_override is None and (
|
||||
env_profile is None or env_profile == live
|
||||
):
|
||||
return bind_session_context(
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.pid != os.getpid():
|
||||
return _bind_session_context_unlocked(
|
||||
profile_name=profile_name,
|
||||
remote=remote,
|
||||
host=host,
|
||||
@@ -164,9 +217,9 @@ def seed_session_context_if_unbound(
|
||||
org=org,
|
||||
role_kind=role_kind,
|
||||
expected_username=expected_username,
|
||||
source=f"{source}-env-rebind",
|
||||
source=source,
|
||||
)
|
||||
return get_session_context() # type: ignore[return-value]
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
def assess_session_context(
|
||||
@@ -187,7 +240,9 @@ def assess_session_context(
|
||||
unbound and ``require_bound`` is true, fails closed.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
ctx = _SESSION_CONTEXT
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
bound = _SESSION_CONTEXT
|
||||
ctx = bound.as_dict() if bound is not None else None
|
||||
if ctx is None or ctx.get("pid") != os.getpid():
|
||||
if require_bound:
|
||||
reasons.append(
|
||||
@@ -307,6 +362,12 @@ def profile_allowed_for_remote(
|
||||
return {"proven": False, "block": True, "reasons": reasons}
|
||||
if not remote:
|
||||
return {"proven": True, "block": False, "reasons": reasons}
|
||||
# Legacy env-only profiles have no configured base URL or v2 context. Their
|
||||
# first call may establish the process remote/host pin; after that,
|
||||
# assess_session_context rejects any drift. Configured v2 profiles still
|
||||
# require positive host/context alignment here.
|
||||
if not profile_host(profile) and not (profile.get("context") or "").strip():
|
||||
return {"proven": True, "block": False, "reasons": reasons}
|
||||
if not profile_matches_remote(profile, remote, remotes, contexts=contexts):
|
||||
p_name = profile.get("profile_name") or profile.get("name") or "(unknown)"
|
||||
p_host = profile_host(profile) or "(none)"
|
||||
@@ -319,7 +380,7 @@ def profile_allowed_for_remote(
|
||||
|
||||
|
||||
def mutation_context_audit_fields(
|
||||
ctx: dict[str, Any] | None = None,
|
||||
ctx: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fields to include in pre-mutation audit records."""
|
||||
data = ctx if ctx is not None else get_session_context()
|
||||
@@ -347,7 +408,7 @@ def mutation_context_audit_fields(
|
||||
|
||||
|
||||
def _assessment(
|
||||
proven: bool, reasons: list[str], ctx: dict[str, Any] | None
|
||||
proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"proven": proven,
|
||||
|
||||
Reference in New Issue
Block a user