"""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" 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"})