test: integration helpers and org, team and repo scenarios through the MCP server
This commit is contained in:
1 parent
f86b9b529c
commit
51c766b231
3 files changed
+541
-3
No files matched your search
@@ -6,14 +6,22 @@ Tests in this directory are marked ``integration`` and skipped unless
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
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,
|
||||
@@ -97,3 +105,231 @@ def regular_user(gitea: GiteaInstance) -> RegularUser:
|
||||
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)
|
||||
return ToolResult(tool=tool, is_error=bool(raw.is_error), payload=payload, text=text)
|
||||
|
||||
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"})
|
||||
Reference in new issue
Block a user