Files
AegisGitea-MCP/tests/integration/conftest.py
T

366 lines
13 KiB
Python

"""Opt-in fixtures for the live-Gitea integration tests.
Tests in this directory are marked ``integration`` and skipped unless
``AEGIS_INTEGRATION=1``. The default test run never touches Docker.
"""
from __future__ import annotations
import asyncio
import base64
import json
import os
import re
from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import pytest
from mcp import Client
from aegis_gitea_mcp.audit import AuditLogger
from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.server import build_server
from tests.integration.harness import (
GiteaInstance,
HarnessError,
start_gitea,
stop_gitea,
unique,
)
from tests.integration.sweep_plan import LOG, write_report_if_enabled
_HERE = Path(__file__).parent.resolve()
ENABLE_ENV = "AEGIS_INTEGRATION"
REQUIRE_RUNNER_ENV = "AEGIS_IT_REQUIRE_RUNNER"
def runner_required() -> bool:
"""True when a runner that cannot start must fail the test instead of xfailing it.
Set by the CI job (``AEGIS_IT_REQUIRE_RUNNER=1``) and implied by ``CI``: a pipeline that
silently skips the Actions scenario would hide a broken runner setup.
"""
return os.environ.get(REQUIRE_RUNNER_ENV) == "1" or bool(os.environ.get("CI"))
def runner_unavailable(reason: str) -> None:
"""Report that the act_runner could not start or come online (fails or xfails)."""
if runner_required():
pytest.fail(f"act_runner unavailable: {reason}", pytrace=False)
pytest.xfail(reason)
def integration_enabled() -> bool:
"""Return True when the opt-in env var is set."""
return os.environ.get(ENABLE_ENV) == "1"
def pytest_configure(config: pytest.Config) -> None:
"""Register the ``integration`` marker (idempotent with pyproject)."""
config.addinivalue_line(
"markers", "integration: live Gitea in Docker, opt-in via AEGIS_INTEGRATION=1"
)
def _is_integration(item: pytest.Item) -> bool:
try:
return _HERE in Path(str(item.path)).resolve().parents
except (OSError, ValueError): # pragma: no cover - defensive
return False
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
"""Mark and (unless opted in) skip every test under tests/integration."""
selected = [item for item in items if _is_integration(item)]
skip = pytest.mark.skip(reason=f"live Gitea tests need {ENABLE_ENV}=1 and Docker")
for item in selected:
item.add_marker(pytest.mark.integration)
if not integration_enabled():
item.add_marker(skip)
if selected and len(selected) == len(items):
# An integration-only run covers almost none of src/, so the unit-test
# coverage gate does not apply to it.
# pytest-cov reads its options from the early-config namespace.
for namespace in (config.option, config.known_args_namespace):
if getattr(namespace, "cov_fail_under", None) is not None:
namespace.cov_fail_under = 0
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
"""Write ``docs/integration-coverage.md`` when ``AEGIS_IT_WRITE_REPORT=1`` and the sweep ran."""
from aegis_gitea_mcp.catalog import load_catalog
catalog = load_catalog()
write_report_if_enabled(catalog, catalog.spec_version)
@pytest.fixture(scope="session")
def gitea() -> Iterator[GiteaInstance]:
"""A throwaway Gitea in Docker, shared by the whole session."""
try:
instance = start_gitea()
except HarnessError as exc:
pytest.fail(f"integration harness could not start Gitea: {exc}", pytrace=False)
try:
yield instance
finally:
stop_gitea(instance)
@pytest.fixture
def unique_name(request: pytest.FixtureRequest) -> str:
"""A unique, Gitea-safe name derived from the test name."""
slug = re.sub(r"[^a-z0-9]+", "-", request.node.name.lower()).strip("-")[:16].strip("-")
return unique(slug or "t")
@dataclass
class RegularUser:
"""A non-admin user with its own full-scope token."""
login: str
password: str = field(repr=False)
token: str = field(repr=False)
@pytest.fixture(scope="session")
def regular_user(gitea: GiteaInstance) -> RegularUser:
"""A non-admin user plus a full-scope token minted for that user."""
login, password = gitea.create_user(gitea.unique("it-user"))
token = gitea.mint_token(login, password, ["all"])
return RegularUser(login=login, password=password, token=token)
# ---------------------------------------------------------------------------
# Calling tools through the MCP server
# ---------------------------------------------------------------------------
@dataclass
class ToolResult:
"""One tool call result: the parsed envelope plus the raw text."""
tool: str
is_error: bool
payload: dict[str, Any]
text: str
@property
def status(self) -> int | None:
"""HTTP status Gitea answered with (None when the call never reached Gitea)."""
error = self.payload.get("error")
if isinstance(error, dict):
status = error.get("status")
return status if isinstance(status, int) else None
status = self.payload.get("status")
return status if isinstance(status, int) else None
@property
def data(self) -> Any:
"""The ``data`` member of a success envelope."""
return self.payload.get("data")
@property
def error_kind(self) -> str | None:
"""The ``error.kind`` of a failed call."""
error = self.payload.get("error")
return error.get("kind") if isinstance(error, dict) else None
@property
def identity(self) -> dict[str, Any]:
"""The ``identity`` object of the envelope."""
identity = self.payload.get("identity")
return identity if isinstance(identity, dict) else {}
class McpSession:
"""A connected in-memory MCP client plus bookkeeping for secret-leak assertions.
Every result text is kept in :attr:`texts` so a test can assert that a secret value
never came back, whatever tool returned it.
"""
def __init__(self, client: Client, audit_path: Path) -> None:
self.client = client
self.audit_path = audit_path
self.texts: list[str] = []
async def call(self, tool: str, /, **arguments: Any) -> ToolResult:
"""Call ``tool`` with ``arguments`` and return the parsed result (never raises)."""
raw = await self.client.call_tool(tool, arguments)
assert raw.content, f"{tool}: empty result"
block = raw.content[0]
text = getattr(block, "text", None)
assert isinstance(text, str), f"{tool}: result is not text"
self.texts.append(text)
payload = json.loads(text)
assert isinstance(payload, dict)
result = ToolResult(tool=tool, is_error=bool(raw.is_error), payload=payload, text=text)
LOG.add(tool, result.status, result.is_error)
return result
async def ok(
self, tool: str, /, *, status: int | tuple[int, ...] = (200, 201, 204), **arguments: Any
) -> ToolResult:
"""Call ``tool`` and assert it succeeded with one of the expected statuses."""
result = await self.call(tool, **arguments)
allowed = (status,) if isinstance(status, int) else status
assert not result.is_error and result.status in allowed, f"{tool} failed: {_brief(result)}"
return result
async def fail(self, tool: str, /, status: int, **arguments: Any) -> ToolResult:
"""Call ``tool`` and assert Gitea refused it with ``status``."""
result = await self.call(tool, **arguments)
assert result.is_error and result.status == status, (
f"{tool} expected HTTP {status}: {_brief(result)}"
)
return result
def audit_entries(self) -> list[dict[str, Any]]:
"""Payloads of the ``tool_invocation`` entries written to the audit log so far."""
lines = self.audit_path.read_text(encoding="utf-8").splitlines()
entries = [json.loads(line) for line in lines if line.strip()]
return [e["payload"] for e in entries if e.get("event_type") == "tool_invocation"]
def audit_text(self) -> str:
"""The whole audit log file as text."""
return self.audit_path.read_text(encoding="utf-8")
def _brief(result: ToolResult) -> str:
"""A short, secret-free description of a result for assertion messages."""
error = result.payload.get("error")
detail = error if isinstance(error, dict) else {"status": result.status}
return json.dumps(detail, ensure_ascii=False)[:400]
def integration_settings(gitea: GiteaInstance, data_dir: Path, **overrides: Any) -> Settings:
"""Server settings for the live instance: admin token, state under ``data_dir``."""
values: dict[str, Any] = {
"gitea_url": gitea.base_url,
"gitea_token": gitea.admin_token,
"data_dir": data_dir,
"audit_log_path": data_dir / "audit.log",
"request_timeout_seconds": 60,
}
values.update(overrides)
return Settings(**values)
class BackgroundContext:
"""Enter an async context manager in its own task and leave it from that same task.
The MCP client uses anyio task groups, which must be exited by the task that entered
them. Async pytest fixtures set up and tear down in different tasks, so the context
lives in a helper task instead.
"""
def __init__(self, manager: Any) -> None:
self._manager = manager
self._ready = asyncio.Event()
self._stop = asyncio.Event()
self._value: Any = None
self._error: BaseException | None = None
self._task: asyncio.Task[None] | None = None
async def _run(self) -> None:
try:
async with self._manager as value:
self._value = value
self._ready.set()
await self._stop.wait()
except BaseException as exc:
self._error = exc
raise
finally:
self._ready.set()
async def open(self) -> Any:
"""Enter the context and return its value (raises what entering raised)."""
self._task = asyncio.create_task(self._run())
await self._ready.wait()
if self._error is not None:
raise self._error
return self._value
async def close(self) -> None:
"""Leave the context from the task that entered it."""
self._stop.set()
if self._task is not None:
await self._task
@pytest.fixture
async def mcp(
gitea: GiteaInstance, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> AsyncIterator[McpSession]:
"""An in-memory MCP client on a server running as the admin of the live instance."""
monkeypatch.setenv("GITEA_URL", gitea.base_url)
settings = integration_settings(gitea, tmp_path / "mcp-state")
audit = AuditLogger(settings.resolved_audit_log_path)
server = build_server(settings, audit=audit)
holder = BackgroundContext(Client(server))
try:
client = await holder.open()
yield McpSession(client, settings.resolved_audit_log_path)
finally:
await holder.close()
audit.close()
# ---------------------------------------------------------------------------
# Scenario helpers
# ---------------------------------------------------------------------------
def b64(text: str | bytes) -> str:
"""Base64 text of ``text`` (the contents API and file arguments take base64)."""
raw = text.encode("utf-8") if isinstance(text, str) else text
return base64.b64encode(raw).decode("ascii")
@dataclass(frozen=True)
class RepoRef:
"""A repository created for one test."""
owner: str
name: str
@property
def args(self) -> dict[str, str]:
"""The ``owner`` and ``repo`` tool arguments."""
return {"owner": self.owner, "repo": self.name}
@property
def full(self) -> str:
"""``owner/name``."""
return f"{self.owner}/{self.name}"
@pytest.fixture
def fresh_repo(gitea: GiteaInstance, unique_name: str) -> Iterator[RepoRef]:
"""A public repository with a README on ``main`` owned by the admin, deleted afterwards."""
gitea.create_repo(gitea.admin_login, unique_name)
try:
yield RepoRef(gitea.admin_login, unique_name)
finally:
gitea.api("DELETE", f"/repos/{gitea.admin_login}/{unique_name}")
def drop_org(gitea: GiteaInstance, org: str) -> None:
"""Best effort: delete an organization together with its repositories."""
listing = gitea.api("GET", f"/orgs/{org}/repos", params={"limit": 50})
if listing.status_code == 200:
for repo in listing.json():
gitea.api("DELETE", f"/repos/{org}/{repo['name']}")
gitea.api("DELETE", f"/orgs/{org}")
def drop_user(gitea: GiteaInstance, login: str) -> None:
"""Best effort: purge a user created by a test."""
gitea.api("DELETE", f"/admin/users/{login}", params={"purge": "true"})