366 lines
13 KiB
Python
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"})
|