Files
prowler/mcp_server/tests/conftest.py
T

265 lines
9.8 KiB
Python

"""Shared fixtures for the Prowler MCP Server test suite.
This module deliberately does not import ``prowler_mcp_server.server`` at module
scope. That import builds every tool and reads the environment, so it must happen
only once the environment is settled. Environment pinning itself lives in
``[tool.pytest_env]`` in ``pyproject.toml``, which is applied before any conftest
or test module is imported; the fixtures here only keep it pinned per test.
Three properties of the runtime shape everything below and are easy to get wrong:
1. ``prowler_app/server.py`` builds every tool at import time. A tool whose
construction raises -- which is what happens with no API key -- is swallowed by
``load_all_tools``, leaving the ``prowler_*`` namespace silently empty. So the
suite pins a fake key rather than stripping the real one.
2. ``BaseTool.__init__`` captured the ``ProwlerAPIClient`` singleton by reference
at import time. Evicting it from the registry does not re-point the tools, so
the client must be patched in place.
3. ``ProwlerAppAuth`` resolves ``PROWLER_MCP_TRANSPORT_MODE`` and ``API_BASE_URL``
in its default arguments, which are evaluated once at module import.
``monkeypatch.setenv`` cannot change them -- pass ``mode=``/``base_url=``
explicitly instead.
"""
import socket
from collections.abc import Callable, Iterator
import httpx
import pytest
from starlette.requests import Request
from starlette.testclient import TestClient
from tests.helpers.http import MockRouter
from tests.helpers.tokens import FAKE_API_KEY
# Must match [tool.pytest_env] in pyproject.toml: the env var is what the code
# reads at import time, this constant is what tests assert against.
TEST_API_BASE_URL = "https://api.testing.invalid/api/v1"
# --------------------------------------------------------------- environment
@pytest.fixture(autouse=True)
def _pinned_environment(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pin the runtime environment to deterministic test values.
Pinned rather than stripped: a missing ``PROWLER_API_KEY`` collapses the
``prowler_*`` namespace to zero tools instead of failing loudly.
``PROWLER_APP_API_KEY`` is the deprecated fallback and is removed so only a
test that sets it exercises that path.
This also stops a developer's gitignored ``mcp_server/.env`` or shell
environment from reaching the suite.
"""
monkeypatch.setenv("PROWLER_API_KEY", FAKE_API_KEY)
monkeypatch.setenv("API_BASE_URL", TEST_API_BASE_URL)
monkeypatch.setenv("PROWLER_MCP_TRANSPORT_MODE", "stdio")
monkeypatch.delenv("PROWLER_APP_API_KEY", raising=False)
@pytest.fixture(autouse=True)
def _no_real_network(monkeypatch: pytest.MonkeyPatch) -> None:
"""Fail loudly on any real outbound socket connection.
The subject under test is an HTTP client, so a route that was not mocked must
fail fast and obviously rather than quietly reaching hub.prowler.com and
making the suite slow, flaky and dependent on someone else's uptime.
In-process transports (Starlette's ``TestClient``, fastmcp's in-memory
client) do not open sockets, so this does not interfere with them.
"""
def _blocked(self: socket.socket, address: object, *_: object) -> None:
raise RuntimeError(
f"Blocked a real network connection to {address}. Drive HTTP through "
"the mock_api_client, hub_router or docs_router fixtures."
)
monkeypatch.setattr(socket.socket, "connect", _blocked)
monkeypatch.setattr(socket.socket, "connect_ex", _blocked)
# ----------------------------------------------------------------- API client
@pytest.fixture(autouse=True)
def _singleton_registry_guard() -> Iterator[None]:
"""Snapshot and restore the singleton registry around every test.
Deliberately a snapshot, not a clear. ``BaseTool.__init__`` captured the
``ProwlerAPIClient`` instance by reference at import time, so evicting it
would leave every registered tool pointing at an orphan that later fixtures
cannot patch -- one holding a real ``httpx.AsyncClient``. Restoring keeps a
test that resets on purpose from leaking into the next one.
"""
from prowler_mcp_server.prowler_app.utils.api_client import SingletonMeta
snapshot = dict(SingletonMeta._instances)
try:
yield
finally:
SingletonMeta._instances.clear()
SingletonMeta._instances.update(snapshot)
@pytest.fixture
def mock_router() -> MockRouter:
"""An empty route registry and request recorder for this test."""
return MockRouter()
@pytest.fixture
def api_client():
"""The live ``ProwlerAPIClient`` singleton that every registered tool holds."""
from prowler_mcp_server.prowler_app.utils.api_client import ProwlerAPIClient
return ProwlerAPIClient()
@pytest.fixture
def mock_api_client(api_client, mock_router: MockRouter) -> Iterator:
"""The API client singleton, with its transport driven by ``mock_router``.
Swaps ``.client`` in place rather than constructing a fresh client, so tools
reached through the MCP protocol -- which hold this exact instance -- are
mocked too. Everything else still runs for real: URL joining, query encoding,
auth headers, ``raise_for_status()`` and the JSON:API error unwrapping.
"""
original = api_client.client
api_client.client = httpx.AsyncClient(transport=mock_router.transport, timeout=30.0)
try:
yield api_client
finally:
api_client.client = original
@pytest.fixture
def isolated_api_client() -> Iterator[type]:
"""Evict the singleton so a test can exercise construction semantics.
Only for tests *about* ``ProwlerAPIClient`` itself -- its ``__init__`` or its
singleton identity. Anything reached through a tool must use
``mock_api_client``, because the tools still point at the original instance.
"""
from prowler_mcp_server.prowler_app.utils.api_client import (
ProwlerAPIClient,
SingletonMeta,
)
SingletonMeta._instances.pop(ProwlerAPIClient, None)
yield ProwlerAPIClient
# --------------------------------------------------------------- MCP surface
@pytest.fixture(scope="session")
def mcp_root_server():
"""The mounted root MCP server, imported lazily because importing has effects.
Tests open their own client over this (``async with Client(mcp_root_server)``)
rather than receiving a connected one, because FastMCP warns that holding a
client in a fixture causes hard-to-diagnose event-loop problems.
"""
from prowler_mcp_server.server import prowler_mcp_server
return prowler_mcp_server
@pytest.fixture
def health_client() -> Iterator[TestClient]:
"""An ASGI client over the stateless HTTP app, for the ``/health`` route."""
from prowler_mcp_server.server import app
with TestClient(app) as client:
yield client
@pytest.fixture
def http_request_headers() -> Iterator[Callable[..., None]]:
"""Return a callable that makes ``get_http_headers()`` observe given headers.
In HTTP transport mode ``ProwlerAppAuth`` reads the authorization header
through fastmcp's request context variable. Setting that variable directly is
what lets an auth test run without standing up a real HTTP server.
Underscores in keyword names become hyphens, so ``x_request_id=`` sets
``x-request-id``.
"""
from fastmcp.server.http import _current_http_request
def _set(**headers: str) -> None:
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"path": "/mcp",
"raw_path": b"/mcp",
"root_path": "",
"scheme": "http",
"query_string": b"",
"server": ("testserver", 80),
"client": ("testclient", 50000),
"headers": [
(name.lower().replace("_", "-").encode(), value.encode())
for name, value in headers.items()
],
}
_current_http_request.set(Request(scope))
try:
yield _set
finally:
# Not a token-based reset: an async test calls `_set` inside its task,
# and asyncio gives each task its own copy of the context, so the token
# cannot be reset from here and the task's value is discarded with the
# task anyway. Clearing the value covers the sync-test case, where the
# set would otherwise persist into the next test.
_current_http_request.set(None)
# ------------------------------------------------------- hub / docs sub-servers
def _clone_with_transport(
client: httpx.Client, transport: httpx.MockTransport
) -> httpx.Client:
"""Copy a sync client's base URL and headers onto a mock transport."""
return httpx.Client(
base_url=client.base_url,
headers=dict(client.headers),
transport=transport,
)
@pytest.fixture
def hub_router(monkeypatch: pytest.MonkeyPatch, mock_router: MockRouter) -> MockRouter:
"""Route the Prowler Hub sub-server's two module-level sync clients.
Hub tools are synchronous and reach for these clients by module global, so
they are replaced on the module rather than injected.
"""
from prowler_mcp_server.prowler_hub import server as hub
for name in ("prowler_hub_client", "github_raw_client"):
monkeypatch.setattr(
hub, name, _clone_with_transport(getattr(hub, name), mock_router.transport)
)
return mock_router
@pytest.fixture
def docs_router(monkeypatch: pytest.MonkeyPatch, mock_router: MockRouter) -> MockRouter:
"""Route the documentation search engine's two sync clients."""
from prowler_mcp_server.prowler_documentation import server as docs
engine = docs.prowler_docs_search_engine
for name in ("mintlify_client", "docs_client"):
monkeypatch.setattr(
engine,
name,
_clone_with_transport(getattr(engine, name), mock_router.transport),
)
return mock_router